From 9731d11825be7efeb7d94a396563504764df2948 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Fri, 7 Aug 2026 16:07:48 +0800 Subject: [PATCH 01/15] feat: add TRON governance command domains --- ts/README.md | 4 +- ts/docs/commands/contract/clear-abi.md | 35 +++ ts/docs/commands/contract/create2.md | 48 ++++ ts/docs/commands/contract/deploy.md | 18 +- ts/docs/commands/contract/index.md | 6 +- ts/docs/commands/contract/send.md | 9 +- .../contract/set-origin-energy-limit.md | 41 +++ .../contract/set-user-resource-percent.md | 41 +++ ts/docs/commands/index.md | 16 +- ts/docs/commands/proposal/approve.md | 42 +++ ts/docs/commands/proposal/create.md | 45 +++ ts/docs/commands/proposal/delete.md | 35 +++ ts/docs/commands/proposal/index.md | 23 ++ ts/docs/commands/proposal/list.md | 41 +++ ts/docs/commands/proposal/show.md | 37 +++ ts/docs/commands/witness/create.md | 42 +++ ts/docs/commands/witness/index.md | 21 ++ ts/docs/commands/witness/set-brokerage.md | 39 +++ ts/docs/commands/witness/update.md | 35 +++ ts/docs/java-parity-v4.12-governance.md | 37 +++ ts/docs/machine-interface.md | 5 + ...wallet-cli-architecture-source-of-truth.md | 9 +- .../adapters/inbound/cli/commands/contract.ts | 130 ++++++++- .../adapters/inbound/cli/commands/proposal.ts | 122 ++++++++ .../adapters/inbound/cli/commands/shared.ts | 23 ++ .../adapters/inbound/cli/commands/witness.ts | 68 +++++ .../inbound/cli/contracts/envelope.ts | 1 + ts/src/adapters/inbound/cli/help/index.ts | 8 +- .../adapters/inbound/cli/output/envelope.ts | 12 +- ts/src/adapters/inbound/cli/output/index.ts | 34 ++- .../inbound/cli/output/output.test.ts | 12 + .../adapters/inbound/cli/render/governance.ts | 217 ++++++++++++++ ts/src/adapters/inbound/cli/render/index.ts | 2 + ts/src/adapters/inbound/cli/render/scalars.ts | 3 + ts/src/adapters/inbound/cli/render/tx.ts | 18 ++ ts/src/adapters/inbound/cli/shell/index.ts | 24 +- .../inbound/cli/shell/shell.chain.test.ts | 45 +++ .../chain/tron/contract-response.test.ts | 8 + .../outbound/chain/tron/contract-response.ts | 11 +- .../chain/tron/proposal-protobuf.test.ts | 78 +++++ .../outbound/chain/tron/proposal-protobuf.ts | 180 ++++++++++++ .../outbound/chain/tron/tron-responses.ts | 8 +- .../chain/tron/tron.governance.test.ts | 62 ++++ ts/src/adapters/outbound/chain/tron/tron.ts | 249 +++++++++++++++- .../outbound/chain/tron/tx-integrity.ts | 13 +- ts/src/adapters/outbound/config/builtins.ts | 5 + .../application/ports/chain/tron-gateway.ts | 77 ++++- ts/src/application/services/pipeline/index.ts | 16 +- .../services/pipeline/pipeline.test.ts | 21 ++ .../services/transaction-mode.test.ts | 8 + .../application/services/transaction-mode.ts | 17 +- .../application/services/tron-confirmation.ts | 3 +- .../tron/contract-service.governance.test.ts | 109 +++++++ .../use-cases/tron/contract-service.ts | 170 +++++++++-- .../use-cases/tron/governance-transaction.ts | 51 ++++ .../use-cases/tron/proposal-service.test.ts | 118 ++++++++ .../use-cases/tron/proposal-service.ts | 271 ++++++++++++++++++ .../use-cases/tron/witness-service.test.ts | 73 +++++ .../use-cases/tron/witness-service.ts | 150 ++++++++++ ts/src/bootstrap/families/tron.ts | 44 +++ ts/src/domain/address/index.ts | 17 ++ .../governance/chain-parameters.test.ts | 45 +++ ts/src/domain/governance/chain-parameters.ts | 226 +++++++++++++++ ts/src/domain/governance/create2.test.ts | 30 ++ ts/src/domain/governance/create2.ts | 62 ++++ ts/src/domain/types/tx.ts | 6 +- ts/test/contract-deploy.test.ts | 3 +- ts/test/golden.test.ts | 65 ++++- 68 files changed, 3474 insertions(+), 70 deletions(-) create mode 100644 ts/docs/commands/contract/clear-abi.md create mode 100644 ts/docs/commands/contract/create2.md create mode 100644 ts/docs/commands/contract/set-origin-energy-limit.md create mode 100644 ts/docs/commands/contract/set-user-resource-percent.md create mode 100644 ts/docs/commands/proposal/approve.md create mode 100644 ts/docs/commands/proposal/create.md create mode 100644 ts/docs/commands/proposal/delete.md create mode 100644 ts/docs/commands/proposal/index.md create mode 100644 ts/docs/commands/proposal/list.md create mode 100644 ts/docs/commands/proposal/show.md create mode 100644 ts/docs/commands/witness/create.md create mode 100644 ts/docs/commands/witness/index.md create mode 100644 ts/docs/commands/witness/set-brokerage.md create mode 100644 ts/docs/commands/witness/update.md create mode 100644 ts/docs/java-parity-v4.12-governance.md create mode 100644 ts/src/adapters/inbound/cli/commands/proposal.ts create mode 100644 ts/src/adapters/inbound/cli/commands/witness.ts create mode 100644 ts/src/adapters/inbound/cli/render/governance.ts create mode 100644 ts/src/adapters/outbound/chain/tron/proposal-protobuf.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/proposal-protobuf.ts create mode 100644 ts/src/adapters/outbound/chain/tron/tron.governance.test.ts create mode 100644 ts/src/application/use-cases/tron/contract-service.governance.test.ts create mode 100644 ts/src/application/use-cases/tron/governance-transaction.ts create mode 100644 ts/src/application/use-cases/tron/proposal-service.test.ts create mode 100644 ts/src/application/use-cases/tron/proposal-service.ts create mode 100644 ts/src/application/use-cases/tron/witness-service.test.ts create mode 100644 ts/src/application/use-cases/tron/witness-service.ts create mode 100644 ts/src/domain/governance/chain-parameters.test.ts create mode 100644 ts/src/domain/governance/chain-parameters.ts create mode 100644 ts/src/domain/governance/create2.test.ts create mode 100644 ts/src/domain/governance/create2.ts diff --git a/ts/README.md b/ts/README.md index b3300a480..2c161c977 100644 --- a/ts/README.md +++ b/ts/README.md @@ -135,7 +135,9 @@ Every command — including every subcommand — has a reference page; run `wall | Command | Description | |---|---| | [`token`](docs/commands/token/index.md) | Manage the token address book and query tokens ([balance](docs/commands/token/balance.md) · [info](docs/commands/token/info.md) · [add](docs/commands/token/add.md) · [list](docs/commands/token/list.md) · [remove](docs/commands/token/remove.md)) | -| [`contract`](docs/commands/contract/index.md) | Call, send, deploy, and inspect smart contracts ([call](docs/commands/contract/call.md) · [send](docs/commands/contract/send.md) · [deploy](docs/commands/contract/deploy.md) · [info](docs/commands/contract/info.md)) | +| [`contract`](docs/commands/contract/index.md) | Call, deploy, inspect, and govern smart contracts, including energy policy, ABI clearing, and CREATE2 address calculation | +| [`proposal`](docs/commands/proposal/index.md) | Query, create, approve, and cancel chain-parameter proposals | +| [`witness`](docs/commands/witness/index.md) | Register and operate an SR candidacy, including brokerage | | [`stake`](docs/commands/stake/index.md) | Stake / delegate resources & query state ([freeze](docs/commands/stake/freeze.md) · [unfreeze](docs/commands/stake/unfreeze.md) · [withdraw](docs/commands/stake/withdraw.md) · [cancel-unfreeze](docs/commands/stake/cancel-unfreeze.md) · [delegate](docs/commands/stake/delegate.md) · [undelegate](docs/commands/stake/undelegate.md) · [info](docs/commands/stake/info.md) · [delegated](docs/commands/stake/delegated.md)) | | [`vote`](docs/commands/vote/index.md) | Vote for super representatives ([cast](docs/commands/vote/cast.md) · [list](docs/commands/vote/list.md) · [status](docs/commands/vote/status.md)) | | [`reward`](docs/commands/reward/index.md) | Query / withdraw voting rewards ([balance](docs/commands/reward/balance.md) · [withdraw](docs/commands/reward/withdraw.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 af1beaf26..ad1b07e9b 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -6,15 +6,16 @@ Deploy a smart contract. ``` wallet-cli contract deploy --abi --bytecode --fee-limit - [--constructor-sig --params ] - [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] + [--params ] + [--dry-run | --sign-only | --build-only] + [--expiration ] [--permission-id ] [--wait [--wait-timeout ]] [options] ``` ## Description -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 `--constructor-sig` + `--params`. +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 types are read from the ABI; `--params` supplies raw positional values in that order. -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 and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. @@ -25,10 +26,12 @@ Requires an account and the master password via `--password-stdin`; watch-only a | `--abi ` | **Required.** Contract ABI as a JSON array string | | `--bytecode ` | **Required.** Compiled bytecode as hex (0x-prefixed or bare) | | `--fee-limit ` | **Required.** Max energy fee to burn, in SUN | -| `--constructor-sig ` | Constructor signature, e.g. `constructor(uint256)`; omit when no constructor args | -| `--params ` | Constructor args as a JSON array of `{type,value}` | +| `--params ` | Constructor args as a JSON array of raw positional values | | `--dry-run` | Estimate only; excludes `--sign-only` | | `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--build-only` | Build unsigned without signer access or broadcast | +| `--expiration ` | Extend expiry in build/sign-only modes; max 86,400,000 | +| `--permission-id ` | TRON permission group; default 0 | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | @@ -66,6 +69,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 42cea5c76..a90401d06 100644 --- a/ts/docs/commands/contract/send.md +++ b/ts/docs/commands/contract/send.md @@ -7,14 +7,15 @@ State-changing contract call (triggerSmartContract). ``` wallet-cli contract send --contract
--method [--params ] [--call-value-sun ] [--fee-limit ] - [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] + [--dry-run | --sign-only | --build-only] + [--expiration ] [--permission-id ] [--wait [--wait-timeout ]] [options] ``` ## Description 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. @@ -31,6 +32,9 @@ Requires an account and the master password via `--password-stdin`; watch-only a | `--fee-limit ` | Max energy fee to burn, in SUN (default 100000000) | | `--dry-run` | Estimate energy only, no signature/broadcast; excludes `--sign-only` | | `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--build-only` | Build unsigned without signer access or broadcast | +| `--expiration ` | Extend expiry in build/sign-only modes; max 86,400,000 | +| `--permission-id ` | TRON permission group; default 0 | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | @@ -102,6 +106,7 @@ echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkA | `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `feeSun`, `energyUsed`, `result` (`SUCCESS` / `OUT_OF_ENERGY`, etc.), `failed` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee` (`feeModel`, estimated `energy`, `availableEnergy`), unsigned `tx` | | `--sign-only` | `kind`, `mode: "sign-only"`, `signed` (feed to `tx broadcast`), `address` (signer), `txId`, `fee`, `method`, `contract` | +| `--build-only` | `kind`, `mode: "build-only"`, `unsigned`, `unsignedHex`, `method`, `contract` | ## Exit status 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/index.md b/ts/docs/commands/index.md index 130df9032..433b6e0fd 100644 --- a/ts/docs/commands/index.md +++ b/ts/docs/commands/index.md @@ -61,11 +61,25 @@ 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) | ## Staking, voting, rewards | 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) | @@ -110,4 +124,4 @@ Every command — including every subcommand — has its own page, following a f -h, --help / -V, --version ``` -Broadcast (✍️) commands additionally take `--wait` / `--wait-timeout ` (cap default: config `waitTimeoutMs`, built-in 60000) and `--dry-run` / `--sign-only`. +Broadcast (✍️) commands additionally take `--wait` / `--wait-timeout ` (cap default: config `waitTimeoutMs`, built-in 60000) and `--dry-run` / `--sign-only`. Governance writes also support `--build-only`, `--permission-id`, and an optional `--expiration` extension in build/sign-only modes. diff --git a/ts/docs/commands/proposal/approve.md b/ts/docs/commands/proposal/approve.md new file mode 100644 index 000000000..573428072 --- /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 ` | Build/sign-only expiry extension, max 24 h | +| `--permission-id ` | TRON permission group; 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..4944defc7 --- /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 ` | Extend expiry by at most 86,400,000 ms; build/sign-only only | +| `--permission-id ` | TRON permission group; 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/witness/create.md b/ts/docs/commands/witness/create.md new file mode 100644 index 000000000..dc7a0eba9 --- /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 ` | Build/sign-only expiry extension, max 24 h | +| `--permission-id ` | TRON permission group; 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 ec72528fe..481da7d77 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[] | always | Non-fatal notices | +| `meta.pagination` | object | paginated results | `offset`, nullable `limit`, and filtered `total` | | `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. @@ -87,6 +88,7 @@ Common codes at exit **2** (usage — fix the call): | `unknown_command` | No such command | | `output_exists` | Target file already exists and is never overwritten (e.g. `backup --out`) | | `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): @@ -98,6 +100,9 @@ Common codes at exit **1** (execution — runtime failure): | `auth_failed` | Wrong master password (decryption failed) | | `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 | | `history_not_supported` | The endpoint lacks TronGrid history support | 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/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index b20bbcf77..b72c07ced 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/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 366969683..1f816bc9b 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -15,6 +15,29 @@ export const txModeFields = { dryRun: z.boolean().default(false).describe("build and estimate only, with no signature and no broadcast; mutually exclusive with --sign-only"), signOnly: z.boolean().default(false).describe("sign and output the transaction without broadcasting; mutually exclusive with --dry-run; broadcast later with tx broadcast"), }; + +/** 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/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/envelope.ts b/ts/src/adapters/inbound/cli/contracts/envelope.ts index 99cef10ae..d865d69df 100644 --- a/ts/src/adapters/inbound/cli/contracts/envelope.ts +++ b/ts/src/adapters/inbound/cli/contracts/envelope.ts @@ -13,6 +13,7 @@ export interface ChainView { export interface Meta { durationMs: number; warnings: string[]; + pagination?: { offset: number; limit: number | null; total: number }; } 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 067d21c9a..2d4f5fd6e 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -101,7 +101,9 @@ export class HelpService { ["account", "Query on-chain account state", ""], ["token", "Manage the token address book and query tokens", ""], ["tx", "Build, send, broadcast, and inspect transactions", ""], - ["contract", "Call, send, deploy, and inspect smart contracts", ""], + ["contract", "Call, deploy, govern, 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"], @@ -432,7 +434,9 @@ const GROUP_DESCRIPTIONS: Record = { account: "Query on-chain account state.", token: "Manage the token address book and query tokens.", tx: "Build, send, broadcast, and inspect transactions.", - contract: "Call, send, deploy, and inspect smart contracts.", + 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.", 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/output/envelope.ts b/ts/src/adapters/inbound/cli/output/envelope.ts index 14fce6023..d8cbe344f 100644 --- a/ts/src/adapters/inbound/cli/output/envelope.ts +++ b/ts/src/adapters/inbound/cli/output/envelope.ts @@ -27,8 +27,8 @@ function chainView(net: NetworkDescriptor): ChainView { }; } -function meta(durationMs: number, warnings: string[]): Meta { - return { durationMs, warnings }; +function meta(value: Meta): Meta { + return value; } export const OutputEnvelope = { @@ -36,14 +36,14 @@ export const OutputEnvelope = { command: string, net: NetworkDescriptor | undefined, data: unknown, - m: { durationMs: number; warnings: string[] }, + 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 +53,14 @@ export const OutputEnvelope = { command: string, net: NetworkDescriptor | undefined, err: CliErrorEnvelopeShape, - m: { durationMs: number; warnings: string[] }, + 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..6fabf9869 100644 --- a/ts/src/adapters/inbound/cli/output/index.ts +++ b/ts/src/adapters/inbound/cli/output/index.ts @@ -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,32 @@ class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter } } +/** Pagination is envelope metadata in the public JSON contract, while text renderers consume the + * same value from their view model to produce `showing N of total` titles. */ +function extractPagination(data: unknown): { + data: unknown; + pagination?: { offset: number; limit: number | null; total: number }; +} { + 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; + if ( + !Number.isInteger(pagination.offset) || + !(pagination.limit === null || Number.isInteger(pagination.limit)) || + !Number.isInteger(pagination.total) + ) return { data }; + const normalized = { + offset: Number(pagination.offset), + limit: pagination.limit === null ? null : Number(pagination.limit), + total: Number(pagination.total), + }; + 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 6b11fc801..2398f6481 100644 --- a/ts/src/adapters/inbound/cli/output/output.test.ts +++ b/ts/src/adapters/inbound/cli/output/output.test.ts @@ -47,6 +47,18 @@ 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 }); + }); }); describe("createOutputFormatter (text)", () => { 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 041647baa..1d1cd45dc 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -22,6 +22,7 @@ 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" export { FAMILY_RENDER, renderFamily } from "./family.js" @@ -34,6 +35,7 @@ export const TextFormatters = { ...RewardFormatters, ...ChainFormatters, ...MiscFormatters, + ...GovernanceFormatters, } export function renderGenericText(command: string, net: NetworkDescriptor | undefined, data: unknown): string { diff --git a/ts/src/adapters/inbound/cli/render/scalars.ts b/ts/src/adapters/inbound/cli/render/scalars.ts index 8bab44849..768678b3e 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 54fb0ebc4..4042e8a93 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -118,6 +118,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"}` @@ -194,6 +203,15 @@ function actionLabel(kind: TxReceiptKind): string { return "contract send" case "contract-deploy": return "contract deploy" + case "proposal-create": return "proposal create" + case "proposal-approve": return "proposal approve" + case "proposal-delete": return "proposal delete" + case "witness-create": return "witness create" + case "witness-update": return "witness update" + case "witness-set-brokerage": return "witness set-brokerage" + case "contract-clear-abi": return "contract clear-abi" + case "contract-set-origin-energy-limit": return "contract set-origin-energy-limit" + case "contract-set-user-resource-percent": return "contract set-user-resource-percent" case "vote-cast": return "vote cast" case "reward-withdraw": diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index 8421a41ab..75a9492e2 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -149,10 +149,32 @@ async function dispatchNeutral(opts: ShellOptions, path: string[], argv: any): P async function dispatchLogical(opts: ShellOptions, path: string[], argv: any): Promise { const chain = opts.registry.resolveChain(path) - if (chain) return executeChainCommand(opts, chain, argv) + if (chain) { + bindGroupedPositionals(chain.spec, argv) + return executeChainCommand(opts, chain, argv) + } throw new UsageError("unknown_command", `unknown command: ${path.join(" ")}`) } +/** yargs binds the group verb (`proposal show`) but leaves leaf arguments in `argv._` because + * groups are registered once. Project those tail values onto the resolved leaf's declared + * positionals before zod validation. */ +function bindGroupedPositionals(spec: ChainSpec, argv: any): void { + const tail = Array.isArray(argv._) ? argv._.slice(1) : [] + const positionals = spec.positionals ?? [] + if (tail.length > positionals.length) { + throw new UsageError("usage_error", `too many arguments for ${spec.path.join(" ")}`) + } + for (const [index, raw] of tail.entries()) { + const field = positionals[index]?.field + if (!field) continue + if (argv[field] !== undefined && String(argv[field]) !== String(raw)) { + throw new UsageError("invalid_option", `${field} was provided both positionally and as --${camelToKebab(field)}`) + } + argv[field] = raw + } +} + async function executeChainCommand(opts: ShellOptions, def: ChainCommandDefinition, argv: any): Promise { const { globals, deps, targetResolver, caps, streams, formatter, session } = opts const { spec } = def diff --git a/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts index b2fe73c74..5ea47455e 100644 --- a/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts +++ b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts @@ -68,4 +68,49 @@ describe("ChainCommandDefinition dispatch", () => { expect(run.mock.calls[0]![2]).toMatchObject({ number: "123" }); expect(JSON.parse(out[0]!).data).toEqual({ block: { number: "123" } }); }); + + it("binds positional arguments declared by a grouped leaf command", async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), "wallet-cli-group-position-test-")); + const store = new AtomicFileStore(); + const backend = { + isTTY: () => false, + async question() { return ""; }, + async readKey() { return { name: "return" }; }, + write() {}, beginRaw() {}, endRaw() {}, + }; + const prompter = new Prompter(backend); + const out: string[] = []; + const streams = new StreamManager("json", false, (value) => out.push(value)); + const secrets = new SecretResolver(streams, {}, prompter); + const keystore = new Keystore(tmpRoot, store, () => secrets.masterPassword()); + const config = ConfigLoader.load(); + const networkRegistry = new NetworkRegistry(config); + const formatter = createOutputFormatter("json", streams, Date.now()); + const registry = new CommandRegistry(); + const run = vi.fn(async (_ctx, _net, input) => ({ proposal: input.id })); + registry.addChain({ + path: ["proposal", "show"], + network: "optional", wallet: "none", auth: "none", + positionals: [{ field: "id" }], + examples: [], + baseFields: z.object({ id: z.coerce.number().int().positive() }), + }, "tron", { run }); + + const globals = { output: "json" as const, verbose: false, network: "tron:mainnet" }; + const deps = { config, networkRegistry, streams, secrets, keystore, prompter, formatter }; + await buildCli({ + registry, + globals, + deps, + targetResolver: new TargetResolver({ networkRegistry, keystore }), + caps: new CapabilityRegistry(), + streams, + formatter, + session: {} as SessionRef, + }).parseAsync(["proposal", "show", "47"]); + + expect(run).toHaveBeenCalledOnce(); + expect(run.mock.calls[0]![2]).toMatchObject({ id: 47 }); + expect(JSON.parse(out[0]!).data).toEqual({ proposal: 47 }); + }); }); diff --git a/ts/src/adapters/outbound/chain/tron/contract-response.test.ts b/ts/src/adapters/outbound/chain/tron/contract-response.test.ts index 05d47e5a0..3351dbad5 100644 --- a/ts/src/adapters/outbound/chain/tron/contract-response.test.ts +++ b/ts/src/adapters/outbound/chain/tron/contract-response.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { isDeployedContract, normalizeContractResponses } from "./contract-response.js"; +const ORIGIN_HEX = "410000000000000000000000000000000000000000"; +const ORIGIN_BASE58 = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; + describe("normalizeContractResponses", () => { it("normalizes name and ABI entry variants", () => { const contract = { name: "Token", abi: { entrys: [ @@ -20,6 +23,11 @@ describe("normalizeContractResponses", () => { methods: ["owner"], }); }); + + it("normalizes origin_address for deployer authorization", () => { + expect(normalizeContractResponses({ contract_address: ORIGIN_HEX, origin_address: ORIGIN_HEX }, undefined)) + .toMatchObject({ originAddress: ORIGIN_BASE58 }); + }); }); describe("isDeployedContract", () => { diff --git a/ts/src/adapters/outbound/chain/tron/contract-response.ts b/ts/src/adapters/outbound/chain/tron/contract-response.ts index 56c066b76..2dca36dcb 100644 --- a/ts/src/adapters/outbound/chain/tron/contract-response.ts +++ b/ts/src/adapters/outbound/chain/tron/contract-response.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { TronContractMetadata } from "../../../../application/ports/chain/tron-gateway.js"; +import { tronHexToBase58 } from "../../../../domain/address/index.js"; const ContractEntrySchema = z.looseObject({ type: z.string().optional().catch(undefined), @@ -39,5 +40,13 @@ export function normalizeContractResponses(contract: unknown, info: unknown): Tr .filter((entry) => entry.type === "Function" || entry.type === "function") .map((entry) => entry.name) .filter((name): name is string => typeof name === "string" && name.length > 0); - return { name: contractView.name ?? infoView.name, methods, contract, info: info ?? undefined }; + const rawContract = contract && typeof contract === "object" ? contract as Record : {}; + const origin = rawContract.origin_address ?? rawContract.originAddress; + return { + name: contractView.name ?? infoView.name, + methods, + originAddress: origin === undefined ? undefined : tronHexToBase58(origin), + contract, + info: info ?? undefined, + }; } diff --git a/ts/src/adapters/outbound/chain/tron/proposal-protobuf.test.ts b/ts/src/adapters/outbound/chain/tron/proposal-protobuf.test.ts new file mode 100644 index 000000000..3ba1829d4 --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/proposal-protobuf.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { utils as tronUtils } from "tronweb"; +import { + proposalCreatePayloadHex, + proposalCreateTxJsonToPbExact, + updateEnergyLimitTxJsonToPbExact, +} from "./proposal-protobuf.js"; + +const OWNER_HEX = "410000000000000000000000000000000000000000"; + +function transaction(value: number | string) { + return { + visible: false, + raw_data: { + contract: [{ + parameter: { + value: { owner_address: OWNER_HEX, parameters: [{ key: 17, value }] }, + type_url: "type.googleapis.com/protocol.ProposalCreateContract", + }, + type: "ProposalCreateContract", + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + expiration: 2_000_000, + timestamp: 1_000_000, + }, + }; +} + +describe("exact ProposalCreateContract protobuf", () => { + it("is byte-identical to TronWeb for safe integers", () => { + const input = transaction(123_456); + expect(tronUtils.transaction.txPbToRawDataHex(proposalCreateTxJsonToPbExact(input))) + .toBe(tronUtils.transaction.txPbToRawDataHex(tronUtils.transaction.txJsonToPb(input))); + }); + + it("encodes the full Java long without Number rounding", () => { + const payload = proposalCreatePayloadHex(OWNER_HEX, [{ key: 17, value: "9223372036854775807" }]); + // Map entry: key=17, value=Long.MAX_VALUE (ff..ff7f varint). + expect(payload).toContain("120c081110ffffffffffffffff7f"); + }); +}); + +describe("exact UpdateEnergyLimitContract protobuf", () => { + function energyTransaction(value: number | string) { + return { + visible: false, + raw_data: { + contract: [{ + parameter: { + value: { + owner_address: OWNER_HEX, + contract_address: "411111111111111111111111111111111111111111", + origin_energy_limit: value, + }, + type_url: "type.googleapis.com/protocol.UpdateEnergyLimitContract", + }, + type: "UpdateEnergyLimitContract", + }], + ref_block_bytes: "1234", ref_block_hash: "0011223344556677", + expiration: 2_000_000, timestamp: 1_000_000, + }, + }; + } + + it("is byte-identical to TronWeb for safe values", () => { + const input = energyTransaction(50_000_000); + expect(tronUtils.transaction.txPbToRawDataHex(updateEnergyLimitTxJsonToPbExact(input))) + .toBe(tronUtils.transaction.txPbToRawDataHex(tronUtils.transaction.txJsonToPb(input))); + }); + + it("preserves a positive int64 supplied as a decimal string", () => { + const encoded = tronUtils.transaction.txPbToRawDataHex( + updateEnergyLimitTxJsonToPbExact(energyTransaction("9223372036854775807")), + ); + expect(encoded.toLowerCase()).toContain("18ffffffffffffffff7f"); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/proposal-protobuf.ts b/ts/src/adapters/outbound/chain/tron/proposal-protobuf.ts new file mode 100644 index 000000000..0321717c8 --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/proposal-protobuf.ts @@ -0,0 +1,180 @@ +/** Exact protobuf support for ProposalCreateContract's map. + * + * google-protobuf generated this map as Map, so TronWeb rounds values above + * Number.MAX_SAFE_INTEGER before serialization. Java wallet-cli accepts the full positive int64 + * range. We let TronWeb encode the transaction envelope, then replace the Any payload with a + * minimal, exact proposal message encoded from decimal strings. */ +import { TronWeb, utils as tronUtils } from "tronweb"; +import { bytesToHex, concatBytes, hexToBytes } from "@noble/hashes/utils.js"; + +const INT64_MIN = -(1n << 63n); +const INT64_MAX = (1n << 63n) - 1n; + +interface ProposalParameter { + key: string | number; + value: string | number; +} + +type Json = Record; + +export function proposalCreateTxJsonToPbExact(transaction: unknown): any { + const source = transaction as { raw_data?: { contract?: Json[] }; visible?: boolean }; + if (!Array.isArray(source?.raw_data?.contract)) throw new Error("missing proposal transaction contracts"); + const clone = JSON.parse(JSON.stringify(transaction)) as typeof source; + const clonedContracts = clone.raw_data!.contract!; + + const exactPayloads = new Map(); + source.raw_data.contract.forEach((contract, index) => { + if (contract.type !== "ProposalCreateContract") return; + const parameter = asObject(contract.parameter); + const value = asObject(parameter.value); + const entries = proposalEntries(value.parameters); + exactPayloads.set(index, encodeProposalCreate(String(value.owner_address ?? ""), entries)); + + // Feed only safe placeholders into TronWeb. The resulting Any bytes are replaced below; + // every outer field (TAPOS, timestamp, expiration, permission id) remains SDK-encoded. + const clonedParameter = asObject(clonedContracts[index]!.parameter); + const clonedValue = asObject(clonedParameter.value); + clonedValue.parameters = entries.map(({ key }) => ({ key: Number(key), value: 0 })); + }); + + const protobuf = tronUtils.transaction.txJsonToPb(clone as any); + const contracts = protobuf.getRawData().getContractList(); + for (const [index, payload] of exactPayloads) { + contracts[index].getParameter().setValue(payload); + } + return protobuf; +} + +export function proposalCreateTxCheckExact(transaction: unknown): boolean { + const expected = String((transaction as { raw_data_hex?: unknown })?.raw_data_hex ?? "") + .replace(/^0x/, "") + .toLowerCase(); + return expected.length > 0 && + tronUtils.transaction.txPbToRawDataHex(proposalCreateTxJsonToPbExact(transaction)).toLowerCase() === expected; +} + +/** Exact UpdateEnergyLimitContract int64 encoder; TronWeb's builder both narrows to number and + * applies an obsolete 10M policy cap that is not part of the Java protocol builder. */ +export function updateEnergyLimitTxJsonToPbExact(transaction: unknown): any { + const source = transaction as { raw_data?: { contract?: Json[] } }; + if (!Array.isArray(source?.raw_data?.contract)) throw new Error("missing energy-limit transaction contracts"); + const clone = JSON.parse(JSON.stringify(transaction)) as typeof source; + const exactPayloads = new Map(); + source.raw_data.contract.forEach((contract, index) => { + if (contract.type !== "UpdateEnergyLimitContract") return; + const value = asObject(asObject(contract.parameter).value); + exactPayloads.set(index, encodeUpdateEnergyLimit( + String(value.owner_address ?? ""), + String(value.contract_address ?? ""), + decimal(value.origin_energy_limit, "origin energy limit"), + )); + const clonedValue = asObject(asObject(clone.raw_data!.contract![index]!.parameter).value); + clonedValue.origin_energy_limit = 0; + }); + const protobuf = tronUtils.transaction.txJsonToPb(clone as any); + const contracts = protobuf.getRawData().getContractList(); + for (const [index, payload] of exactPayloads) contracts[index].getParameter().setValue(payload); + return protobuf; +} + +export function updateEnergyLimitTxCheckExact(transaction: unknown): boolean { + const expected = String((transaction as { raw_data_hex?: unknown })?.raw_data_hex ?? "") + .replace(/^0x/, "") + .toLowerCase(); + return expected.length > 0 && + tronUtils.transaction.txPbToRawDataHex(updateEnergyLimitTxJsonToPbExact(transaction)).toLowerCase() === expected; +} + +function proposalEntries(value: unknown): ProposalParameter[] { + if (Array.isArray(value)) { + return value.map((entry) => { + const object = asObject(entry); + return { key: decimal(object.key, "parameter id"), value: decimal(object.value, "parameter value") }; + }); + } + if (value && typeof value === "object") { + return Object.entries(value).map(([key, entry]) => ({ + key: decimal(key, "parameter id"), + value: decimal(entry, "parameter value"), + })); + } + throw new Error("proposal parameters must be an array or map"); +} + +function encodeProposalCreate(ownerAddress: string, parameters: ProposalParameter[]): Uint8Array { + const fields: Uint8Array[] = [lengthDelimited(1, addressBytes(ownerAddress))]; + + // jspb.Map serializes one value per key in key order; duplicate assignments use the last value. + const selected = new Map(); + for (const parameter of parameters) { + selected.set(int64(parameter.key, "parameter id"), int64(parameter.value, "parameter value")); + } + const sorted = [...selected.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + for (const [key, value] of sorted) { + const entry = concatBytes(tag(1, 0), varint64(key), tag(2, 0), varint64(value)); + fields.push(lengthDelimited(2, entry)); + } + return concatBytes(...fields); +} + +function encodeUpdateEnergyLimit(owner: string, contract: string, energy: string): Uint8Array { + return concatBytes( + lengthDelimited(1, addressBytes(owner)), + lengthDelimited(2, addressBytes(contract)), + tag(3, 0), + varint64(int64(energy, "origin energy limit")), + ); +} + +function addressBytes(address: string): Uint8Array { + const hex = TronWeb.address.toHex(address).replace(/^0x/, ""); + if (!/^41[0-9a-fA-F]{40}$/.test(hex)) throw new Error("invalid TRON address"); + return hexToBytes(hex); +} + +function lengthDelimited(field: number, value: Uint8Array): Uint8Array { + return concatBytes(tag(field, 2), unsignedVarint(BigInt(value.length)), value); +} + +function tag(field: number, wireType: number): Uint8Array { + return unsignedVarint(BigInt((field << 3) | wireType)); +} + +function varint64(value: bigint): Uint8Array { + return unsignedVarint(BigInt.asUintN(64, value)); +} + +function unsignedVarint(input: bigint): Uint8Array { + let value = input; + const bytes: number[] = []; + do { + let byte = Number(value & 0x7fn); + value >>= 7n; + if (value !== 0n) byte |= 0x80; + bytes.push(byte); + } while (value !== 0n); + return Uint8Array.from(bytes); +} + +function int64(value: string | number, label: string): bigint { + const parsed = BigInt(value); + if (parsed < INT64_MIN || parsed > INT64_MAX) throw new Error(`${label} is outside int64`); + return parsed; +} + +function decimal(value: unknown, label: string): string { + const raw = String(value ?? ""); + if (!/^-?\d+$/.test(raw)) throw new Error(`${label} must be a decimal integer`); + return raw; +} + +function asObject(value: unknown): Json { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("malformed proposal transaction"); + return value as Json; +} + +/** Test/debug helper: exact encoded ProposalCreateContract Any payload as hex. */ +export function proposalCreatePayloadHex(owner: string, parameters: ProposalParameter[]): string { + return bytesToHex(encodeProposalCreate(owner, parameters)); +} diff --git a/ts/src/adapters/outbound/chain/tron/tron-responses.ts b/ts/src/adapters/outbound/chain/tron/tron-responses.ts index 4144fead3..dc06bb21d 100644 --- a/ts/src/adapters/outbound/chain/tron/tron-responses.ts +++ b/ts/src/adapters/outbound/chain/tron/tron-responses.ts @@ -30,7 +30,13 @@ const TronTxInfoSchema = objectish( blockNumber: optNum, fee: optNum, receipt: z - .looseObject({ result: optStr, energy_usage_total: optNum }) + .looseObject({ + result: optStr, + energy_usage_total: optNum, + energy_fee: optNum, + net_usage: optNum, + net_fee: optNum, + }) .optional() .catch(undefined), }), diff --git a/ts/src/adapters/outbound/chain/tron/tron.governance.test.ts b/ts/src/adapters/outbound/chain/tron/tron.governance.test.ts new file mode 100644 index 000000000..8038a5b86 --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/tron.governance.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertTronTxIntegrity } from "./tx-integrity.js"; +import { TronRpcClient } from "./tron.js"; + +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + +describe("TronRpcClient governance builders", () => { + it("locally builds and integrity-binds a 50M origin energy limit", async () => { + const client = new TronRpcClient("http://127.0.0.1:1"); + vi.spyOn(client.tronweb.trx, "getCurrentRefBlockParams").mockResolvedValue({ + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + expiration: 2_000_000, + timestamp: 1_000_000, + }); + const transaction = await client.buildUpdateOriginEnergyLimit(OWNER, CONTRACT, 50_000_000, { permissionId: 2 }); + + expect(transaction.raw_data.contract[0]).toMatchObject({ + type: "UpdateEnergyLimitContract", + Permission_id: 2, + parameter: { value: { origin_energy_limit: 50_000_000 } }, + }); + expect(() => assertTronTxIntegrity(transaction)).not.toThrow(); + }); + + it("preserves and integrity-checks a Java long origin energy limit", async () => { + const client = new TronRpcClient("http://127.0.0.1:1"); + vi.spyOn(client.tronweb.trx, "getCurrentRefBlockParams").mockResolvedValue({ + ref_block_bytes: "1234", ref_block_hash: "0011223344556677", + expiration: 2_000_000, timestamp: 1_000_000, + }); + const transaction = await client.buildUpdateOriginEnergyLimit( + OWNER, CONTRACT, "9223372036854775807", + ); + const value = transaction.raw_data.contract[0]!.parameter.value as unknown as { + origin_energy_limit: unknown; + }; + expect(value.origin_energy_limit).toBe("9223372036854775807"); + expect(() => assertTronTxIntegrity(transaction)).not.toThrow(); + }); + + it("builds and integrity-checks a proposal value above Number.MAX_SAFE_INTEGER", async () => { + const client = new TronRpcClient("http://127.0.0.1:1"); + vi.spyOn(client.tronweb.trx, "getCurrentRefBlockParams").mockResolvedValue({ + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + expiration: 2_000_000, + timestamp: 1_000_000, + }); + const transaction = await client.buildProposalCreate(OWNER, [ + { key: 17, value: "9223372036854775807" }, + ]); + + const value = transaction.raw_data.contract[0]!.parameter.value as unknown as { + parameters: Array<{ value: unknown }>; + }; + expect(value.parameters[0]!.value) + .toBe("9223372036854775807"); + expect(() => assertTronTxIntegrity(transaction)).not.toThrow(); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 079dc4037..75c20e827 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -3,7 +3,7 @@ * Broadcaster port plus TRON-specific reads, TRC10/TRC20, Stake 2.0, and contract operations. * (builtin TRON networks carry an HTTP fullHost; tronweb is HTTP-based.) */ -import { TronWeb } from "tronweb"; +import { TronWeb, utils as tronUtils } from "tronweb"; import type { Types } from "tronweb"; import { isLosslessNumber, parse as parseLosslessJson } from "lossless-json"; import type { BroadcastResult, SignedTx } from "../../../../domain/types/index.js"; @@ -17,6 +17,7 @@ import type { TronDelegatedResource, TronGateway, TronNodeInfo, + TronProposal, TronTokenInfo, TronTx, TronTxInfo, @@ -31,6 +32,10 @@ import { parseTronTx, parseTronTxInfo } from "./tron-responses.js"; import { assertBuiltTx } from "./tx-guard.js"; import { decodeTronTransaction } from "./transaction-decoder.js"; import { isDeployedContract, normalizeContractResponses } from "./contract-response.js"; +import { + proposalCreateTxJsonToPbExact, + updateEnergyLimitTxJsonToPbExact, +} from "./proposal-protobuf.js"; /** a valid base58 owner used as the caller for read-only (constant) contract calls. */ const TRON_READ_OWNER = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; @@ -304,6 +309,120 @@ export class TronRpcClient implements TronGateway, Broadcaster { return witnesses.map(normalizeWitness).filter((w): w is TronWitness => w !== null); }); } + async getWitness(address: string): Promise { + return this.#wrap("getWitnessByAddress", async () => { + const response = await fetch(`${this.#fullHost}/wallet/getwitnessbyaddress`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address: this.#tw.address.toHex(address) }), + signal: AbortSignal.timeout(this.#timeoutMs), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const normalized = normalizeAccountValue(parseLosslessJson(await response.text())); + return normalizeWitness(normalized); + }); + } + async getProposals(): Promise { + return this.#wrap("listProposals", async () => { + const response = await fetch(`${this.#fullHost}/wallet/listproposals`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + signal: AbortSignal.timeout(this.#timeoutMs), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record; + const proposals = Array.isArray(raw.proposals) ? raw.proposals : []; + return proposals.map(normalizeProposal).filter((proposal): proposal is TronProposal => proposal !== null); + }); + } + async getProposal(id: number): Promise { + return this.#wrap("getProposalById", async () => { + const response = await fetch(`${this.#fullHost}/wallet/getproposalbyid`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id }), + signal: AbortSignal.timeout(this.#timeoutMs), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return normalizeProposal(normalizeAccountValue(parseLosslessJson(await response.text()))); + }); + } + async buildProposalCreate( + owner: string, + parameters: Array<{ key: number; value: number | string }>, + options: { permissionId?: number } = {}, + ): Promise { + return this.#wrap("createProposal", async () => assertBuiltTx( + await this.#buildLocalTransaction( + "ProposalCreateContract", + { owner_address: this.#tw.address.toHex(owner), parameters }, + options.permissionId, + ), + "ProposalCreateContract", + )); + } + async buildProposalApprove( + owner: string, + proposalId: number, + addApproval: boolean, + options: { permissionId?: number } = {}, + ): Promise { + return this.#wrap("voteProposal", async () => + assertBuiltTx( + await this.#tw.transactionBuilder.voteProposal(proposalId, addApproval, owner, options), + "ProposalApproveContract", + ), + ); + } + async buildProposalDelete( + owner: string, + proposalId: number, + options: { permissionId?: number } = {}, + ): Promise { + return this.#wrap("deleteProposal", async () => + assertBuiltTx( + await this.#tw.transactionBuilder.deleteProposal(proposalId, owner, options), + "ProposalDeleteContract", + ), + ); + } + async buildWitnessCreate( + owner: string, + url: string, + options: { permissionId?: number } = {}, + ): Promise { + return this.#wrap("applyForSR", async () => + assertBuiltTx( + await this.#tw.transactionBuilder.applyForSR(owner, url, options), + "WitnessCreateContract", + ), + ); + } + async buildWitnessUpdate( + owner: string, + url: string, + options: { permissionId?: number } = {}, + ): Promise { + return this.#wrap("updateWitness", async () => + assertBuiltTx( + await this.#tw.transactionBuilder.updateWitness(owner, url, options), + "WitnessUpdateContract", + ), + ); + } + async buildWitnessSetBrokerage( + owner: string, + brokerage: number, + options: { permissionId?: number } = {}, + ): Promise { + return this.#wrap("updateBrokerage", async () => + assertBuiltTx( + await this.#tw.transactionBuilder.updateBrokerage(brokerage, owner, options), + "UpdateBrokerageContract", + ), + ); + } async getBrokerage(address: string): Promise { return this.#wrap("getBrokerage", async () => { const response = await fetch(`${this.#fullHost}/wallet/getBrokerage`, { @@ -399,7 +518,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { contract: string, fn: string, params: TronContractParameter[], - opts: { feeLimit?: string; callValue?: string } = {}, + opts: { feeLimit?: string; callValue?: string; permissionId?: number } = {}, ): Promise { // Guard before #wrap so a bad fee/callValue surfaces as invalid_amount, not a wrapped rpc_error. const feeLimit = opts.feeLimit === undefined ? undefined : this.#safeNumber(opts.feeLimit, "fee limit"); @@ -409,7 +528,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { const { transaction } = await this.#tw.transactionBuilder.triggerSmartContract( contract, fn, - { feeLimit, callValue, txLocal: true }, + { feeLimit, callValue, permissionId: opts.permissionId, txLocal: true }, params as Types.ContractFunctionParameter[], from, ); @@ -418,13 +537,19 @@ export class TronRpcClient implements TronGateway, Broadcaster { } async deployContract( from: string, - p: { abi: unknown; bytecode: string; feeLimit: string; parameters?: unknown[] }, + p: { abi: unknown; bytecode: string; feeLimit: string; parameters?: unknown[]; permissionId?: number }, ): Promise { const feeLimit = this.#safeNumber(p.feeLimit, "fee limit"); // guard before #wrap → invalid_amount, not rpc_error return this.#wrap("createSmartContract", async () => assertBuiltTx( await this.#tw.transactionBuilder.createSmartContract( - { abi: p.abi as Types.CreateSmartContractOptions["abi"], bytecode: p.bytecode, feeLimit, parameters: p.parameters }, + { + abi: p.abi as Types.CreateSmartContractOptions["abi"], + bytecode: p.bytecode, + feeLimit, + parameters: p.parameters, + permissionId: p.permissionId, + }, from, ), "CreateSmartContract", @@ -449,6 +574,91 @@ export class TronRpcClient implements TronGateway, Broadcaster { } return normalizeContractResponses(contract, info); } + async buildClearContractAbi( + owner: string, + contract: string, + options: { permissionId?: number } = {}, + ): Promise { + return this.#wrap("clearContractABI", async () => + assertBuiltTx( + await this.#tw.transactionBuilder.clearABI(contract, owner, options), + "ClearABIContract", + ), + ); + } + async buildUpdateOriginEnergyLimit( + owner: string, + contract: string, + energy: number | string, + options: { permissionId?: number } = {}, + ): Promise { + // TronWeb 6.4.0 still rejects values above 10,000,000 in its client-side validator, while + // java-tron and the protocol field accept a positive int64. Build the same protobuf locally + // so valid limits such as 50,000,000 are not rejected by an obsolete SDK policy check. + return this.#wrap("updateEnergyLimit", async () => assertBuiltTx( + await this.#buildLocalTransaction( + "UpdateEnergyLimitContract", + { + owner_address: this.#tw.address.toHex(owner), + contract_address: this.#tw.address.toHex(contract), + origin_energy_limit: energy, + }, + options.permissionId, + ), + "UpdateEnergyLimitContract", + )); + } + async buildUpdateUserResourcePercent( + owner: string, + contract: string, + percent: number, + options: { permissionId?: number } = {}, + ): Promise { + return this.#wrap("updateSetting", async () => + assertBuiltTx( + await this.#tw.transactionBuilder.updateSetting(contract, percent, owner, options), + "UpdateSettingContract", + ), + ); + } + async extendTransactionExpiration(transaction: unknown, extensionMs: number): Promise { + return this.#wrap("extendExpiration", async () => + await this.#tw.transactionBuilder.extendExpiration( + transaction as Types.Transaction, + extensionMs, + { txLocal: true }, + ), + ); + } + + /** Build a one-contract transaction using TronWeb's public protobuf codec and local ref block. + * This is equivalent to TransactionBuilder.createTransaction but does not inherit individual + * builder methods' stale policy limits. raw_data, raw_data_hex and txID are derived together. */ + async #buildLocalTransaction( + type: string, + value: Record, + permissionId?: number, + ): Promise { + const rawData = { + contract: [{ + parameter: { value, type_url: `type.googleapis.com/protocol.${type}` }, + type, + ...(permissionId ? { Permission_id: permissionId } : {}), + }], + ...await this.#tw.trx.getCurrentRefBlockParams(), + }; + const shell = { visible: false, txID: "", raw_data_hex: "", raw_data: rawData }; + const protobuf = type === "ProposalCreateContract" + ? proposalCreateTxJsonToPbExact(shell) + : type === "UpdateEnergyLimitContract" + ? updateEnergyLimitTxJsonToPbExact(shell) + : tronUtils.transaction.txJsonToPb(shell); + return { + ...shell, + txID: tronUtils.transaction.txPbToTxID(protobuf).replace(/^0x/, ""), + raw_data_hex: tronUtils.transaction.txPbToRawDataHex(protobuf).toLowerCase(), + } as unknown as Types.Transaction; + } // tronweb's builder params are JS numbers; strings stay exact until this last inch, // where we reject any value that a Number could not represent without precision loss. @@ -517,6 +727,35 @@ function normalizeWitness(value: unknown): TronWitness | null { }; } +function normalizeProposal(value: unknown): TronProposal | null { + if (!value || typeof value !== "object") return null; + const raw = value as Record; + const id = Number(raw.proposal_id ?? raw.proposalId); + if (!Number.isSafeInteger(id) || id < 0) return null; + const proposerAddress = hexToBase58(raw.proposer_address ?? raw.proposerAddress); + if (!proposerAddress) return null; + const parametersRaw = raw.parameters && typeof raw.parameters === "object" && !Array.isArray(raw.parameters) + ? raw.parameters as Record + : {}; + const parameters = Object.fromEntries( + Object.entries(parametersRaw).map(([key, entry]) => [key, String(entry)]), + ); + const stateValue = raw.state; + const states = ["PENDING", "DISAPPROVED", "APPROVED", "CANCELED"] as const; + const state = typeof stateValue === "string" && states.includes(stateValue.toUpperCase() as typeof states[number]) + ? stateValue.toUpperCase() as typeof states[number] + : states[Number(stateValue)] ?? "PENDING"; + return { + id, + proposerAddress, + parameters, + expirationTime: Number(raw.expiration_time ?? raw.expirationTime ?? 0), + createTime: Number(raw.create_time ?? raw.createTime ?? 0), + approvals: (Array.isArray(raw.approvals) ? raw.approvals : []).map(hexToBase58).filter(Boolean), + state, + }; +} + /** Parse node account JSON without first coercing 64-bit quantities through JS number. */ export function parseTronAccountResponse(text: string): TronAccount { return normalizeAccountValue(parseLosslessJson(text)) as TronAccount; diff --git a/ts/src/adapters/outbound/chain/tron/tx-integrity.ts b/ts/src/adapters/outbound/chain/tron/tx-integrity.ts index d3c24e4d1..941d29fa1 100644 --- a/ts/src/adapters/outbound/chain/tron/tx-integrity.ts +++ b/ts/src/adapters/outbound/chain/tron/tx-integrity.ts @@ -45,6 +45,10 @@ import { utils as tronUtils } from "tronweb" import { sha256 } from "@noble/hashes/sha2.js" import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js" import { ChainError } from "../../../../domain/errors/index.js" +import { + proposalCreateTxCheckExact, + updateEnergyLimitTxCheckExact, +} from "./proposal-protobuf.js" /** tronweb's txJsonToPb rejects contract types it has no protobuf mapping for with this message. */ const UNSUPPORTED_CONTRACT_TYPE = /^Unsupported transaction type/i @@ -127,7 +131,14 @@ export function assertTronTxIntegrity(tx: unknown): void { let matchesRawData: boolean try { - matchesRawData = tronUtils.transaction.txCheck(tx as any) + const contracts = Array.isArray((t.raw_data as { contract?: unknown })?.contract) + ? (t.raw_data as { contract: Array<{ type?: unknown }> }).contract + : [] + matchesRawData = contracts.some((contract) => contract?.type === "ProposalCreateContract") + ? proposalCreateTxCheckExact(tx) + : contracts.some((contract) => contract?.type === "UpdateEnergyLimitContract") + ? updateEnergyLimitTxCheckExact(tx) + : tronUtils.transaction.txCheck(tx as any) } catch (e) { const message = (e as Error)?.message ?? String(e) // The one tolerable failure: tronweb has no encoding for this contract type, so raw_data diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 47f59f791..6c2bb664c 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -24,6 +24,11 @@ export const CAP_SUMMARIES: Record = { "message.sign": "sign a message", "contract.call": "constant + state-changing contract calls", "contract.deploy": "deploy a smart contract", + "contract.governance": "govern a deployed smart contract", + "contract.create2": "compute TVM CREATE2 addresses", + "proposal.read": "query governance proposals", + "proposal.write": "create, approve, and delete governance proposals", + "witness.manage": "register and operate an SR candidacy", "staking.freeze": "freeze/unfreeze (Stake 2.0)", "staking.delegate": "delegate/undelegate resource (Stake 2.0)", "vote.cast": "cast/replace SR votes", diff --git a/ts/src/application/ports/chain/tron-gateway.ts b/ts/src/application/ports/chain/tron-gateway.ts index 6ac8bb196..21329f1c0 100644 --- a/ts/src/application/ports/chain/tron-gateway.ts +++ b/ts/src/application/ports/chain/tron-gateway.ts @@ -63,6 +63,23 @@ export interface TronWitness { [key: string]: unknown; } +export type TronProposalState = "PENDING" | "DISAPPROVED" | "APPROVED" | "CANCELED"; + +/** Proposal payload normalized at the adapter boundary; all int64 map values stay decimal strings. */ +export interface TronProposal { + id: number; + proposerAddress: string; + parameters: Record; + expirationTime: number; + createTime: number; + approvals: string[]; + state: TronProposalState; +} + +export interface TronTransactionBuildOptions { + permissionId?: number; +} + export interface TronVote { witness: string; count: string; @@ -82,7 +99,14 @@ export interface TronTokenInfo { export interface TronTxInfo { blockNumber?: number; fee?: number; - receipt?: { result?: string; energy_usage_total?: number; [key: string]: unknown }; + receipt?: { + result?: string; + energy_usage_total?: number; + energy_fee?: number; + net_usage?: number; + net_fee?: number; + [key: string]: unknown; + }; [key: string]: unknown; } @@ -111,6 +135,7 @@ export interface DecodedTronTransaction { export interface TronContractMetadata { name?: string; methods: string[]; + originAddress?: string; contract: unknown; info?: unknown; } @@ -148,7 +173,7 @@ export interface TronGateway extends Broadcaster { getBlock(number?: string): Promise; getTransactionById(txid: string): Promise; getTransactionInfoById(txid: string): Promise; - getChainParameters(): Promise>; + getChainParameters(): Promise>; getEnergyPrices(): Promise; getBandwidthPrices(): Promise; getNodeInfo(): Promise; @@ -203,6 +228,32 @@ export interface TronGateway extends Broadcaster { buildVoteWitness(owner: string, votes: TronVote[]): Promise; buildWithdrawBalance(owner: string): Promise; getWitnesses(limit: number): Promise; + getWitness(address: string): Promise; + getProposals(): Promise; + getProposal(id: number): Promise; + buildProposalCreate( + owner: string, + parameters: Array<{ key: number; value: number | string }>, + options?: TronTransactionBuildOptions, + ): Promise; + buildProposalApprove( + owner: string, + proposalId: number, + addApproval: boolean, + options?: TronTransactionBuildOptions, + ): Promise; + buildProposalDelete( + owner: string, + proposalId: number, + options?: TronTransactionBuildOptions, + ): Promise; + buildWitnessCreate(owner: string, url: string, options?: TronTransactionBuildOptions): Promise; + buildWitnessUpdate(owner: string, url: string, options?: TronTransactionBuildOptions): Promise; + buildWitnessSetBrokerage( + owner: string, + brokerage: number, + options?: TronTransactionBuildOptions, + ): Promise; getBrokerage(address: string): Promise; getReward(address: string): Promise; triggerConstantContract( @@ -216,13 +267,31 @@ export interface TronGateway extends Broadcaster { contract: string, method: string, parameters: TronContractParameter[], - options?: { feeLimit?: string; callValue?: string }, + options?: { feeLimit?: string; callValue?: string; permissionId?: number }, ): Promise; deployContract( from: string, - input: { abi: unknown; bytecode: string; feeLimit: string; parameters?: unknown[] }, + input: { abi: unknown; bytecode: string; feeLimit: string; parameters?: unknown[]; permissionId?: number }, ): Promise; getContract(address: string): Promise; getContractInfo(address: string): Promise; getContractMetadata(address: string): Promise; + buildClearContractAbi( + owner: string, + contract: string, + options?: TronTransactionBuildOptions, + ): Promise; + buildUpdateOriginEnergyLimit( + owner: string, + contract: string, + energy: number | string, + options?: TronTransactionBuildOptions, + ): Promise; + buildUpdateUserResourcePercent( + owner: string, + contract: string, + percent: number, + options?: TronTransactionBuildOptions, + ): Promise; + extendTransactionExpiration(transaction: UnsignedTx, extensionMs: number): Promise; } diff --git a/ts/src/application/services/pipeline/index.ts b/ts/src/application/services/pipeline/index.ts index e54d82f48..2d1529144 100644 --- a/ts/src/application/services/pipeline/index.ts +++ b/ts/src/application/services/pipeline/index.ts @@ -20,6 +20,7 @@ export interface TxPipelineParams { build: (signerAddress: string) => Promise; estimate: (tx: UnsignedTx) => Promise; dryRun: boolean; + buildOnly?: boolean; broadcast: boolean; /** Optional post-broadcast confirmation: poll the chain for on-chain results (fee/energy/ * withdrawn amount) and merge them into the broadcast outcome. Best-effort — it must never @@ -56,20 +57,25 @@ export class TxPipeline { async run(p: TxPipelineParams): Promise { // --wait only makes sense when we actually broadcast (dry-run/sign-only never reach the chain). if (p.ctx.wait && !p.broadcast) { - throw new UsageError("invalid_option", "--wait has nothing to wait for with --dry-run/--sign-only (neither broadcasts)"); + throw new UsageError("invalid_option", "--wait has nothing to wait for with --dry-run/--sign-only/--build-only (none broadcasts)"); } - const signer = this.signers.resolve(p.account, p.net.family); + // Planning/build-only never needs private-key access. Resolve only an address so watch-only + // accounts can safely build or inspect the exact transaction without pretending they can sign. + const unsignedOnly = p.dryRun || p.buildOnly === true; + const signer = unsignedOnly ? undefined : this.signers.resolve(p.account, p.net.family); + const signerAddress = signer?.address ?? p.ctx.resolveAddress(p.net.family); // RPC steps (build/estimate/broadcast) are bounded by the adapter's own --timeout, so they // aren't wrapped here. The one thing no RPC timeout covers is a Ledger tap that never comes; // obtainSignature bounds the device signature and aborts its prompt on timeout. - const tx = await p.build(signer.address); + const tx = await p.build(signerAddress); + if (p.buildOnly) return { stage: "built", tx }; const fee = await p.estimate(tx); if (p.dryRun) return { stage: "plan", tx, fee }; - const signed = await obtainSignature(signer, p.ctx, (opts) => signer.sign(tx, opts)); + const signed = await obtainSignature(signer!, p.ctx, (opts) => signer!.sign(tx, opts)); - if (!p.broadcast) return { stage: "signed", signed, fee, address: signer.address, txId: txIdOf(signed) }; + if (!p.broadcast) return { stage: "signed", signed, fee, address: signer!.address, txId: txIdOf(signed) }; const result = await p.broadcaster.broadcast(signed); const txId = String(result.txId ?? result.hash ?? ""); // default (no --wait): non-blocking, return the submitted txid only (fee/energy unknown yet). diff --git a/ts/src/application/services/pipeline/pipeline.test.ts b/ts/src/application/services/pipeline/pipeline.test.ts index 0eb0bf3e6..7e7c01894 100644 --- a/ts/src/application/services/pipeline/pipeline.test.ts +++ b/ts/src/application/services/pipeline/pipeline.test.ts @@ -53,3 +53,24 @@ describe("TxPipeline device-sign timeout", () => { expect(captured?.aborted).toBe(true); // the abort is wired so the device prompt is cancelled }); }); + +describe("TxPipeline build-only", () => { + it("builds from the public address without resolving a signer or estimating", async () => { + const resolve = vi.fn(() => { throw new Error("signer must not be resolved"); }); + const signers = { resolve } as unknown as SignerResolver; + const build = vi.fn(async (address: string) => ({ raw_data_hex: "0102", owner: address })); + const estimate = vi.fn(async () => ({})); + + await expect(new TxPipeline(signers).run(params({} as Signer, { + ctx: scope({ resolveAddress: () => "TWatchOnly" }), + buildOnly: true, + build, + estimate, + }))).resolves.toEqual({ + stage: "built", + tx: { raw_data_hex: "0102", owner: "TWatchOnly" }, + }); + expect(resolve).not.toHaveBeenCalled(); + expect(estimate).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/services/transaction-mode.test.ts b/ts/src/application/services/transaction-mode.test.ts index 061e17cc8..d6506c507 100644 --- a/ts/src/application/services/transaction-mode.test.ts +++ b/ts/src/application/services/transaction-mode.test.ts @@ -24,7 +24,15 @@ describe("transactionMode", () => { expect(transactionMode({ signOnly: true })).toEqual({ dryRun: false, broadcast: false }); }); + it("--build-only → unsigned transaction without broadcast", () => { + expect(transactionMode({ buildOnly: true })).toEqual({ dryRun: false, buildOnly: true, broadcast: false }); + }); + it("--dry-run + --sign-only → invalid_option", () => { expectCode(() => transactionMode({ dryRun: true, signOnly: true }), "invalid_option"); }); + + it("rejects build-only combined with another mode", () => { + expectCode(() => transactionMode({ signOnly: true, buildOnly: true }), "invalid_option"); + }); }); diff --git a/ts/src/application/services/transaction-mode.ts b/ts/src/application/services/transaction-mode.ts index 1ea54587a..83cb831a7 100644 --- a/ts/src/application/services/transaction-mode.ts +++ b/ts/src/application/services/transaction-mode.ts @@ -4,22 +4,34 @@ import { UsageError } from "../../domain/errors/index.js"; export interface TransactionModeInput { dryRun?: boolean; signOnly?: boolean; + buildOnly?: boolean; } export function transactionMode(input: TransactionModeInput): { dryRun: boolean; + buildOnly?: boolean; broadcast: boolean; } { - if (input.dryRun && input.signOnly) { - throw new UsageError("invalid_option", "choose at most one of --dry-run, --sign-only"); + const selected = [input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length; + if (selected > 1) { + throw new UsageError("invalid_option", "choose at most one of --dry-run, --sign-only, --build-only"); } if (input.dryRun) return { dryRun: true, broadcast: false }; if (input.signOnly) return { dryRun: false, broadcast: false }; + if (input.buildOnly) return { dryRun: false, buildOnly: true, broadcast: false }; return { dryRun: false, broadcast: true }; } export function outcomeData(outcome: TxOutcome): Record { if (outcome.stage === "plan") return { mode: "dry-run", fee: outcome.fee, tx: outcome.tx }; + if (outcome.stage === "built") { + const rawDataHex = (outcome.tx as { raw_data_hex?: unknown } | null)?.raw_data_hex; + return { + mode: "build-only", + unsigned: outcome.tx, + ...(typeof rawDataHex === "string" ? { unsignedHex: rawDataHex } : {}), + }; + } if (outcome.stage === "signed") { // `fee` is absent when the caller supplied the transaction (tx sign): nothing was estimated. // Omit rather than emit undefined — kv() drops empty rows and JSON stays additive. @@ -33,4 +45,3 @@ export function outcomeData(outcome: TxOutcome): Record { } return outcome as unknown as Record; } - diff --git a/ts/src/application/services/tron-confirmation.ts b/ts/src/application/services/tron-confirmation.ts index 94cbcf221..91ba78f48 100644 --- a/ts/src/application/services/tron-confirmation.ts +++ b/ts/src/application/services/tron-confirmation.ts @@ -14,6 +14,8 @@ function normalize(info: TronTxInfo): Record { if (info.fee !== undefined) result.feeSun = info.fee; if (receipt.energy_usage_total !== undefined) result.energyUsed = receipt.energy_usage_total; if (receipt.net_usage !== undefined) result.netUsed = receipt.net_usage; + if (receipt.energy_fee !== undefined) result.energyFeeSun = receipt.energy_fee; + if (receipt.net_fee !== undefined) result.netFeeSun = receipt.net_fee; if (info.withdraw_amount !== undefined) result.withdrawnSun = info.withdraw_amount; if (receipt.result !== undefined) result.result = receipt.result; result.failed = receipt.result !== undefined && @@ -57,4 +59,3 @@ export async function stageTronBroadcast( } return { stage: confirmed.failed ? "failed" : "confirmed", ...result, ...confirmed }; } - diff --git a/ts/src/application/use-cases/tron/contract-service.governance.test.ts b/ts/src/application/use-cases/tron/contract-service.governance.test.ts new file mode 100644 index 000000000..88b5deb10 --- /dev/null +++ b/ts/src/application/use-cases/tron/contract-service.governance.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; +import { TronContractService } from "./contract-service.js"; + +const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] }; +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const OTHER = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; +const scope: TransactionScope = { + activeAccount: "wlt_test.0", resolveAddress: () => OWNER, + timeoutMs: 60_000, wait: false, waitTimeoutMs: 60_000, emit: () => {}, warn: () => {}, +}; + +function createService(gateway: Partial) { + const concrete = gateway as TronGateway; + const pipeline = { + assertCanSign: vi.fn(), + run: async (params: TxPipelineParams) => { + await params.build(OWNER); + return { stage: "submitted", txId: "tx-contract" } as never; + }, + } as unknown as TxPipeline; + return new TronContractService( + { get: () => concrete } as unknown as ChainGatewayProvider, + pipeline, + ); +} + +describe("TronContractService governance", () => { + it("applies v4.11 permission and expiration controls to contract send", async () => { + const trigger = vi.fn(async () => ({ raw_data: {} })); + const extend = vi.fn(async (transaction) => ({ ...transaction as object, extended: true })); + const service = createService({ + triggerSmartContract: trigger, + extendTransactionExpiration: extend, + estimateResources: async () => ({ feeModel: "tron-resource", energy: 0 }), + }); + await expect(service.send(scope, NET, { + contract: CONTRACT, + method: "set(uint256)", + parameters: [{ type: "uint256", value: "1" }], + callValueSun: "0", + feeLimit: "100000000", + permissionId: 2, + expiration: 120_000, + signOnly: true, + })).resolves.toMatchObject({ kind: "contract-send", txId: "tx-contract" }); + expect(trigger).toHaveBeenCalledWith( + OWNER, + CONTRACT, + "set(uint256)", + [{ type: "uint256", value: "1" }], + { feeLimit: "100000000", callValue: "0", permissionId: 2 }, + ); + expect(extend).toHaveBeenCalledWith(expect.anything(), 120_000); + }); + + it("requires SmartContract.origin_address to equal the selected account", async () => { + const build = vi.fn(); + const service = createService({ + getContractMetadata: async () => ({ methods: [], originAddress: OTHER, contract: {} }), + buildClearContractAbi: build, + }); + await expect(service.clearAbi(scope, NET, { address: CONTRACT, permissionId: 0 })) + .rejects.toMatchObject({ code: "not_contract_deployer" }); + expect(build).not.toHaveBeenCalled(); + }); + + it("maps the generic adapter absence to contract_not_found", async () => { + const service = createService({ + getContractMetadata: async () => { throw new ChainError("not_found", "missing"); }, + }); + await expect(service.clearAbi(scope, NET, { address: CONTRACT, permissionId: 0 })) + .rejects.toMatchObject({ code: "contract_not_found" }); + }); + + it("passes the caller-paid percentage through without reversing it", async () => { + const build = vi.fn(async () => ({})); + const service = createService({ + getContractMetadata: async () => ({ methods: [], originAddress: OWNER, contract: {} }), + buildUpdateUserResourcePercent: build, + }); + await expect(service.setUserResourcePercent(scope, NET, { + address: CONTRACT, percent: 100, permissionId: 2, + })).resolves.toMatchObject({ + contractAddress: CONTRACT, + deployerAddress: OWNER, + consumeUserResourcePercent: 100, + }); + expect(build).toHaveBeenCalledWith(OWNER, CONTRACT, 100, { permissionId: 2 }); + }); + + it("accepts an energy limit above TronWeb's obsolete 10M client cap", async () => { + const build = vi.fn(async () => ({})); + const service = createService({ + getContractMetadata: async () => ({ methods: [], originAddress: OWNER, contract: {} }), + buildUpdateOriginEnergyLimit: build, + }); + await expect(service.setOriginEnergyLimit(scope, NET, { + address: CONTRACT, energy: 50_000_000, permissionId: 0, + })).resolves.toMatchObject({ originEnergyLimit: 50_000_000 }); + expect(build).toHaveBeenCalledWith(OWNER, CONTRACT, 50_000_000, { permissionId: 0 }); + }); +}); diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index 8472b4aad..3406f497f 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -3,9 +3,18 @@ import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { TronContractParameter } from "../../ports/chain/tron-gateway.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; -import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; +import { outcomeData } from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; import { tronHexToBase58 } from "../../../domain/address/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { computeTronCreate2Address } from "../../../domain/governance/create2.js"; +import type { UnsignedTx } from "../../../domain/types/index.js"; +import { + governanceTransactionMode, + transactionResource, + withExtendedExpiration, + type GovernanceTransactionInput, +} from "./governance-transaction.js"; export class TronContractService { constructor( @@ -30,7 +39,7 @@ export class TronContractService { async send( scope: TransactionScope, network: NetworkDescriptor, - input: TransactionModeInput & { + input: GovernanceTransactionInput & { contract: string; method: string; parameters: TronContractParameter[]; @@ -38,21 +47,25 @@ export class TronContractService { feeLimit: string; }, ) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); + const mode = governanceTransactionMode(this.pipeline, scope, input); const outcome = await this.pipeline.run({ ctx: scope, net: network, account: scope.activeAccount, broadcaster: gateway, - ...transactionMode(input), + ...mode, confirm: tronConfirmation(gateway, scope), - build: (from) => gateway.triggerSmartContract( - from, - input.contract, - input.method, - input.parameters, - { feeLimit: input.feeLimit, callValue: input.callValueSun }, + build: async (from) => withExtendedExpiration( + gateway, + await gateway.triggerSmartContract( + from, + input.contract, + input.method, + input.parameters, + { feeLimit: input.feeLimit, callValue: input.callValueSun, permissionId: input.permissionId }, + ), + input.expiration, ), estimate: () => gateway.estimateResources( scope.resolveAddress("tron"), @@ -72,29 +85,29 @@ export class TronContractService { async deploy( scope: TransactionScope, network: NetworkDescriptor, - input: TransactionModeInput & { + input: GovernanceTransactionInput & { abi: unknown; bytecode: string; feeLimit: string; parameters: unknown[]; }, ) { - // Ledger TRON app firmware cannot sign a CreateSmartContract tx — reject before any device I/O. - this.pipeline.assertCanSign(scope.activeAccount, "tron", { requireSoftware: true }); const gateway = this.gateways.get(network, "tron"); + // Ledger TRON app firmware cannot sign a CreateSmartContract tx — reject before any device I/O. + const mode = governanceTransactionMode(this.pipeline, scope, input, { requireSoftware: true }); let contractAddress: string | undefined; const outcome = await this.pipeline.run({ ctx: scope, net: network, account: scope.activeAccount, broadcaster: gateway, - ...transactionMode(input), + ...mode, confirm: tronConfirmation(gateway, scope), build: async (from) => { - const tx = await gateway.deployContract(from, input); - const hex = (tx as { contract_address?: string }).contract_address; + const built = await gateway.deployContract(from, input); + const hex = (built as { contract_address?: string }).contract_address; if (hex) contractAddress = tronHexToBase58(hex); - return tx; + return withExtendedExpiration(gateway, built, input.expiration); }, estimate: async () => ({ feeModel: "tron-resource", @@ -115,4 +128,127 @@ export class TronContractService { info: metadata.info, }; } + + async clearAbi( + scope: TransactionScope, + network: NetworkDescriptor, + input: GovernanceTransactionInput & { address: string }, + ) { + return this.govern( + scope, + network, + input, + "contract-clear-abi", + (gateway, owner) => gateway.buildClearContractAbi( + owner, + input.address, + { permissionId: input.permissionId }, + ), + {}, + ); + } + + async setOriginEnergyLimit( + scope: TransactionScope, + network: NetworkDescriptor, + input: GovernanceTransactionInput & { address: string; energy: number | string }, + ) { + return this.govern( + scope, + network, + input, + "contract-set-origin-energy-limit", + (gateway, owner) => gateway.buildUpdateOriginEnergyLimit( + owner, + input.address, + input.energy, + { permissionId: input.permissionId }, + ), + { originEnergyLimit: exactIntegerView(input.energy) }, + ); + } + + async setUserResourcePercent( + scope: TransactionScope, + network: NetworkDescriptor, + input: GovernanceTransactionInput & { address: string; percent: number }, + ) { + return this.govern( + scope, + network, + input, + "contract-set-user-resource-percent", + (gateway, owner) => gateway.buildUpdateUserResourcePercent( + owner, + input.address, + input.percent, + { permissionId: input.permissionId }, + ), + { consumeUserResourcePercent: input.percent }, + ); + } + + create2(deployer: string, code: string, salt: string) { + return computeTronCreate2Address(deployer, code, salt); + } + + private async govern( + scope: TransactionScope, + network: NetworkDescriptor, + input: GovernanceTransactionInput & { address: string }, + kind: + | "contract-clear-abi" + | "contract-set-origin-energy-limit" + | "contract-set-user-resource-percent", + build: (gateway: ReturnType, owner: string) => Promise, + fields: Record, + ) { + const gateway = this.gateways.get(network, "tron"); + const mode = governanceTransactionMode(this.pipeline, scope, input); + const owner = scope.resolveAddress("tron"); + let metadata; + try { + metadata = await gateway.getContractMetadata(input.address); + } catch (error) { + if (error instanceof ChainError && error.code === "not_found") { + throw new ChainError("contract_not_found", `no contract deployed at ${input.address}`); + } + throw error; + } + if (!metadata.originAddress || metadata.originAddress !== owner) { + throw new ChainError( + "not_contract_deployer", + `only contract deployer ${metadata.originAddress ?? "(unknown)"} may govern ${input.address}`, + ); + } + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + confirm: tronConfirmation(gateway, scope), + build: async (address) => withExtendedExpiration( + gateway, + await build(gateway, address), + input.expiration, + ), + estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "contract governance uses bandwidth only" }), + }); + const data = outcomeData(outcome); + const resource = transactionResource(data); + return { + kind, + ...data, + contractAddress: input.address, + deployerAddress: owner, + ...fields, + ...(resource ? { resource } : {}), + }; + } +} + +function exactIntegerView(value: number | string): number | string { + const parsed = BigInt(value); + return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : parsed.toString(); } diff --git a/ts/src/application/use-cases/tron/governance-transaction.ts b/ts/src/application/use-cases/tron/governance-transaction.ts new file mode 100644 index 000000000..10f40bfed --- /dev/null +++ b/ts/src/application/use-cases/tron/governance-transaction.ts @@ -0,0 +1,51 @@ +import type { UnsignedTx } from "../../../domain/types/index.js"; +import { UsageError } from "../../../domain/errors/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import { + transactionMode, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; + +export interface GovernanceTransactionInput extends TransactionModeInput { + expiration?: number; + permissionId?: number; +} + +export function governanceTransactionMode( + pipeline: TxPipeline, + scope: TransactionScope, + input: GovernanceTransactionInput, + options: { requireSoftware?: boolean } = {}, +) { + const mode = transactionMode(input); + if (input.expiration !== undefined && !input.signOnly && !input.buildOnly) { + throw new UsageError("invalid_option", "--expiration is only valid with --sign-only or --build-only"); + } + if (!input.dryRun && !input.buildOnly) { + pipeline.assertCanSign(scope.activeAccount, "tron", options.requireSoftware ? { requireSoftware: true } : undefined); + } + return mode; +} + +export async function withExtendedExpiration( + gateway: TronGateway, + transaction: UnsignedTx, + extensionMs: number | undefined, +): Promise { + return extensionMs === undefined + ? transaction + : await gateway.extendTransactionExpiration(transaction, extensionMs); +} + +/** Canonical nested resource view required by governance JSON receipts. */ +export function transactionResource(data: Readonly>): Record | undefined { + const resource = { + netUsage: data.netUsed, + netFeeSun: data.netFeeSun, + energyUsage: data.energyUsed, + energyFeeSun: data.energyFeeSun, + }; + return Object.values(resource).some((value) => value !== undefined) ? resource : undefined; +} diff --git a/ts/src/application/use-cases/tron/proposal-service.test.ts b/ts/src/application/use-cases/tron/proposal-service.test.ts new file mode 100644 index 000000000..428739baf --- /dev/null +++ b/ts/src/application/use-cases/tron/proposal-service.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway, TronProposal } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; +import { TronProposalService } from "./proposal-service.js"; + +const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] }; +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const OTHER = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; +const scope: TransactionScope = { + activeAccount: "wlt_test.0", + resolveAddress: () => OWNER, + timeoutMs: 60_000, + wait: false, + waitTimeoutMs: 60_000, + emit: () => {}, + warn: () => {}, +}; + +function createService(gateway: Partial, run?: (params: TxPipelineParams) => Promise) { + const concrete = gateway as TronGateway; + const gateways = { get: () => concrete } as unknown as ChainGatewayProvider; + const pipeline = { + assertCanSign: vi.fn(), + run: run ?? (async (params: TxPipelineParams) => { + await params.build(OWNER); + return { stage: "submitted", txId: "tx-proposal" } as never; + }), + } as unknown as TxPipeline; + return { service: new TronProposalService(gateways, pipeline), pipeline }; +} + +describe("TronProposalService", () => { + it("filters active proposals, sorts ids, paginates, and uses Java's 70% threshold", async () => { + const now = Date.now(); + const { service } = createService({ + getProposals: async () => ([ + { id: 1, proposerAddress: OWNER, parameters: { "3": "15" }, expirationTime: now - 1, createTime: now - 2, approvals: [], state: "DISAPPROVED" }, + { id: 3, proposerAddress: OWNER, parameters: { "3": "15", "2": "200000" }, expirationTime: now + 60_000, createTime: now, approvals: [OTHER], state: "PENDING" }, + { id: 2, proposerAddress: OTHER, parameters: { "20": "1" }, expirationTime: now + 60_000, createTime: now, approvals: [], state: "PENDING" }, + ] as TronProposal[]), + getChainParameters: async () => [ + { key: "getCreateAccountFee", value: 100_000 }, + { key: "getTransactionFee", value: 10 }, + { key: "getAllowMultiSign", value: 0 }, + ], + getWitnesses: async () => Array.from({ length: 27 }, (_, index) => ({ address: `${OWNER}${index}`, voteCount: "0" })), + }); + + await expect(service.list(NET, { state: "active", offset: 1, limit: 1 })).resolves.toMatchObject({ + approvalThreshold: 18, + pagination: { offset: 1, limit: 1, total: 2 }, + proposals: [{ id: 2, state: "voting", changes: [{ id: 20, name: "getAllowMultiSign" }] }], + }); + }); + + it("maps --cancel to Java is_add_approval=false and preserves permission/expiration", async () => { + const build = vi.fn(async () => ({ raw_data: { contract: [{ type: "ProposalApproveContract" }] } })); + const extend = vi.fn(async (tx) => ({ ...tx as object, extended: true })); + const { service } = createService({ + getProposal: async () => ({ + id: 47, + proposerAddress: OTHER, + parameters: { "3": "15" }, + expirationTime: Date.now() + 60_000, + createTime: Date.now(), + approvals: [OWNER], + state: "PENDING", + }), + getWitness: async () => ({ address: OWNER, voteCount: "1" }), + getWitnesses: async () => Array.from({ length: 27 }, () => ({ address: OTHER, voteCount: "1" })), + buildProposalApprove: build, + extendTransactionExpiration: extend, + }); + + await expect(service.approve(scope, NET, { + id: 47, + cancel: true, + permissionId: 2, + expiration: 120_000, + signOnly: true, + })).resolves.toMatchObject({ addApproval: false, approvals: 0, approvalThreshold: 18 }); + expect(build).toHaveBeenCalledWith(OWNER, 47, false, { permissionId: 2 }); + expect(extend).toHaveBeenCalledWith(expect.anything(), 120_000); + }); + + it("rejects a non-witness before proposal creation is built", async () => { + const build = vi.fn(); + const { service } = createService({ + getChainParameters: async () => [], + getWitness: async () => null, + buildProposalCreate: build, + }); + await expect(service.create(scope, NET, { + set: ["getTransactionFee=15"], permissionId: 0, + })).rejects.toMatchObject({ code: "not_a_witness" }); + expect(build).not.toHaveBeenCalled(); + }); + + it("rejects delete by an address other than the proposal owner", async () => { + const { service } = createService({ + getProposal: async () => ({ + id: 48, + proposerAddress: OTHER, + parameters: {}, + expirationTime: Date.now() + 60_000, + createTime: Date.now(), + approvals: [], + state: "PENDING", + }), + getWitness: async () => ({ address: OWNER, voteCount: "1" }), + }); + await expect(service.delete(scope, NET, { id: 48, permissionId: 0 })) + .rejects.toMatchObject({ code: "not_proposal_owner" }); + }); +}); diff --git a/ts/src/application/use-cases/tron/proposal-service.ts b/ts/src/application/use-cases/tron/proposal-service.ts new file mode 100644 index 000000000..dc3e6ee30 --- /dev/null +++ b/ts/src/application/use-cases/tron/proposal-service.ts @@ -0,0 +1,271 @@ +import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { + parseChainParameterAssignments, + proposalParameterChanges, + type ChainParameterChange, +} from "../../../domain/governance/chain-parameters.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway, TronProposal } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import { outcomeData } from "../../services/transaction-mode.js"; +import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { + governanceTransactionMode, + transactionResource, + withExtendedExpiration, + type GovernanceTransactionInput, +} from "./governance-transaction.js"; + +export interface ProposalListInput { + state: "active" | "all"; + limit?: number; + offset: number; +} + +export interface ProposalCreateInput extends GovernanceTransactionInput { + set: string[]; +} + +export interface ProposalApproveInput extends GovernanceTransactionInput { + id: number; + cancel: boolean; +} + +export interface ProposalDeleteInput extends GovernanceTransactionInput { + id: number; +} + +type ChainParameters = Awaited>; + +export class TronProposalService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly pipeline: TxPipeline, + ) {} + + async list(network: NetworkDescriptor, input: ProposalListInput) { + const gateway = this.gateways.get(network, "tron"); + const [proposals, parameters, witnesses] = await Promise.all([ + gateway.getProposals(), + gateway.getChainParameters(), + gateway.getWitnesses(27), + ]); + const approvalThreshold = threshold(witnesses.length); + const views = proposals + .filter((proposal) => input.state === "all" || isActive(proposal)) + .sort((left, right) => right.id - left.id) + .map((proposal) => listView(proposal, parameters)); + const total = views.length; + const proposalsPage = views.slice(input.offset, input.limit === undefined ? undefined : input.offset + input.limit); + return { + approvalThreshold, + proposals: proposalsPage, + pagination: { offset: input.offset, limit: input.limit ?? null, total }, + }; + } + + async show(network: NetworkDescriptor, id: number) { + const gateway = this.gateways.get(network, "tron"); + const [proposal, parameters, witnesses] = await Promise.all([ + gateway.getProposal(id), + gateway.getChainParameters(), + gateway.getWitnesses(27), + ]); + if (!proposal) throw new ChainError("proposal_not_found", `proposal #${id} was not found`); + const approvalThreshold = threshold(witnesses.length); + return { + ...listView(proposal, parameters), + createTime: proposal.createTime, + approvalThreshold, + reachedThreshold: proposal.approvals.length >= approvalThreshold, + approvedBy: proposal.approvals, + }; + } + + async create(scope: TransactionScope, network: NetworkDescriptor, input: ProposalCreateInput) { + const gateway = this.gateways.get(network, "tron"); + const mode = governanceTransactionMode(this.pipeline, scope, input); + const owner = scope.resolveAddress("tron"); + const [parameters] = await Promise.all([ + gateway.getChainParameters(), + assertWitness(gateway, owner), + ]); + const changes = parseChainParameterAssignments(input.set, parameters); + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + confirm: tronConfirmation(gateway, scope), + build: async (address) => withExtendedExpiration( + gateway, + await gateway.buildProposalCreate( + address, + changes.map((change) => ({ key: change.id, value: change.proposedValue })), + { permissionId: input.permissionId }, + ), + input.expiration, + ), + estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "proposal creation uses bandwidth only" }), + }); + const data = outcomeData(outcome); + const proposalId = outcome.stage === "confirmed" + ? await findCreatedProposal(gateway, owner, changes).catch(() => undefined) + : undefined; + return { + kind: "proposal-create" as const, + ...data, + proposerAddress: owner, + ...(proposalId === undefined ? {} : { proposalId }), + changes, + ...(transactionResource(data) ? { resource: transactionResource(data) } : {}), + }; + } + + async approve(scope: TransactionScope, network: NetworkDescriptor, input: ProposalApproveInput) { + const gateway = this.gateways.get(network, "tron"); + const mode = governanceTransactionMode(this.pipeline, scope, input); + const owner = scope.resolveAddress("tron"); + const [proposal, witnesses] = await Promise.all([ + requireProposal(gateway, input.id), + gateway.getWitnesses(27), + assertWitness(gateway, owner), + ]); + assertProposalOpen(proposal); + const alreadyApproved = proposal.approvals.includes(owner); + if (!input.cancel && alreadyApproved) { + throw new ChainError("already_approved", `account already approved proposal #${input.id}`); + } + if (input.cancel && !alreadyApproved) { + throw new ChainError("not_approved", `account has not approved proposal #${input.id}`); + } + const addApproval = !input.cancel; + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + confirm: tronConfirmation(gateway, scope), + build: async (address) => withExtendedExpiration( + gateway, + await gateway.buildProposalApprove(address, input.id, addApproval, { permissionId: input.permissionId }), + input.expiration, + ), + estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "proposal approval uses bandwidth only" }), + }); + const data = outcomeData(outcome); + return { + kind: "proposal-approve" as const, + ...data, + proposalId: input.id, + voterAddress: owner, + addApproval, + approvals: proposal.approvals.length + (addApproval ? 1 : -1), + approvalThreshold: threshold(witnesses.length), + ...(transactionResource(data) ? { resource: transactionResource(data) } : {}), + }; + } + + async delete(scope: TransactionScope, network: NetworkDescriptor, input: ProposalDeleteInput) { + const gateway = this.gateways.get(network, "tron"); + const mode = governanceTransactionMode(this.pipeline, scope, input); + const owner = scope.resolveAddress("tron"); + const [proposal] = await Promise.all([ + requireProposal(gateway, input.id), + assertWitness(gateway, owner), + ]); + if (proposal.state === "CANCELED") { + throw new ChainError("already_canceled", `proposal #${input.id} is already canceled`); + } + assertProposalOpen(proposal); + if (proposal.proposerAddress !== owner) { + throw new ChainError("not_proposal_owner", `only ${proposal.proposerAddress} can delete proposal #${input.id}`); + } + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + confirm: tronConfirmation(gateway, scope), + build: async (address) => withExtendedExpiration( + gateway, + await gateway.buildProposalDelete(address, input.id, { permissionId: input.permissionId }), + input.expiration, + ), + estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "proposal deletion uses bandwidth only" }), + }); + const data = outcomeData(outcome); + return { + kind: "proposal-delete" as const, + ...data, + proposalId: input.id, + proposerAddress: owner, + ...(transactionResource(data) ? { resource: transactionResource(data) } : {}), + }; + } +} + +async function assertWitness(gateway: TronGateway, address: string): Promise { + if (!await gateway.getWitness(address)) { + throw new ChainError("not_a_witness", `${address} is not a registered witness`); + } +} + +async function requireProposal(gateway: TronGateway, id: number): Promise { + const proposal = await gateway.getProposal(id); + if (!proposal) throw new ChainError("proposal_not_found", `proposal #${id} was not found`); + return proposal; +} + +function assertProposalOpen(proposal: TronProposal): void { + if (proposal.state !== "PENDING" || proposal.expirationTime <= Date.now()) { + throw new ChainError("proposal_expired", `proposal #${proposal.id} is no longer in its voting window`); + } +} + +function isActive(proposal: TronProposal): boolean { + return proposal.state === "PENDING" && proposal.expirationTime > Date.now(); +} + +function threshold(activeWitnessCount: number): number { + return activeWitnessCount > 0 ? Math.max(1, Math.floor(activeWitnessCount * 0.7)) : 18; +} + +function stateName(state: TronProposal["state"]): "voting" | "approved" | "disapproved" | "canceled" { + return ({ + PENDING: "voting", + APPROVED: "approved", + DISAPPROVED: "disapproved", + CANCELED: "canceled", + } as const)[state]; +} + +function listView(proposal: TronProposal, parameters: ChainParameters) { + return { + id: proposal.id, + proposerAddress: proposal.proposerAddress, + state: stateName(proposal.state), + approvals: proposal.approvals.length, + expirationTime: proposal.expirationTime, + changes: proposalParameterChanges(proposal.parameters, parameters), + }; +} + +async function findCreatedProposal( + gateway: TronGateway, + owner: string, + changes: ChainParameterChange[], +): Promise { + const expected = new Map(changes.map((change) => [String(change.id), String(change.proposedValue)])); + const matches = (await gateway.getProposals()).filter((proposal) => + proposal.proposerAddress === owner && + expected.size === Object.keys(proposal.parameters).length && + [...expected].every(([id, value]) => proposal.parameters[id] === value), + ); + return matches.sort((left, right) => right.id - left.id)[0]?.id; +} diff --git a/ts/src/application/use-cases/tron/witness-service.test.ts b/ts/src/application/use-cases/tron/witness-service.test.ts new file mode 100644 index 000000000..699db2187 --- /dev/null +++ b/ts/src/application/use-cases/tron/witness-service.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; +import { TronWitnessService } from "./witness-service.js"; + +const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] }; +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const scope: TransactionScope = { + activeAccount: "wlt_test.0", resolveAddress: () => OWNER, + timeoutMs: 60_000, wait: false, waitTimeoutMs: 60_000, emit: () => {}, warn: () => {}, +}; + +function createService(gateway: Partial) { + const concrete = gateway as TronGateway; + const pipeline = { + assertCanSign: vi.fn(), + run: async (params: TxPipelineParams) => { + await params.build(OWNER); + return { stage: "submitted", txId: "tx-witness", feeSun: 0 } as never; + }, + } as unknown as TxPipeline; + return new TronWitnessService( + { get: () => concrete } as unknown as ChainGatewayProvider, + pipeline, + ); +} + +describe("TronWitnessService", () => { + it("uses getAccountUpgradeCost exactly and reports the irreversible burn as feeSun", async () => { + const build = vi.fn(async () => ({})); + const service = createService({ + getWitness: async () => null, + getAccount: async () => ({ balance: "10000000000" }), + getChainParameters: async () => [{ key: "getAccountUpgradeCost", value: 9_999_000_000 }], + buildWitnessCreate: build, + }); + await expect(service.create(scope, NET, { + url: "https://sr.example", permissionId: 2, + })).resolves.toMatchObject({ + kind: "witness-create", + feeSun: "9999000000", + registrationFeeSun: "9999000000", + }); + expect(build).toHaveBeenCalledWith(OWNER, "https://sr.example", { permissionId: 2 }); + }); + + it("rejects insufficient registration balance before building", async () => { + const build = vi.fn(); + const service = createService({ + getWitness: async () => null, + getAccount: async () => ({ balance: "9998999999" }), + getChainParameters: async () => [{ key: "getAccountUpgradeCost", value: 9_999_000_000 }], + buildWitnessCreate: build, + }); + await expect(service.create(scope, NET, { url: "https://sr.example", permissionId: 0 })) + .rejects.toMatchObject({ code: "insufficient_balance" }); + expect(build).not.toHaveBeenCalled(); + }); + + it("passes brokerage through unchanged: percent is the SR-retained share", async () => { + const build = vi.fn(async () => ({})); + const service = createService({ + getWitness: async () => ({ address: OWNER, voteCount: "1" }), + buildWitnessSetBrokerage: build, + }); + await expect(service.setBrokerage(scope, NET, { percent: 20, permissionId: 0 })) + .resolves.toMatchObject({ brokerage: 20 }); + expect(build).toHaveBeenCalledWith(OWNER, 20, { permissionId: 0 }); + }); +}); diff --git a/ts/src/application/use-cases/tron/witness-service.ts b/ts/src/application/use-cases/tron/witness-service.ts new file mode 100644 index 000000000..97cd6b894 --- /dev/null +++ b/ts/src/application/use-cases/tron/witness-service.ts @@ -0,0 +1,150 @@ +import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import { outcomeData } from "../../services/transaction-mode.js"; +import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { + governanceTransactionMode, + transactionResource, + withExtendedExpiration, + type GovernanceTransactionInput, +} from "./governance-transaction.js"; + +export interface WitnessUrlInput extends GovernanceTransactionInput { + url: string; +} + +export interface WitnessBrokerageInput extends GovernanceTransactionInput { + percent: number; +} + +export class TronWitnessService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly pipeline: TxPipeline, + ) {} + + async create(scope: TransactionScope, network: NetworkDescriptor, input: WitnessUrlInput) { + const gateway = this.gateways.get(network, "tron"); + const mode = governanceTransactionMode(this.pipeline, scope, input); + const owner = scope.resolveAddress("tron"); + const [witness, account, parameters] = await Promise.all([ + gateway.getWitness(owner), + gateway.getAccount(owner), + gateway.getChainParameters(), + ]); + if (witness) throw new ChainError("already_witness", `${owner} is already a registered witness`); + if (Object.keys(account).length === 0) { + throw new ChainError("account_not_active", `${owner} is not activated on-chain`); + } + const feeValue = parameters.find((entry) => entry.key === "getAccountUpgradeCost")?.value; + if (feeValue === undefined || !/^\d+$/.test(String(feeValue))) { + throw new ChainError("chain_parameter_unavailable", "getAccountUpgradeCost is unavailable"); + } + const registrationFeeSun = BigInt(String(feeValue)); + if (BigInt(account.balance ?? "0") < registrationFeeSun) { + throw new ChainError( + "insufficient_balance", + `witness registration requires ${registrationFeeSun} SUN but the account balance is ${account.balance ?? "0"} SUN`, + ); + } + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + confirm: tronConfirmation(gateway, scope), + build: async (address) => withExtendedExpiration( + gateway, + await gateway.buildWitnessCreate(address, input.url, { permissionId: input.permissionId }), + input.expiration, + ), + estimate: async (_tx: UnsignedTx) => ({ + feeModel: "tron-resource", + feeSun: registrationFeeSun.toString(), + note: "irreversible witness registration burn plus bandwidth", + }), + }); + return witnessReceipt("witness-create", outcomeData(outcome), owner, { + url: input.url, + // The node receipt's `fee` generally covers bandwidth/energy only. Witness registration + // also burns getAccountUpgradeCost, which is the economically relevant fee for this action. + feeSun: registrationFeeSun.toString(), + registrationFeeSun: registrationFeeSun.toString(), + }); + } + + async update(scope: TransactionScope, network: NetworkDescriptor, input: WitnessUrlInput) { + const gateway = this.gateways.get(network, "tron"); + const mode = governanceTransactionMode(this.pipeline, scope, input); + const owner = scope.resolveAddress("tron"); + await requireWitness(gateway, owner); + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + confirm: tronConfirmation(gateway, scope), + build: async (address) => withExtendedExpiration( + gateway, + await gateway.buildWitnessUpdate(address, input.url, { permissionId: input.permissionId }), + input.expiration, + ), + estimate: bandwidthEstimate, + }); + return witnessReceipt("witness-update", outcomeData(outcome), owner, { url: input.url }); + } + + async setBrokerage(scope: TransactionScope, network: NetworkDescriptor, input: WitnessBrokerageInput) { + const gateway = this.gateways.get(network, "tron"); + const mode = governanceTransactionMode(this.pipeline, scope, input); + const owner = scope.resolveAddress("tron"); + await requireWitness(gateway, owner); + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + confirm: tronConfirmation(gateway, scope), + build: async (address) => withExtendedExpiration( + gateway, + await gateway.buildWitnessSetBrokerage(address, input.percent, { permissionId: input.permissionId }), + input.expiration, + ), + estimate: bandwidthEstimate, + }); + return witnessReceipt("witness-set-brokerage", outcomeData(outcome), owner, { brokerage: input.percent }); + } +} + +async function requireWitness(gateway: TronGateway, address: string): Promise { + if (!await gateway.getWitness(address)) { + throw new ChainError("not_a_witness", `${address} is not a registered witness`); + } +} + +async function bandwidthEstimate(_tx: UnsignedTx) { + return { feeModel: "tron-resource", note: "witness governance uses bandwidth only" }; +} + +function witnessReceipt( + kind: "witness-create" | "witness-update" | "witness-set-brokerage", + data: Record, + witnessAddress: string, + fields: Record, +) { + const resource = transactionResource(data); + return { + kind, + ...data, + witnessAddress, + ...fields, + ...(resource ? { resource } : {}), + }; +} diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 9e00fd3fc..1b521ca9f 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -64,7 +64,35 @@ import { contractInfoTronBinding, contractSendSpec, contractSendTronBinding, + contractClearAbiSpec, + contractClearAbiTronBinding, + contractSetOriginEnergyLimitSpec, + contractSetOriginEnergyLimitTronBinding, + contractSetUserResourcePercentSpec, + contractSetUserResourcePercentTronBinding, + contractCreate2Spec, + contractCreate2TronBinding, } from "../../adapters/inbound/cli/commands/contract.js"; +import { + proposalApproveSpec, + proposalApproveTronBinding, + proposalCreateSpec, + proposalCreateTronBinding, + proposalDeleteSpec, + proposalDeleteTronBinding, + proposalListSpec, + proposalListTronBinding, + proposalShowSpec, + proposalShowTronBinding, +} from "../../adapters/inbound/cli/commands/proposal.js"; +import { + witnessCreateSpec, + witnessCreateTronBinding, + witnessSetBrokerageSpec, + witnessSetBrokerageTronBinding, + witnessUpdateSpec, + witnessUpdateTronBinding, +} from "../../adapters/inbound/cli/commands/witness.js"; import type { CommandRegistry } from "../../adapters/inbound/cli/registry/index.js"; import { TronAccountService } from "../../application/use-cases/tron/account-service.js"; import { TronTokenService } from "../../application/use-cases/tron/token-service.js"; @@ -74,6 +102,8 @@ import { TronStakeService } from "../../application/use-cases/tron/stake-service import { TronVoteService } from "../../application/use-cases/tron/vote-service.js"; import { TronRewardService } from "../../application/use-cases/tron/reward-service.js"; import { TronChainService } from "../../application/use-cases/tron/chain-service.js"; +import { TronProposalService } from "../../application/use-cases/tron/proposal-service.js"; +import { TronWitnessService } from "../../application/use-cases/tron/witness-service.js"; import { TronBlockService } from "../../application/use-cases/tron/block-service.js"; import { MessageService } from "../../application/use-cases/message-service.js"; import { TypedDataService } from "../../application/use-cases/typed-data-service.js"; @@ -115,6 +145,8 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC const reward = new TronRewardService(deps.gateways, deps.transactions); const chain = new TronChainService(deps.gateways); const contract = new TronContractService(deps.gateways, deps.transactions); + const proposal = new TronProposalService(deps.gateways, deps.transactions); + const witness = new TronWitnessService(deps.gateways, deps.transactions); reg.addChain(blockSpec, "tron", blockTronBinding(new TronBlockService(deps.gateways))); reg.addChain(accountBalanceSpec, "tron", accountBalanceTronBinding(account)); @@ -148,4 +180,16 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC reg.addChain(contractSendSpec, "tron", contractSendTronBinding(contract)); reg.addChain(contractDeploySpec, "tron", contractDeployTronBinding(contract)); reg.addChain(contractInfoSpec, "tron", contractInfoTronBinding(contract)); + reg.addChain(contractClearAbiSpec, "tron", contractClearAbiTronBinding(contract)); + reg.addChain(contractSetOriginEnergyLimitSpec, "tron", contractSetOriginEnergyLimitTronBinding(contract)); + reg.addChain(contractSetUserResourcePercentSpec, "tron", contractSetUserResourcePercentTronBinding(contract)); + reg.addChain(contractCreate2Spec, "tron", contractCreate2TronBinding(contract)); + reg.addChain(proposalListSpec, "tron", proposalListTronBinding(proposal)); + reg.addChain(proposalShowSpec, "tron", proposalShowTronBinding(proposal)); + reg.addChain(proposalCreateSpec, "tron", proposalCreateTronBinding(proposal)); + reg.addChain(proposalApproveSpec, "tron", proposalApproveTronBinding(proposal)); + reg.addChain(proposalDeleteSpec, "tron", proposalDeleteTronBinding(proposal)); + reg.addChain(witnessCreateSpec, "tron", witnessCreateTronBinding(witness)); + reg.addChain(witnessUpdateSpec, "tron", witnessUpdateTronBinding(witness)); + reg.addChain(witnessSetBrokerageSpec, "tron", witnessSetBrokerageTronBinding(witness)); } diff --git a/ts/src/domain/address/index.ts b/ts/src/domain/address/index.ts index fe5cac927..9f5883291 100644 --- a/ts/src/domain/address/index.ts +++ b/ts/src/domain/address/index.ts @@ -52,3 +52,20 @@ export function tronHexToBase58(address: unknown): string { return value; } } + +/** Decode a Base58Check TRON address to its 21-byte, 0x41-prefixed payload. */ +export function tronBase58ToBytes(address: string): Uint8Array { + const decoded = b58c.decode(address); + if (decoded.length !== 21 || decoded[0] !== 0x41) { + throw new Error("invalid TRON address payload"); + } + return decoded; +} + +/** Encode a 21-byte, 0x41-prefixed TRON address payload as Base58Check. */ +export function tronBytesToBase58(payload: Uint8Array): string { + if (payload.length !== 21 || payload[0] !== 0x41) { + throw new Error("invalid TRON address payload"); + } + return b58c.encode(payload); +} 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/types/tx.ts b/ts/src/domain/types/tx.ts index 5dcabaaa0..1871ba503 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -32,6 +32,7 @@ export type BroadcastStage = "submitted" | "confirmed" | "failed"; export type TxOutcome = | { stage: "plan"; tx: UnsignedTx; fee: FeeReport } + | { stage: "built"; tx: UnsignedTx } // `fee` is absent when the caller supplied the transaction (tx sign): nothing was estimated. | { stage: "signed"; signed: SignedTx; fee?: FeeReport; address?: string; txId?: string } | ({ stage: BroadcastStage } & BroadcastResult); @@ -67,6 +68,9 @@ 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"; /** @@ -77,7 +81,7 @@ export type TxReceiptKind = */ export interface TxReceiptView { kind: TxReceiptKind; - mode?: "dry-run" | "sign-only"; + mode?: "dry-run" | "sign-only" | "build-only"; stage?: BroadcastStage; txId?: string; hash?: string; 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 05843d628..4bb29e57c 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) @@ -584,3 +587,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") + }) +}) From b579d294bf50669beb5e2e31e279d9f0e55bc51b Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 10 Aug 2026 11:13:10 +0800 Subject: [PATCH 02/15] feat(ts): trc-10 releated commands including asset & exchange and keystore import & backup --- ts/docs/commands/account/activate.md | 4 +- ts/docs/commands/account/set.md | 4 +- ts/docs/commands/asset/index.md | 37 ++ ts/docs/commands/asset/info.md | 73 +++ ts/docs/commands/asset/issue.md | 95 ++++ ts/docs/commands/asset/list.md | 58 +++ ts/docs/commands/asset/participate.md | 78 +++ ts/docs/commands/asset/unfreeze.md | 67 +++ ts/docs/commands/asset/update.md | 71 +++ ts/docs/commands/backup.md | 107 +++- ts/docs/commands/contract/deploy.md | 2 +- ts/docs/commands/contract/send.md | 2 +- ts/docs/commands/exchange/create.md | 70 +++ ts/docs/commands/exchange/index.md | 43 ++ ts/docs/commands/exchange/inject.md | 76 +++ ts/docs/commands/exchange/list.md | 48 ++ ts/docs/commands/exchange/show.md | 48 ++ ts/docs/commands/exchange/trade.md | 95 ++++ ts/docs/commands/exchange/withdraw.md | 66 +++ ts/docs/commands/import/index.md | 1 + ts/docs/commands/import/keystore.md | 96 ++++ ts/docs/commands/index.md | 15 + ts/docs/commands/permission/update.md | 4 +- ts/docs/commands/reward/withdraw.md | 2 +- ts/docs/commands/stake/cancel-unfreeze.md | 2 +- ts/docs/commands/stake/delegate.md | 2 +- ts/docs/commands/stake/freeze.md | 2 +- ts/docs/commands/stake/undelegate.md | 2 +- ts/docs/commands/stake/unfreeze.md | 2 +- ts/docs/commands/stake/withdraw.md | 2 +- ts/docs/commands/tx/send.md | 4 +- ts/docs/commands/vote/cast.md | 2 +- ts/docs/machine-interface.md | 5 + ts/src/adapters/inbound/cli/arity/index.ts | 7 +- ts/src/adapters/inbound/cli/commands/asset.ts | 190 ++++++++ .../adapters/inbound/cli/commands/exchange.ts | 199 ++++++++ .../cli/commands/wallet.backup.test.ts | 9 +- .../cli/commands/wallet.keystore.test.ts | 273 +++++++++++ .../inbound/cli/commands/wallet.test.ts | 9 +- .../adapters/inbound/cli/commands/wallet.ts | 175 ++++++- .../adapters/inbound/cli/contracts/command.ts | 8 + ts/src/adapters/inbound/cli/render/asset.ts | 62 +++ .../adapters/inbound/cli/render/exchange.ts | 53 ++ ts/src/adapters/inbound/cli/render/index.ts | 4 + ts/src/adapters/inbound/cli/render/tx.ts | 144 +++++- ts/src/adapters/inbound/cli/render/wallet.ts | 32 +- ts/src/adapters/inbound/cli/shell/index.ts | 13 +- .../cli/shell/positional-contract.test.ts | 5 +- .../chain/tron/asset-contract-codec.test.ts | 180 +++++++ .../chain/tron/asset-contract-codec.ts | 327 +++++++++++++ .../outbound/chain/tron/node-errors.test.ts | 43 ++ .../outbound/chain/tron/node-errors.ts | 63 +++ .../outbound/chain/tron/transaction-codec.ts | 62 ++- ts/src/adapters/outbound/chain/tron/tron.ts | 236 ++++++++- .../outbound/chain/tron/tx-integrity.ts | 10 +- .../persistence/backup-records.test.ts | 67 +++ .../outbound/persistence/backup-records.ts | 46 ++ .../persistence/backup-writer.test.ts | 32 +- .../outbound/persistence/backup-writer.ts | 25 +- .../outbound/persistence/crypto/index.ts | 34 +- ts/src/application/ports/backup-records.ts | 31 ++ ts/src/application/ports/backup-writer.ts | 11 +- .../application/ports/chain/tron-gateway.ts | 111 +++++ .../application/services/tron-confirmation.ts | 13 + .../use-cases/tron/asset-service.test.ts | 276 +++++++++++ .../use-cases/tron/asset-service.ts | 455 ++++++++++++++++++ .../use-cases/tron/exchange-service.test.ts | 241 ++++++++++ .../use-cases/tron/exchange-service.ts | 417 ++++++++++++++++ .../use-cases/wallet-service.keystore.test.ts | 279 +++++++++++ .../application/use-cases/wallet-service.ts | 120 ++++- ts/src/bootstrap/composition.ts | 4 +- ts/src/bootstrap/families/tron.ts | 12 + ts/src/domain/asset/asset.test.ts | 57 +++ ts/src/domain/asset/index.ts | 78 +++ ts/src/domain/exchange/exchange.test.ts | 124 +++++ ts/src/domain/exchange/index.ts | 115 +++++ ts/src/domain/keystore/index.ts | 155 ++++++ ts/src/domain/keystore/keystore-v3.test.ts | 137 ++++++ ts/src/domain/types/tx.ts | 58 ++- 79 files changed, 6071 insertions(+), 116 deletions(-) create mode 100644 ts/docs/commands/asset/index.md create mode 100644 ts/docs/commands/asset/info.md create mode 100644 ts/docs/commands/asset/issue.md create mode 100644 ts/docs/commands/asset/list.md create mode 100644 ts/docs/commands/asset/participate.md create mode 100644 ts/docs/commands/asset/unfreeze.md create mode 100644 ts/docs/commands/asset/update.md create mode 100644 ts/docs/commands/exchange/create.md create mode 100644 ts/docs/commands/exchange/index.md create mode 100644 ts/docs/commands/exchange/inject.md create mode 100644 ts/docs/commands/exchange/list.md create mode 100644 ts/docs/commands/exchange/show.md create mode 100644 ts/docs/commands/exchange/trade.md create mode 100644 ts/docs/commands/exchange/withdraw.md create mode 100644 ts/docs/commands/import/keystore.md create mode 100644 ts/src/adapters/inbound/cli/commands/asset.ts create mode 100644 ts/src/adapters/inbound/cli/commands/exchange.ts create mode 100644 ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts create mode 100644 ts/src/adapters/inbound/cli/render/asset.ts create mode 100644 ts/src/adapters/inbound/cli/render/exchange.ts create mode 100644 ts/src/adapters/outbound/chain/tron/asset-contract-codec.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/asset-contract-codec.ts create mode 100644 ts/src/adapters/outbound/chain/tron/node-errors.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/node-errors.ts create mode 100644 ts/src/adapters/outbound/persistence/backup-records.test.ts create mode 100644 ts/src/adapters/outbound/persistence/backup-records.ts create mode 100644 ts/src/application/ports/backup-records.ts create mode 100644 ts/src/application/use-cases/tron/asset-service.test.ts create mode 100644 ts/src/application/use-cases/tron/asset-service.ts create mode 100644 ts/src/application/use-cases/tron/exchange-service.test.ts create mode 100644 ts/src/application/use-cases/tron/exchange-service.ts create mode 100644 ts/src/application/use-cases/wallet-service.keystore.test.ts create mode 100644 ts/src/domain/asset/asset.test.ts create mode 100644 ts/src/domain/asset/index.ts create mode 100644 ts/src/domain/exchange/exchange.test.ts create mode 100644 ts/src/domain/exchange/index.ts create mode 100644 ts/src/domain/keystore/index.ts create mode 100644 ts/src/domain/keystore/keystore-v3.test.ts 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..16c84f3cf --- /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` carries `offset` and `limit` only, and 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..8d73787bb 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"}],"pagination":{"offset":0,"limit":null,"total":1}},"meta":{"durationMs":8,"warnings":[]}} ``` ## 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; `data.pagination` carries `offset`, `limit` (`null` when unlimited) and the pre-window `total`. + +| 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/deploy.md b/ts/docs/commands/contract/deploy.md index 76c74cc75..12a88e644 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -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 | diff --git a/ts/docs/commands/contract/send.md b/ts/docs/commands/contract/send.md index 5ca745510..c76ed27f3 100644 --- a/ts/docs/commands/contract/send.md +++ b/ts/docs/commands/contract/send.md @@ -33,7 +33,7 @@ Requires an account. The master password (via `--password-stdin`) is needed only | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/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..54446af8f --- /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` carries `offset` and `limit` only. 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 9e2a49d77..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) | 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/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/machine-interface.md b/ts/docs/machine-interface.md index b8607d3b0..38417c136 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -100,6 +100,8 @@ 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 | @@ -113,6 +115,9 @@ 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 | 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/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/wallet.backup.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts index 831b46c42..16bf27db0 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts @@ -52,9 +52,12 @@ function fixture(opts: { tty: boolean }) { 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 }), - }), + walletService: new WalletService( + keystore, + {} as any, + { write: () => ({ out: "unused", fileMode: "0600", bytes: 0 }) }, + { append: () => {}, list: () => [] }, + ), ledger: {} as any, } as any); 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..0f449dc2d --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts @@ -0,0 +1,273 @@ +/** + * 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"); + }); + + it("returns records with pagination", 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 { data } = f.envelope(); + expect(data.records.map((r: BackupRecord) => r.out)).toEqual(["./1.json"]); + expect(data.pagination).toEqual({ offset: 0, limit: 1, total: 2 }); + }); + + 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..1c3e3b0fd 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,45 @@ 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"), + offset: z.coerce.number().int().min(0).default(0) + .describe("with --records: pagination offset"), + }) + const RECORD_FILTERS = ["from", "to", "limit"] 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 +479,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" }, + // --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/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/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/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index 674931263..0d76b5d14 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -16,6 +16,8 @@ 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" @@ -34,6 +36,8 @@ export { FAMILY_RENDER, renderFamily } from "./family.js" export const TextFormatters = { ...WalletFormatters, ...AccountFormatters, + ...AssetFormatters, + ...ExchangeFormatters, ...TxFormatters, ...StakeFormatters, ...VoteFormatters, diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index 11b935969..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" @@ -164,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/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 8d493df89..1420827a8 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -92,7 +92,9 @@ export type TxReceiptKind = | "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). @@ -135,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; From 839c9df58dc2de80db775da731328a63ac06c5ea Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 10 Aug 2026 16:25:49 +0800 Subject: [PATCH 03/15] fix(ts): repair the governance commands from PR #972 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of d12bd1c1 (proposal / witness / contract governance, another contributor's work) against §1 and §2 of the v4.12.0 requirements, validated live on Nile with a registered witness account. Two defects made 6 of the 12 new commands unusable against a real node; both were invisible to the existing tests because those mock the gateway port, so they could only ever re-assert the adapter's own assumptions. - getWitness called /wallet/getwitnessbyaddress, which does not exist on any node (POST 405 / GET 404 on mainnet and Nile). Every witness-status check therefore failed with rpc_error, breaking `witness create`, `witness update`, `witness set-brokerage` and — via assertWitness — `proposal create`, `proposal approve` and `proposal delete`. Read the witness list and filter locally instead: one request, no fan-out, and listwitnesses covers every witness rather than only the active 27. - normalizeProposal rejected an array of parameters and fell back to {}, but listproposals only ever sends an array. Every proposal reported zero parameter changes — the field that says what a proposal does — and `proposal create --wait` could not resolve the id of the proposal it had just created, since findCreatedProposal matches on that set. Also gate the writes the Ledger TRON app cannot parse (WitnessCreate, WitnessUpdate, UpdateBrokerage, ClearABI, UpdateEnergyLimit, UpdateSetting): governanceTransactionMode already accepted requireSoftware but no call site passed it, so a Ledger user reached the device and spent RPCs before APDU 0x6a80. The proposal group stays ungated — its contract types are on the app's allowlist. See adr/0003. Tighten `witness create`'s activation check from "empty object" to a present address, matching accountExists, and make the fixtures realistic. Adds adapter-level coverage over a verbatim mainnet listproposals payload and per-command Ledger assertions; both fail if the fixes are reverted. Verified on Nile: witness update and set-brokerage confirmed on chain (url change re-read from listwitnesses); proposal create -> id 20662 resolved -> show renders the change -> approve -> already_approved -> --cancel -> not_approved -> delete -> canceled. Contract governance reaches real endpoints (not_contract_deployer / contract_not_found). create2 verified byte-exact against an independent implementation of Java's formula. Co-Authored-By: Claude Opus 5 (1M context) --- .../chain/tron/tron.proposals.test.ts | 120 ++++++++++++++++++ ts/src/adapters/outbound/chain/tron/tron.ts | 68 ++++++++-- .../use-cases/tron/contract-service.ts | 6 +- .../use-cases/tron/witness-service.test.ts | 49 ++++++- .../use-cases/tron/witness-service.ts | 22 +++- 5 files changed, 245 insertions(+), 20 deletions(-) create mode 100644 ts/src/adapters/outbound/chain/tron/tron.proposals.test.ts diff --git a/ts/src/adapters/outbound/chain/tron/tron.proposals.test.ts b/ts/src/adapters/outbound/chain/tron/tron.proposals.test.ts new file mode 100644 index 000000000..580ed7369 --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/tron.proposals.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TronRpcClient } from "./tron.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** + * Guards the HTTP→domain boundary for proposals, which had no coverage and was silently wrong: the + * normalizer accepted a parameter MAP and rejected arrays, while `/wallet/listproposals` only ever + * sends an array. Every proposal therefore reported zero changes — the one field that says what a + * proposal actually does — and `proposal create --wait` could not identify the proposal it had just + * created (it matches on the parameter set). + * + * The payload below is a verbatim excerpt of mainnet proposal 106 (parameter 94 → 1), so this test + * fails if the real node shape stops being handled. Service-level fixtures cannot catch that: they + * mock the port, so they can only re-encode whatever shape the adapter believes in. + */ +const MAINNET_106 = JSON.stringify({ + proposals: [ + { + proposal_id: 106, + proposer_address: "41456798cb4ab28109d8cc643cd7da7bd6069ceae9", + parameters: [{ key: 94, value: 1 }], + expiration_time: 1775822400000, + create_time: 1775543550000, + approvals: ["41456798cb4ab28109d8cc643cd7da7bd6069ceae9"], + state: "APPROVED", + }, + ], +}); + +function stubNode(body: string) { + const fetch = vi.fn(async () => new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + })); + vi.stubGlobal("fetch", fetch); + return fetch; +} + +describe("TronRpcClient.getProposals", () => { + it("keeps the parameter changes the node sends as an array", async () => { + stubNode(MAINNET_106); + const [proposal] = await new TronRpcClient("https://node.invalid", 1000).getProposals(); + + // keyed by protocol parameter id, values stringified — the shape the domain maps to names/units + expect(proposal!.parameters).toEqual({ "94": "1" }); + expect(proposal!.id).toBe(106); + expect(proposal!.state).toBe("APPROVED"); + expect(proposal!.proposerAddress).toBe("TGJBjL8wmRVyRStkghnhcVNYYgn6Yjno6X"); + expect(proposal!.approvals).toEqual(["TGJBjL8wmRVyRStkghnhcVNYYgn6Yjno6X"]); + }); + + it("keeps every entry of a multi-parameter proposal", async () => { + stubNode(JSON.stringify({ + proposals: [{ + proposal_id: 7, + proposer_address: "41456798cb4ab28109d8cc643cd7da7bd6069ceae9", + parameters: [{ key: 3, value: 15 }, { key: 2, value: 200000 }], + expiration_time: 1, + create_time: 0, + approvals: [], + state: "PENDING", + }], + })); + const [proposal] = await new TronRpcClient("https://node.invalid", 1000).getProposals(); + expect(proposal!.parameters).toEqual({ "3": "15", "2": "200000" }); + }); + + // tronweb's typings describe a map; accepted so a different gateway cannot regress the group. + it("also accepts a parameter map keyed by id", async () => { + stubNode(JSON.stringify({ + proposals: [{ + proposal_id: 8, + proposer_address: "41456798cb4ab28109d8cc643cd7da7bd6069ceae9", + parameters: { "3": 15 }, + expiration_time: 1, + create_time: 0, + approvals: [], + state: "PENDING", + }], + })); + const [proposal] = await new TronRpcClient("https://node.invalid", 1000).getProposals(); + expect(proposal!.parameters).toEqual({ "3": "15" }); + }); + + // Written as RAW json: a JS number literal this large is already rounded before it reaches the + // stub, so building the payload with JSON.stringify would test nothing. + it("preserves a parameter value beyond Number.MAX_SAFE_INTEGER as an exact string", async () => { + stubNode(`{ + "proposals": [{ + "proposal_id": 9, + "proposer_address": "41456798cb4ab28109d8cc643cd7da7bd6069ceae9", + "parameters": [{ "key": 61, "value": 9007199254740993 }], + "expiration_time": 1, + "create_time": 0, + "approvals": [], + "state": "PENDING" + }] + }`); + const [proposal] = await new TronRpcClient("https://node.invalid", 1000).getProposals(); + expect(proposal!.parameters["61"]).toBe("9007199254740993"); + }); + + it("yields no changes — not a crash — when the node omits parameters entirely", async () => { + stubNode(JSON.stringify({ + proposals: [{ + proposal_id: 10, + proposer_address: "41456798cb4ab28109d8cc643cd7da7bd6069ceae9", + expiration_time: 1, + create_time: 0, + approvals: [], + state: "CANCELED", + }], + })); + const [proposal] = await new TronRpcClient("https://node.invalid", 1000).getProposals(); + expect(proposal!.parameters).toEqual({}); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index f67b7fbcc..4867a637e 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -832,17 +832,36 @@ export class TronRpcClient implements TronGateway, Broadcaster { return witnesses.map(normalizeWitness).filter((w): w is TronWitness => w !== null); }); } + /** + * The witness record for `address`, or null when it is not a registered witness. + * + * Read from the witness LIST and filtered locally, because there is no per-address witness + * endpoint: `/wallet/getwitnessbyaddress` does not exist on any node (POST 405 / GET 404 on both + * mainnet and Nile), so asking for one can only ever fail. One request, no per-row fan-out — + * 440 records / 59 KB on mainnet, 842 / 89 KB on Nile — and it runs once as a pre-flight for a + * write, never inside a listing. + * + * `listwitnesses` rather than `getpaginatednowwitnesslist`: it returns EVERY witness in a single + * response, and the rights this gates (brokerage, creating/approving proposals) belong to any + * witness, not only the 27 currently-active SRs. + */ async getWitness(address: string): Promise { - return this.#wrap("getWitnessByAddress", async () => { - const response = await fetch(`${this.#fullHost}/wallet/getwitnessbyaddress`, { + return this.#wrap("listWitnesses", async () => { + const response = await fetch(`${this.#fullHost}/wallet/listwitnesses`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ address: this.#tw.address.toHex(address) }), + body: "{}", signal: AbortSignal.timeout(this.#timeoutMs), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const normalized = normalizeAccountValue(parseLosslessJson(await response.text())); - return normalizeWitness(normalized); + const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record; + const witnesses = Array.isArray(raw.witnesses) ? raw.witnesses : []; + // normalizeWitness yields base58; compare against the caller's ref in the same form. + const wanted = hexToBase58(address) || address; + return witnesses + .map(normalizeWitness) + .find((witness): witness is TronWitness => witness !== null && witness.address === wanted) + ?? null; }); } async getProposals(): Promise { @@ -1250,6 +1269,38 @@ function normalizeWitness(value: unknown): TronWitness | null { }; } +/** + * A proposal's parameter changes, keyed by protocol parameter id — the substance of the proposal. + * + * The node sends `parameters` as an ARRAY of `{key, value}` (`/wallet/listproposals`), which is the + * only shape observed on mainnet and Nile. A map keyed by id is accepted too, because that is what + * tronweb's typings describe and what a future gateway could hand us; both collapse to the same + * `{ "": "" }` record the domain works with. + * + * Getting this wrong is silent: an unrecognised shape yields `{}`, so every proposal renders with + * "no changes" and `proposal create --wait` cannot match the proposal it just made. Hence the + * explicit array branch and the adapter-level test over a real node payload. + */ +function normalizeProposalParameters(value: unknown): Record { + if (Array.isArray(value)) { + const entries = value.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const { key, value: parameterValue } = entry as { key?: unknown; value?: unknown }; + if (key === undefined || key === null || parameterValue === undefined || parameterValue === null) return []; + return [[String(key), String(parameterValue)] as const]; + }); + return Object.fromEntries(entries); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined && entry !== null) + .map(([key, entry]) => [key, String(entry)]), + ); + } + return {}; +} + function normalizeProposal(value: unknown): TronProposal | null { if (!value || typeof value !== "object") return null; const raw = value as Record; @@ -1257,12 +1308,7 @@ function normalizeProposal(value: unknown): TronProposal | null { if (!Number.isSafeInteger(id) || id < 0) return null; const proposerAddress = hexToBase58(raw.proposer_address ?? raw.proposerAddress); if (!proposerAddress) return null; - const parametersRaw = raw.parameters && typeof raw.parameters === "object" && !Array.isArray(raw.parameters) - ? raw.parameters as Record - : {}; - const parameters = Object.fromEntries( - Object.entries(parametersRaw).map(([key, entry]) => [key, String(entry)]), - ); + const parameters = normalizeProposalParameters(raw.parameters); const stateValue = raw.state; const states = ["PENDING", "DISAPPROVED", "APPROVED", "CANCELED"] as const; const state = typeof stateValue === "string" && states.includes(stateValue.toUpperCase() as typeof states[number]) diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index c9b158207..4c27fcb48 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -225,7 +225,11 @@ export class TronContractService { fields: Record, ) { const gateway = this.gateways.get(network, "tron"); - const mode = governanceTransactionMode(this.pipeline, scope, input); + // ClearABIContract / UpdateEnergyLimitContract / UpdateSettingContract are all absent from the + // Ledger TRON app's contract-type allowlist, so the device cannot parse any of them (APDU + // 0x6a80). Refuse before the unlock prompt and before any RPC — docs/adr/0003. `send` above is + // deliberately ungated: TriggerSmartContract IS allowlisted. + const mode = governanceTransactionMode(this.pipeline, scope, input, { requireSoftware: true }); const owner = scope.resolveAddress("tron"); let metadata; try { diff --git a/ts/src/application/use-cases/tron/witness-service.test.ts b/ts/src/application/use-cases/tron/witness-service.test.ts index 699db2187..6a4b54b49 100644 --- a/ts/src/application/use-cases/tron/witness-service.test.ts +++ b/ts/src/application/use-cases/tron/witness-service.test.ts @@ -15,17 +15,19 @@ const scope: TransactionScope = { function createService(gateway: Partial) { const concrete = gateway as TronGateway; + const assertCanSign = vi.fn(); const pipeline = { - assertCanSign: vi.fn(), + assertCanSign, run: async (params: TxPipelineParams) => { await params.build(OWNER); return { stage: "submitted", txId: "tx-witness", feeSun: 0 } as never; }, } as unknown as TxPipeline; - return new TronWitnessService( + const service = new TronWitnessService( { get: () => concrete } as unknown as ChainGatewayProvider, pipeline, ); + return Object.assign(service, { assertCanSign }) as TronWitnessService & { assertCanSign: typeof assertCanSign }; } describe("TronWitnessService", () => { @@ -33,7 +35,7 @@ describe("TronWitnessService", () => { const build = vi.fn(async () => ({})); const service = createService({ getWitness: async () => null, - getAccount: async () => ({ balance: "10000000000" }), + getAccount: async () => ({ address: OWNER, balance: "10000000000" }), getChainParameters: async () => [{ key: "getAccountUpgradeCost", value: 9_999_000_000 }], buildWitnessCreate: build, }); @@ -51,7 +53,7 @@ describe("TronWitnessService", () => { const build = vi.fn(); const service = createService({ getWitness: async () => null, - getAccount: async () => ({ balance: "9998999999" }), + getAccount: async () => ({ address: OWNER, balance: "9998999999" }), getChainParameters: async () => [{ key: "getAccountUpgradeCost", value: 9_999_000_000 }], buildWitnessCreate: build, }); @@ -70,4 +72,43 @@ describe("TronWitnessService", () => { .resolves.toMatchObject({ brokerage: 20 }); expect(build).toHaveBeenCalledWith(OWNER, 20, { permissionId: 0 }); }); + + + it("refuses an unactivated account before demanding the registration fee", async () => { + const build = vi.fn(async () => ({})); + const service = createService({ + getWitness: async () => null, + getAccount: async () => ({}), + getChainParameters: async () => [{ key: "getAccountUpgradeCost", value: 9_999_000_000 }], + buildWitnessCreate: build, + }); + await expect(service.create(scope, NET, { url: "https://sr.example" })) + .rejects.toMatchObject({ code: "account_not_active" }); + expect(build).not.toHaveBeenCalled(); + }); + + // The Ledger TRON app has no parser for WitnessCreate / WitnessUpdate / UpdateBrokerage + // (java's ledger/wrapper/ContractTypeChecker lists neither), so every write in this group must be + // refused as software-only BEFORE the device is touched — docs/adr/0003. Asserted per command + // because the flag is passed at each call site and is easy to drop in one of them. + describe("Ledger accounts", () => { + const cases = [ + ["create", (s: ReturnType) => s.create(scope, NET, { url: "https://sr.example" })], + ["update", (s: ReturnType) => s.update(scope, NET, { url: "https://sr.example" })], + ["set-brokerage", (s: ReturnType) => s.setBrokerage(scope, NET, { percent: 20 })], + ] as const; + + it.each(cases)("`witness %s` demands a software signer", async (_label, call) => { + const service = createService({ + getWitness: async () => ({ address: OWNER, voteCount: "0", url: "u" } as never), + getAccount: async () => ({ address: OWNER, balance: "10000000000" }), + getChainParameters: async () => [{ key: "getAccountUpgradeCost", value: 9_999_000_000 }], + buildWitnessCreate: async () => ({}) as never, + buildWitnessUpdate: async () => ({}) as never, + buildWitnessSetBrokerage: async () => ({}) as never, + }); + await call(service).catch(() => undefined); // `create` rejects with already_witness; irrelevant here + expect(service.assertCanSign).toHaveBeenCalledWith(scope.activeAccount, "tron", { requireSoftware: true }); + }); + }); }); diff --git a/ts/src/application/use-cases/tron/witness-service.ts b/ts/src/application/use-cases/tron/witness-service.ts index 97cd6b894..5939eb0ca 100644 --- a/ts/src/application/use-cases/tron/witness-service.ts +++ b/ts/src/application/use-cases/tron/witness-service.ts @@ -13,6 +13,17 @@ import { type GovernanceTransactionInput, } from "./governance-transaction.js"; +/** + * The Ledger TRON app's parser implements a fixed contract-type allowlist (java's + * `ledger/wrapper/ContractTypeChecker`), and none of this group's types are on it: + * `WitnessCreateContract`, `WitnessUpdateContract`, `UpdateBrokerageContract`. The device answers + * APDU 0x6a80 and no app setting changes that, so refuse before the user is sent to unlock it and + * before any RPC is spent — the rule established for the asset group in docs/adr/0003. + * + * The `proposal` group is deliberately NOT gated: ProposalCreate/Approve/Delete *are* allowlisted. + */ +const LEDGER_CANNOT_SIGN = { requireSoftware: true } as const; + export interface WitnessUrlInput extends GovernanceTransactionInput { url: string; } @@ -29,7 +40,7 @@ export class TronWitnessService { async create(scope: TransactionScope, network: NetworkDescriptor, input: WitnessUrlInput) { const gateway = this.gateways.get(network, "tron"); - const mode = governanceTransactionMode(this.pipeline, scope, input); + const mode = governanceTransactionMode(this.pipeline, scope, input, LEDGER_CANNOT_SIGN); const owner = scope.resolveAddress("tron"); const [witness, account, parameters] = await Promise.all([ gateway.getWitness(owner), @@ -37,7 +48,10 @@ export class TronWitnessService { gateway.getChainParameters(), ]); if (witness) throw new ChainError("already_witness", `${owner} is already a registered witness`); - if (Object.keys(account).length === 0) { + // "Activated" means the node returned a record carrying an address. Testing for an EMPTY object + // is not the same thing: a node that answers with a stub (just `address`, no balance) would pass + // that check and the user would get an opaque node rejection instead of `account_not_active`. + if (!account.address) { throw new ChainError("account_not_active", `${owner} is not activated on-chain`); } const feeValue = parameters.find((entry) => entry.key === "getAccountUpgradeCost")?.value; @@ -80,7 +94,7 @@ export class TronWitnessService { async update(scope: TransactionScope, network: NetworkDescriptor, input: WitnessUrlInput) { const gateway = this.gateways.get(network, "tron"); - const mode = governanceTransactionMode(this.pipeline, scope, input); + const mode = governanceTransactionMode(this.pipeline, scope, input, LEDGER_CANNOT_SIGN); const owner = scope.resolveAddress("tron"); await requireWitness(gateway, owner); const outcome = await this.pipeline.run({ @@ -102,7 +116,7 @@ export class TronWitnessService { async setBrokerage(scope: TransactionScope, network: NetworkDescriptor, input: WitnessBrokerageInput) { const gateway = this.gateways.get(network, "tron"); - const mode = governanceTransactionMode(this.pipeline, scope, input); + const mode = governanceTransactionMode(this.pipeline, scope, input, LEDGER_CANNOT_SIGN); const owner = scope.resolveAddress("tron"); await requireWitness(gateway, owner); const outcome = await this.pipeline.run({ From b68d902c6a88da630725246863d7bd2c209126ae Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 10 Aug 2026 16:55:07 +0800 Subject: [PATCH 04/15] fix(ts): send origin_energy_limit as a json number, not a string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `contract set-origin-energy-limit` was rejected by every node with "Contract validate error : No contract!" — a message that points at the contract address, which was in fact correct. java-tron rebuilds the contract from the `raw_data` json on the non-visible broadcast path and IGNORES raw_data_hex. A numeric string does not parse into the int64 field, so the node validated an empty UpdateEnergyLimitContract, whose contract_address is empty. The CLI carries int64 quantities as strings by convention, and this builder passed that string straight into raw_data. Isolated on Nile with a single signed transaction, mutating only the json view so the signature and raw_data_hex stayed byte-identical: visible:false + hex + "9000000" -> CONTRACT_VALIDATE_ERROR, No contract! visible:false + hex + 9000000 -> accepted Coerced via #safeNumber, which refuses anything a json number cannot hold exactly. That replaces the previous "preserve a Java long" behaviour: such a value could only ever produce a transaction the node rejects, or silently set a rounded limit. Java's CLI can send int64 max because it speaks protobuf over gRPC; over HTTP+json we cannot, and refusing is the honest answer. Real limits are bounded by getTotalEnergyLimit (~1.8e11), far below the safe-integer ceiling, so nothing reachable is lost. Verified live on Nile after the fix: origin_energy_limit set to 12000000 and re-read from getcontract. The new test fails if the coercion is reverted. Note for follow-up: `proposal create` builds parameter values through the same local path and also stringifies values above 2^53, so it is likely to have the same defect. It is not reachable in practice — no TRON chain parameter is near that magnitude, and the actuator's own range checks reject absurd values — so it is reported rather than changed blindly. Co-Authored-By: Claude Opus 5 (1M context) --- .../chain/tron/tron.governance-build.test.ts | 71 +++++++++++++++++++ .../chain/tron/tron.governance.test.ts | 20 +++--- ts/src/adapters/outbound/chain/tron/tron.ts | 8 ++- 3 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 ts/src/adapters/outbound/chain/tron/tron.governance-build.test.ts diff --git a/ts/src/adapters/outbound/chain/tron/tron.governance-build.test.ts b/ts/src/adapters/outbound/chain/tron/tron.governance-build.test.ts new file mode 100644 index 000000000..c5c4dd587 --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/tron.governance-build.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { TronRpcClient } from "./tron.js"; + +const OWNER = "TNmoJ3Be59WFEq5dsW6eCkZjveiL3G8HVB"; +const CONTRACT = "TPgmqJ9ixVReY2Zc5FSYiC8qp4yZybbMhU"; + +function client() { + const c = new TronRpcClient("https://node.invalid", 1000); + c.tronweb.trx.getCurrentRefBlockParams = vi.fn(async () => ({ + ref_block_bytes: "4b6b", + ref_block_hash: "4ad4875499feb0de", + expiration: 1786000000000, + timestamp: 1785999940000, + })) as never; + return c; +} + +const contractValue = (tx: unknown) => + (tx as { raw_data: { contract: Array<{ parameter: { value: Record } }> } }) + .raw_data.contract[0]!.parameter.value; + +/** + * `UpdateEnergyLimitContract` is built locally (tronweb 6.4.0's validator rejects limits above + * 10,000,000, which the protocol allows), so this code owns the json shape — and one detail of it is + * load-bearing for interop: + * + * java-tron rebuilds the contract from `raw_data` json on the non-visible broadcast path, IGNORING + * raw_data_hex. A numeric STRING does not parse into the int64 field, so the node validates an empty + * message and answers "Contract validate error : No contract!" — a message that points at the + * contract address, which is in fact correct. Proven on Nile with one signed transaction and + * identical raw_data_hex: number accepted, string rejected. + * + * The CLI carries int64 quantities as strings by convention, so the coercion here is what keeps that + * convention from silently breaking the broadcast. + */ +describe("TronRpcClient.buildUpdateOriginEnergyLimit", () => { + it("puts origin_energy_limit in raw_data as a NUMBER when given a string", async () => { + const tx = await client().buildUpdateOriginEnergyLimit(OWNER, CONTRACT, "12000000"); + const value = contractValue(tx); + expect(typeof value.origin_energy_limit).toBe("number"); + expect(value.origin_energy_limit).toBe(12_000_000); + }); + + it("keeps a numeric input a number", async () => { + const value = contractValue(await client().buildUpdateOriginEnergyLimit(OWNER, CONTRACT, 5_000_000)); + expect(value.origin_energy_limit).toBe(5_000_000); + }); + + it("accepts a limit above tronweb's own 10,000,000 ceiling — the reason we build locally", async () => { + const value = contractValue(await client().buildUpdateOriginEnergyLimit(OWNER, CONTRACT, "50000000")); + expect(value.origin_energy_limit).toBe(50_000_000); + }); + + it("refuses a limit no json number can hold exactly, rather than losing precision silently", async () => { + await expect(client().buildUpdateOriginEnergyLimit(OWNER, CONTRACT, "9007199254740993")) + .rejects.toMatchObject({ code: "invalid_amount" }); + }); + + it("still binds the addresses and emits a self-consistent txID / raw_data_hex", async () => { + const tx = await client().buildUpdateOriginEnergyLimit(OWNER, CONTRACT, "12000000") as unknown as { + txID: string; raw_data_hex: string; + }; + const value = contractValue(tx); + expect(value.owner_address).toBe("418c7145112ac207cc95544a930c769d468d01cd4e"); + expect(value.contract_address).toBe("419676189bf6a884aeb297c2447e890326aa074502"); + expect(tx.txID).toMatch(/^[0-9a-f]{64}$/); + // the local encoder must produce the field in the wire bytes too (proto field 3, varint) + expect(tx.raw_data_hex).toContain("18"); // field 3 varint tag + expect(tx.raw_data_hex.startsWith("0a")).toBe(true); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.governance.test.ts b/ts/src/adapters/outbound/chain/tron/tron.governance.test.ts index 8038a5b86..269f8c95a 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.governance.test.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.governance.test.ts @@ -24,20 +24,22 @@ describe("TronRpcClient governance builders", () => { expect(() => assertTronTxIntegrity(transaction)).not.toThrow(); }); - it("preserves and integrity-checks a Java long origin energy limit", async () => { + // Java's CLI can send int64 max here because it speaks protobuf over gRPC. We cannot: java-tron + // rebuilds the contract from `raw_data` json on the non-visible broadcast path, and a numeric + // STRING does not parse into the int64 field — the node then validates an empty message and + // answers "Contract validate error : No contract!". Proven on Nile with one signed transaction and + // identical raw_data_hex. A json number cannot hold int64 max exactly either, so the only honest + // answer for such a value is to refuse it up front rather than emit a transaction the node will + // reject, or silently set a rounded limit. Real limits are bounded by getTotalEnergyLimit (~1.8e11), + // far below the safe-integer ceiling, so nothing reachable is lost. + it("refuses an origin energy limit no json number can hold exactly", async () => { const client = new TronRpcClient("http://127.0.0.1:1"); vi.spyOn(client.tronweb.trx, "getCurrentRefBlockParams").mockResolvedValue({ ref_block_bytes: "1234", ref_block_hash: "0011223344556677", expiration: 2_000_000, timestamp: 1_000_000, }); - const transaction = await client.buildUpdateOriginEnergyLimit( - OWNER, CONTRACT, "9223372036854775807", - ); - const value = transaction.raw_data.contract[0]!.parameter.value as unknown as { - origin_energy_limit: unknown; - }; - expect(value.origin_energy_limit).toBe("9223372036854775807"); - expect(() => assertTronTxIntegrity(transaction)).not.toThrow(); + await expect(client.buildUpdateOriginEnergyLimit(OWNER, CONTRACT, "9223372036854775807")) + .rejects.toMatchObject({ code: "invalid_amount" }); }); it("builds and integrity-checks a proposal value above Number.MAX_SAFE_INTEGER", async () => { diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 4867a637e..b349b2414 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -1143,7 +1143,13 @@ export class TronRpcClient implements TronGateway, Broadcaster { { owner_address: this.#tw.address.toHex(owner), contract_address: this.#tw.address.toHex(contract), - origin_energy_limit: energy, + // MUST be a json NUMBER, never a numeric string. java-tron rebuilds the contract from + // `raw_data` json (it ignores raw_data_hex on the non-visible broadcast path); a string + // fails to parse into the int64 field and it validates an EMPTY message, which surfaces as + // the misleading "Contract validate error : No contract!" even though the address is right. + // Proven on Nile: one signed transaction, identical raw_data_hex — number accepted, string + // rejected. #safeNumber refuses anything Number cannot hold exactly. + origin_energy_limit: typeof energy === "string" ? this.#safeNumber(energy, "origin energy limit") : energy, }, options.permissionId, ), From a0ce486c4fc355aaf350d08b61b013cc937432fd Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 10 Aug 2026 17:26:38 +0800 Subject: [PATCH 05/15] fix(ts): make --build-only and --sign-only work for the governance writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All nine governance writes advertised --build-only and --sign-only and neither worked: build-only failed outright with "this chain adapter cannot produce transaction hex", and sign-only silently omitted `hex`, which is the entire point of the flag. None of the three services passed the pipeline's `artifact` hook. Passing it alone would have been wrong. `artifact` serialises through encodeTransactionHex, and the override table it consulted covered only the TRC10 types — so ProposalCreate and UpdateEnergyLimit, the two types with their own exact encoders precisely because tronweb encodes them wrongly, would have produced hex from tronweb. That is worse than an honest error. So the override dispatch is unified first: one table in transaction-codec covering both families, consulted by BOTH protobuf paths. rawDataHexOf had its own copy of the dispatch, which is how the two could disagree despite toProtobuf's comment claiming otherwise. tx-integrity then drops its per-type special-casing and simply compares against rawDataHexOf, removing the TODO left when the branches were merged. `artifact` only, deliberately not the full tronTransactionHooks: this group binds --permission-id in each builder and applies --expiration via withExtendedExpiration before the pipeline sees the transaction, so also supplying `prepare` would rebind Permission_id and extend the expiration a second time. Unifying on prepare is a separate refactor. Verified end-to-end on Nile, for both custom-encoded types, through the whole relay the flag exists for — build-only -> tx sign --hex -> tx broadcast: set-origin-energy-limit 146B unsigned -> 213B signed -> confirmed, origin_energy_limit 15000000 read back on chain proposal create 122B unsigned -> 189B signed -> confirmed as proposal 20663 with its parameter change intact (deleted afterwards) sign-only now carries hex (184 bytes on witness set-brokerage). Both new tests fail if their fix is reverted: the codec test asserts the two protobuf paths agree and that the encoded value is not the placeholder zero the exact encoders feed tronweb; the service test asserts all nine writes pass the hook, including witness create, whose success path cannot be exercised on chain. Co-Authored-By: Claude Opus 5 (1M context) --- .../tron/transaction-codec.governance.test.ts | 82 ++++++++++++++ .../outbound/chain/tron/transaction-codec.ts | 37 +++++-- .../outbound/chain/tron/tx-integrity.ts | 22 +--- .../use-cases/tron/contract-service.ts | 2 + .../tron/governance-artifact.test.ts | 103 ++++++++++++++++++ .../use-cases/tron/governance-transaction.ts | 14 +++ .../use-cases/tron/proposal-service.ts | 4 + .../use-cases/tron/witness-service.ts | 4 + 8 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 ts/src/adapters/outbound/chain/tron/transaction-codec.governance.test.ts create mode 100644 ts/src/application/use-cases/tron/governance-artifact.test.ts diff --git a/ts/src/adapters/outbound/chain/tron/transaction-codec.governance.test.ts b/ts/src/adapters/outbound/chain/tron/transaction-codec.governance.test.ts new file mode 100644 index 000000000..6ab1aa2ac --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/transaction-codec.governance.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { encodeTransactionHex, rawDataHexOf } from "./transaction-codec.js"; + +/** + * Both protobuf paths must apply the SAME override table. + * + * `rawDataHexOf` (the integrity arbiter) and `encodeTransactionHex` (the `--build-only` / + * `--sign-only` artifact) used to dispatch overrides separately, so a type could be correct in one + * and wrong in the other. That is how `--build-only` shipped unusable for the governance commands: + * the artifact path fell through to tronweb's encoder for exactly the two types we override because + * tronweb encodes them wrongly. + * + * The check below is shape-independent: whatever bytes our serialiser produces for a governance + * contract, BOTH functions must produce the same ones, and `encodeTransactionHex` must accept a + * raw_data_hex derived from `rawDataHexOf` rather than rejecting it as a mismatch. + */ +const REF = { + ref_block_bytes: "4b6b", + ref_block_hash: "4ad4875499feb0de", + expiration: 1786000000000, + timestamp: 1785999940000, +}; + +const OWNER_HEX = "418c7145112ac207cc95544a930c769d468d01cd4e"; +const CONTRACT_HEX = "419676189bf6a884aeb297c2447e890326aa074502"; + +function tx(type: string, value: Record) { + return { + visible: false, + raw_data: { + contract: [{ + parameter: { value, type_url: `type.googleapis.com/protocol.${type}` }, + type, + }], + ...REF, + }, + }; +} + +const CASES = [ + [ + "UpdateEnergyLimitContract", + { owner_address: OWNER_HEX, contract_address: CONTRACT_HEX, origin_energy_limit: 15_000_000 }, + ], + [ + "ProposalCreateContract", + { owner_address: OWNER_HEX, parameters: [{ key: 0, value: 100_000 }] }, + ], +] as const; + +describe("governance contracts encode identically on both protobuf paths", () => { + it.each(CASES)("%s: encodeTransactionHex agrees with rawDataHexOf", (type, value) => { + const candidate = tx(type, value as Record); + const rawDataHex = rawDataHexOf(candidate); + expect(rawDataHex).toMatch(/^[0-9a-f]+$/); + + // encodeTransactionHex verifies raw_data_hex against its own encoding and throws on mismatch, + // so this passing IS the proof that both paths used the same serialiser. + const complete = encodeTransactionHex({ ...candidate, raw_data_hex: rawDataHex }); + expect(complete).toContain(rawDataHex); + }); + + it.each(CASES)("%s: the encoded bytes carry the value, not a placeholder zero", (type, value) => { + // The exact encoders feed a zero placeholder to tronweb and then replace the Any payload; if a + // path skipped that replacement the value would silently serialise as 0. + const hex = rawDataHexOf(tx(type, value as Record)); + const zeroed = rawDataHexOf(tx( + type, + type === "UpdateEnergyLimitContract" + ? { owner_address: OWNER_HEX, contract_address: CONTRACT_HEX, origin_energy_limit: 0 } + : { owner_address: OWNER_HEX, parameters: [{ key: 0, value: 0 }] }, + )); + expect(hex).not.toBe(zeroed); + }); + + it("still routes the TRC10 overrides through the same table", () => { + // UnfreezeAssetContract has no tronweb serialiser at all, so a regression in the shared dispatch + // would surface here first. + const hex = rawDataHexOf(tx("UnfreezeAssetContract", { owner_address: OWNER_HEX })); + expect(hex).toMatch(/^[0-9a-f]+$/); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/transaction-codec.ts b/ts/src/adapters/outbound/chain/tron/transaction-codec.ts index 35a24361a..c755bc7fc 100644 --- a/ts/src/adapters/outbound/chain/tron/transaction-codec.ts +++ b/ts/src/adapters/outbound/chain/tron/transaction-codec.ts @@ -2,6 +2,7 @@ import { utils as tronUtils } from "tronweb"; import { ChainError } from "../../../../domain/errors/index.js"; import type { TronTransactionArtifact } from "../../../../domain/types/index.js"; import { decodeOverriddenContract, encodeOverriddenContract } from "./asset-contract-codec.js"; +import { proposalCreateTxJsonToPbExact, updateEnergyLimitTxJsonToPbExact } from "./proposal-protobuf.js"; const MAX_TRANSACTION_BYTES = 512 * 1024; const SIGNATURE_BYTES = 65; @@ -147,13 +148,36 @@ function withNamedEnums(candidate: Partial): Partial unknown>> = Object.freeze({ + ProposalCreateContract: proposalCreateTxJsonToPbExact, + UpdateEnergyLimitContract: updateEnergyLimitTxJsonToPbExact, +}); + +function encodeOverridden(candidate: Partial): ProtobufTransaction | undefined { + const type = candidate.raw_data?.contract?.[0]?.type; + if (typeof type === "string") { + const governance = GOVERNANCE_ENCODERS[type]; + if (governance) return governance(candidate) as ProtobufTransaction; + } + return encodeOverriddenContract(candidate) as unknown as ProtobufTransaction | undefined; +} + +/** + * TronWeb's JSON→protobuf encoder, with every contract type it gets wrong routed to our own + * serialiser first. Every path that reaches protobuf goes through `encodeOverridden`, so the + * override cannot be bypassed by one caller and silently corrupt a transaction. */ function toProtobuf(candidate: Partial): ProtobufTransaction { - const overridden = encodeOverriddenContract(candidate); - if (overridden) return overridden as unknown as ProtobufTransaction; + const overridden = encodeOverridden(candidate); + if (overridden) return overridden; try { return tronUtils.transaction.txJsonToPb(candidate) as ProtobufTransaction; } catch { @@ -172,8 +196,7 @@ function toProtobuf(candidate: Partial): ProtobufTransa */ export function rawDataHexOf(transaction: unknown): string { const candidate = withNamedEnums(transaction as Partial); - const overridden = encodeOverriddenContract(candidate) as unknown as ProtobufTransaction | undefined; - const pb = overridden ?? (tronUtils.transaction.txJsonToPb(candidate) as ProtobufTransaction); + const pb = encodeOverridden(candidate) ?? (tronUtils.transaction.txJsonToPb(candidate) as ProtobufTransaction); return tronUtils.transaction.txPbToRawDataHex(pb).toLowerCase(); } diff --git a/ts/src/adapters/outbound/chain/tron/tx-integrity.ts b/ts/src/adapters/outbound/chain/tron/tx-integrity.ts index 13d670a2d..5126f721d 100644 --- a/ts/src/adapters/outbound/chain/tron/tx-integrity.ts +++ b/ts/src/adapters/outbound/chain/tron/tx-integrity.ts @@ -44,10 +44,6 @@ import { sha256 } from "@noble/hashes/sha2.js" import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js" import { ChainError } from "../../../../domain/errors/index.js" -import { - proposalCreateTxCheckExact, - updateEnergyLimitTxCheckExact, -} from "./proposal-protobuf.js" import { rawDataHexOf } from "./transaction-codec.js" /** tronweb's txJsonToPb rejects contract types it has no protobuf mapping for with this message. */ @@ -132,19 +128,11 @@ export function assertTronTxIntegrity(tx: unknown): void { let matchesRawData: boolean try { // Never tronweb's `txCheck` here: it mis-encodes several contract types, so asking it would - // refuse transactions whose bytes are correct. Two independent sets of overrides exist and BOTH - // must apply — the governance ones (ProposalCreate / UpdateEnergyLimit, checked exactly) and the - // TRC10 ones inside `rawDataHexOf` (multi-tranche AssetIssue, UnfreezeAsset). `rawDataHexOf` is - // the general path and still delegates to tronweb for every type neither set overrides. - // TODO: fold the two proposal encoders into `encodeOverriddenContract` so there is one seam. - const contracts = Array.isArray((t.raw_data as { contract?: unknown })?.contract) - ? (t.raw_data as { contract: Array<{ type?: unknown }> }).contract - : [] - matchesRawData = contracts.some((contract) => contract?.type === "ProposalCreateContract") - ? proposalCreateTxCheckExact(tx) - : contracts.some((contract) => contract?.type === "UpdateEnergyLimitContract") - ? updateEnergyLimitTxCheckExact(tx) - : rawDataHexOf(tx) === t.raw_data_hex.replace(/^0x/, "").toLowerCase() + // refuse transactions whose bytes are correct. `rawDataHexOf` applies OUR serialiser for every + // overridden type — TRC10 (multi-tranche AssetIssue, UnfreezeAsset) and governance + // (ProposalCreate, UpdateEnergyLimit) — from one table, and delegates to tronweb for everything + // else. So this comparison uses the same arbiter the builders themselves used. + matchesRawData = rawDataHexOf(tx) === t.raw_data_hex.replace(/^0x/, "").toLowerCase() } catch (e) { const message = (e as Error)?.message ?? String(e) // The one tolerable failure: tronweb has no encoding for this contract type, so raw_data diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index 4c27fcb48..d901201d1 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -7,6 +7,7 @@ import { ChainError } from "../../../domain/errors/index.js"; import { computeTronCreate2Address } from "../../../domain/governance/create2.js"; import type { UnsignedTx } from "../../../domain/types/index.js"; import { + governanceArtifact, governanceTransactionMode, transactionResource, withExtendedExpiration, @@ -253,6 +254,7 @@ export class TronContractService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), + ...governanceArtifact(gateway), build: async (address) => withExtendedExpiration( gateway, await build(gateway, address), diff --git a/ts/src/application/use-cases/tron/governance-artifact.test.ts b/ts/src/application/use-cases/tron/governance-artifact.test.ts new file mode 100644 index 000000000..86c2d1040 --- /dev/null +++ b/ts/src/application/use-cases/tron/governance-artifact.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; +import { TronWitnessService } from "./witness-service.js"; +import { TronProposalService } from "./proposal-service.js"; +import { TronContractService } from "./contract-service.js"; + +const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] }; +const OWNER = "TNmoJ3Be59WFEq5dsW6eCkZjveiL3G8HVB"; +const CONTRACT = "TPgmqJ9ixVReY2Zc5FSYiC8qp4yZybbMhU"; +const scope: TransactionScope = { + activeAccount: "wlt_test.0", resolveAddress: () => OWNER, + timeoutMs: 60_000, wait: false, waitTimeoutMs: 60_000, emit: () => {}, warn: () => {}, +}; + +/** + * Every governance WRITE must hand the pipeline an `artifact` hook. + * + * Without it `--build-only` fails outright ("this chain adapter cannot produce transaction hex") and + * `--sign-only` silently omits `hex` — while both flags stay advertised in the command's help. All + * nine writes shipped that way, because the flags are wired generically and nothing asserted the + * hook was present. This test is per-command for that reason: the hook is passed at each call site. + */ +function harness(overrides: Partial = {}) { + const captured: TxPipelineParams[] = []; + const gateway = { + encodeTransactionHex: vi.fn(() => "0a02deadbeef"), + getWitness: async () => ({ address: OWNER, voteCount: "1", url: "u" }), + getAccount: async () => ({ address: OWNER, balance: "10000000000" }), + getChainParameters: async () => [ + { key: "getAccountUpgradeCost", value: 9_999_000_000 }, + { key: "getMaintenanceTimeInterval", value: 1_800_000 }, + ], + getProposals: async () => [{ + id: 7, proposerAddress: OWNER, parameters: { "0": "100000" }, + expirationTime: Date.now() + 600_000, createTime: Date.now(), approvals: [], state: "PENDING" as const, + }], + getWitnesses: async () => [{ address: OWNER, voteCount: "1", url: "u" }], + getContractMetadata: async () => ({ name: "t", methods: [], originAddress: OWNER, contract: {}, info: {} }), + getProposal: async () => ({ + id: 7, proposerAddress: OWNER, parameters: { "0": "100000" }, + expirationTime: Date.now() + 600_000, createTime: Date.now(), approvals: [], state: "PENDING" as const, + }), + buildWitnessCreate: async () => ({}), buildWitnessUpdate: async () => ({}), + buildWitnessSetBrokerage: async () => ({}), buildProposalCreate: async () => ({}), + buildProposalApprove: async () => ({}), buildProposalDelete: async () => ({}), + buildClearContractAbi: async () => ({}), buildUpdateOriginEnergyLimit: async () => ({}), + buildUpdateUserResourcePercent: async () => ({}), + ...overrides, + } as unknown as TronGateway; + + const pipeline = { + assertCanSign: vi.fn(), + run: async (params: TxPipelineParams) => { + captured.push(params); + return { stage: "submitted", txId: "tx", feeSun: 0 } as never; + }, + } as unknown as TxPipeline; + + const provider = { get: () => gateway } as unknown as ChainGatewayProvider; + return { + captured, + witness: new TronWitnessService(provider, pipeline), + proposal: new TronProposalService(provider, pipeline), + contract: new TronContractService(provider, pipeline), + }; +} + +describe("every governance write supplies the --build-only / --sign-only artifact hook", () => { + const calls: Array<[string, (h: ReturnType) => Promise]> = [ + ["witness update", (h) => h.witness.update(scope, NET, { url: "https://sr.example" })], + ["witness set-brokerage", (h) => h.witness.setBrokerage(scope, NET, { percent: 20 })], + ["proposal create", (h) => h.proposal.create(scope, NET, { set: ["getMaintenanceTimeInterval=100000"] } as never)], + ["proposal approve", (h) => h.proposal.approve(scope, NET, { id: 7 } as never)], + ["proposal delete", (h) => h.proposal.delete(scope, NET, { id: 7 } as never)], + ["contract clear-abi", (h) => h.contract.clearAbi(scope, NET, { address: CONTRACT })], + ["contract set-origin-energy-limit", (h) => h.contract.setOriginEnergyLimit(scope, NET, { address: CONTRACT, energy: "15000000" })], + ["contract set-user-resource-percent", (h) => h.contract.setUserResourcePercent(scope, NET, { address: CONTRACT, percent: 60 })], + ]; + + it.each(calls)("`%s` passes artifact", async (_label, call) => { + const h = harness(); + await call(h); // a guard rejecting here would mean the double is wrong, not the hook + expect(h.captured).toHaveLength(1); + const { artifact } = h.captured[0]!; + expect(typeof artifact).toBe("function"); + expect(artifact!({} as never)).toBe("0a02deadbeef"); + }); + + // `witness create` is the ninth write; it burns 9999 TRX so its own success path is not + // exercised on chain, which makes the hook assertion here the only coverage it gets. + it("`witness create` passes artifact too", async () => { + // The ninth write. Its success path is never exercised on chain (it burns 9999 TRX), so this is + // the only coverage the hook gets there. Needs a gateway that reports "not yet a witness". + const h = harness({ getWitness: async () => null }); + await h.witness.create(scope, NET, { url: "https://sr.example" }); + expect(h.captured).toHaveLength(1); + expect(typeof h.captured[0]!.artifact).toBe("function"); + }); +}); diff --git a/ts/src/application/use-cases/tron/governance-transaction.ts b/ts/src/application/use-cases/tron/governance-transaction.ts index 10f40bfed..d69240c9e 100644 --- a/ts/src/application/use-cases/tron/governance-transaction.ts +++ b/ts/src/application/use-cases/tron/governance-transaction.ts @@ -29,6 +29,20 @@ export function governanceTransactionMode( return mode; } +/** + * The hook `--build-only` and `--sign-only` need: complete transaction hex. Without it the pipeline + * refuses build-only outright ("this chain adapter cannot produce transaction hex") and omits `hex` + * from sign-only, which is the whole point of both flags. + * + * Only `artifact` — deliberately NOT the full `tronTransactionHooks`. This group binds + * `--permission-id` inside each builder and applies `--expiration` via `withExtendedExpiration` + * before the pipeline sees the transaction, so also supplying `prepare` would rebind Permission_id + * and extend the expiration a SECOND time. Unifying on `prepare` is a separate refactor. + */ +export function governanceArtifact(gateway: TronGateway) { + return { artifact: (transaction: UnsignedTx) => gateway.encodeTransactionHex(transaction) }; +} + export async function withExtendedExpiration( gateway: TronGateway, transaction: UnsignedTx, diff --git a/ts/src/application/use-cases/tron/proposal-service.ts b/ts/src/application/use-cases/tron/proposal-service.ts index dc3e6ee30..296705a1c 100644 --- a/ts/src/application/use-cases/tron/proposal-service.ts +++ b/ts/src/application/use-cases/tron/proposal-service.ts @@ -12,6 +12,7 @@ import type { TxPipeline } from "../../services/pipeline/index.js"; import { outcomeData } from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; import { + governanceArtifact, governanceTransactionMode, transactionResource, withExtendedExpiration, @@ -100,6 +101,7 @@ export class TronProposalService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), + ...governanceArtifact(gateway), build: async (address) => withExtendedExpiration( gateway, await gateway.buildProposalCreate( @@ -150,6 +152,7 @@ export class TronProposalService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), + ...governanceArtifact(gateway), build: async (address) => withExtendedExpiration( gateway, await gateway.buildProposalApprove(address, input.id, addApproval, { permissionId: input.permissionId }), @@ -192,6 +195,7 @@ export class TronProposalService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), + ...governanceArtifact(gateway), build: async (address) => withExtendedExpiration( gateway, await gateway.buildProposalDelete(address, input.id, { permissionId: input.permissionId }), diff --git a/ts/src/application/use-cases/tron/witness-service.ts b/ts/src/application/use-cases/tron/witness-service.ts index 5939eb0ca..e6323dffe 100644 --- a/ts/src/application/use-cases/tron/witness-service.ts +++ b/ts/src/application/use-cases/tron/witness-service.ts @@ -7,6 +7,7 @@ import type { TxPipeline } from "../../services/pipeline/index.js"; import { outcomeData } from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; import { + governanceArtifact, governanceTransactionMode, transactionResource, withExtendedExpiration, @@ -72,6 +73,7 @@ export class TronWitnessService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), + ...governanceArtifact(gateway), build: async (address) => withExtendedExpiration( gateway, await gateway.buildWitnessCreate(address, input.url, { permissionId: input.permissionId }), @@ -104,6 +106,7 @@ export class TronWitnessService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), + ...governanceArtifact(gateway), build: async (address) => withExtendedExpiration( gateway, await gateway.buildWitnessUpdate(address, input.url, { permissionId: input.permissionId }), @@ -126,6 +129,7 @@ export class TronWitnessService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), + ...governanceArtifact(gateway), build: async (address) => withExtendedExpiration( gateway, await gateway.buildWitnessSetBrokerage(address, input.percent, { permissionId: input.permissionId }), From 858402645bcb2a455111542957fb454842dce44d Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 10 Aug 2026 18:32:09 +0800 Subject: [PATCH 06/15] feat(ts): integrate --- ts/docs/commands/asset/list.md | 2 +- ts/docs/commands/backup.md | 4 +- ts/docs/commands/exchange/list.md | 2 +- ts/docs/commands/proposal/approve.md | 4 +- ts/docs/commands/proposal/create.md | 4 +- ts/docs/commands/witness/create.md | 4 +- ts/docs/machine-interface.md | 33 +++++++++ .../cli/commands/wallet.keystore.test.ts | 11 +-- .../inbound/cli/contracts/envelope.ts | 12 ++++ ts/src/adapters/inbound/cli/help/index.ts | 13 ++-- .../cli/help/root-help-coverage.test.ts | 68 +++++++++++++++++++ .../adapters/inbound/cli/output/envelope.ts | 11 +-- ts/src/adapters/inbound/cli/output/index.ts | 28 +++++--- .../inbound/cli/output/output.test.ts | 52 ++++++++++++++ .../cli/shell/positional-contract.test.ts | 5 +- .../services/pipeline/pipeline.test.ts | 29 ++++++-- .../services/transaction-mode.test.ts | 8 ++- .../use-cases/tron/account-service.ts | 4 +- 18 files changed, 253 insertions(+), 41 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/help/root-help-coverage.test.ts diff --git a/ts/docs/commands/asset/list.md b/ts/docs/commands/asset/list.md index 16c84f3cf..9abdbd286 100644 --- a/ts/docs/commands/asset/list.md +++ b/ts/docs/commands/asset/list.md @@ -14,7 +14,7 @@ Lists TRC10 tokens with id, name, total supply, precision and issuer. Use [`asse **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` carries `offset` and `limit` only, and the text header reads `Assets (limit 10, offset 0)`. Page until you get a short page. +**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. diff --git a/ts/docs/commands/backup.md b/ts/docs/commands/backup.md index 8d73787bb..8e2065347 100644 --- a/ts/docs/commands/backup.md +++ b/ts/docs/commands/backup.md @@ -109,7 +109,7 @@ 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"}],"pagination":{"offset":0,"limit":null,"total":1}},"meta":{"durationMs":8,"warnings":[]}} +{"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 @@ -137,7 +137,7 @@ The two modes return **different shapes** and therefore different `command` ids: ### Audit log (`backup --records`) -`data.records` is newest-first; `data.pagination` carries `offset`, `limit` (`null` when unlimited) and the pre-window `total`. +`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 | |---|---|---| diff --git a/ts/docs/commands/exchange/list.md b/ts/docs/commands/exchange/list.md index 54446af8f..e42ed08b0 100644 --- a/ts/docs/commands/exchange/list.md +++ b/ts/docs/commands/exchange/list.md @@ -16,7 +16,7 @@ Lists exchange pairs with their two token ids, reserves and creator. 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` carries `offset` and `limit` only. Page until you get a short page. +**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 diff --git a/ts/docs/commands/proposal/approve.md b/ts/docs/commands/proposal/approve.md index 573428072..0af54cb43 100644 --- a/ts/docs/commands/proposal/approve.md +++ b/ts/docs/commands/proposal/approve.md @@ -20,8 +20,8 @@ TRON proposals have approval and un-approval, not an against vote. The default m | `` | Positive proposal id | | `--cancel` | Remove this witness's existing approval | | `--dry-run`, `--sign-only`, `--build-only` | Mutually exclusive transaction modes | -| `--expiration ` | Build/sign-only expiry extension, max 24 h | -| `--permission-id ` | TRON permission group; 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` | ## Example diff --git a/ts/docs/commands/proposal/create.md b/ts/docs/commands/proposal/create.md index 4944defc7..70bb4908b 100644 --- a/ts/docs/commands/proposal/create.md +++ b/ts/docs/commands/proposal/create.md @@ -21,8 +21,8 @@ Only a registered witness can create a proposal. Parameter names match [`chain p | `--dry-run` | Build and estimate without signing | | `--sign-only` | Sign without broadcasting | | `--build-only` | Return the unsigned transaction without accessing a signer | -| `--expiration ` | Extend expiry by at most 86,400,000 ms; build/sign-only only | -| `--permission-id ` | TRON permission group; 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` | Plus `--account`, `--password-stdin`, and the [global options](../index.md#global-options-every-command). diff --git a/ts/docs/commands/witness/create.md b/ts/docs/commands/witness/create.md index dc7a0eba9..026dffa58 100644 --- a/ts/docs/commands/witness/create.md +++ b/ts/docs/commands/witness/create.md @@ -18,8 +18,8 @@ Registration burns the current `getAccountUpgradeCost` chain parameter and canno |---|---| | `--url ` | Required candidate information URL, at most 256 UTF-8 bytes | | `--dry-run`, `--sign-only`, `--build-only` | Mutually exclusive transaction modes | -| `--expiration ` | Build/sign-only expiry extension, max 24 h | -| `--permission-id ` | TRON permission group; 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` | Plus `--account`, `--wait`, `--password-stdin`, and the [global options](../index.md#global-options-every-command). diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index 38417c136..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. diff --git a/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts index 0f449dc2d..44b5d2067 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts @@ -166,12 +166,15 @@ describe("backup --records", () => { expect(f.envelope().command).toBe("backup.records"); }); - it("returns records with pagination", async () => { + // 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 { data } = f.envelope(); - expect(data.records.map((r: BackupRecord) => r.out)).toEqual(["./1.json"]); - expect(data.pagination).toEqual({ offset: 0, limit: 1, total: 2 }); + 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 () => { 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 69a118883..747aac371 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -101,6 +101,8 @@ 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", ""], + ["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"], @@ -178,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, "") 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 0588229b2..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,7 +39,7 @@ export const OutputEnvelope = { command: string, net: NetworkDescriptor | undefined, data: unknown, - m: { durationMs: number; warnings: WarningItem[] }, + m: Meta, ): ResultEnvelope { const env: ResultEnvelope = { schema: SCHEMA_VERSION, @@ -53,7 +56,7 @@ export const OutputEnvelope = { command: string, net: NetworkDescriptor | undefined, err: CliErrorEnvelopeShape, - m: { durationMs: number; warnings: WarningItem[] }, + m: Meta, ): ErrorEnvelope { const env: ErrorEnvelope = { schema: SCHEMA_VERSION, diff --git a/ts/src/adapters/inbound/cli/output/index.ts b/ts/src/adapters/inbound/cli/output/index.ts index 6fabf9869..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"; @@ -59,26 +59,32 @@ class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter } } -/** Pagination is envelope metadata in the public JSON contract, while text renderers consume the - * same value from their view model to produce `showing N of total` titles. */ -function extractPagination(data: unknown): { - data: unknown; - pagination?: { offset: number; limit: number | null; total: number }; -} { +/** + * 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)) || - !Number.isInteger(pagination.total) + !(pagination.limit === null || Number.isInteger(pagination.limit)) ) return { data }; - const normalized = { + const normalized: Pagination = { offset: Number(pagination.offset), limit: pagination.limit === null ? null : Number(pagination.limit), - total: Number(pagination.total), + total: Number.isInteger(pagination.total) ? Number(pagination.total) : null, }; const clean = { ...source }; delete clean.pagination; diff --git a/ts/src/adapters/inbound/cli/output/output.test.ts b/ts/src/adapters/inbound/cli/output/output.test.ts index f73b7f0f3..8f407493d 100644 --- a/ts/src/adapters/inbound/cli/output/output.test.ts +++ b/ts/src/adapters/inbound/cli/output/output.test.ts @@ -59,6 +59,58 @@ describe("createOutputFormatter (json)", () => { 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/shell/positional-contract.test.ts b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts index 1944eaa8c..2dcfd2f89 100644 --- a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts +++ b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts @@ -66,9 +66,12 @@ describe("every registered positional command rejects its -- spelling", ( expect(found).toEqual([ "asset info", "asset participate", "backup", "block", "config", "contact add", "contact remove", + "contract clear-abi", "contract set-origin-energy-limit", "contract set-user-resource-percent", "delete", "encoding convert", "exchange inject", "exchange show", "exchange trade", "exchange withdraw", - "gasfree trace", "import keystore", "rename", "use", + "gasfree trace", "import keystore", + "proposal approve", "proposal delete", "proposal show", + "rename", "use", "witness set-brokerage", ]); }); diff --git a/ts/src/application/services/pipeline/pipeline.test.ts b/ts/src/application/services/pipeline/pipeline.test.ts index 8d79a8efd..4ce028372 100644 --- a/ts/src/application/services/pipeline/pipeline.test.ts +++ b/ts/src/application/services/pipeline/pipeline.test.ts @@ -136,22 +136,41 @@ describe("TxPipeline device-sign timeout", () => { }); describe("TxPipeline build-only", () => { - it("builds from the public address without resolving a signer or estimating", async () => { + // The guarantee that matters: NO signer is resolved. That is what lets --build-only run from a + // watch-only or Ledger account and hand the unsigned hex to co-signers. It does estimate, and + // reports `fee` — documented for every command offering the flag (docs/commands/tx/send.md). + it("builds from the public address without resolving a signer", async () => { const resolve = vi.fn(() => { throw new Error("signer must not be resolved"); }); - const signers = { resolve } as unknown as SignerResolver; + const assertCanSign = vi.fn(() => { throw new Error("signing must not be asserted"); }); + const signers = { resolve, assertCanSign } as unknown as SignerResolver; const build = vi.fn(async (address: string) => ({ raw_data_hex: "0102", owner: address })); - const estimate = vi.fn(async () => ({})); + const estimate = vi.fn(async () => ({ feeSun: "1000" })); + const artifact = vi.fn(() => "0a02010202"); await expect(new TxPipeline(signers).run(params({} as Signer, { ctx: scope({ resolveAddress: () => "TWatchOnly" }), buildOnly: true, build, estimate, - }))).resolves.toEqual({ + artifact, + } as Partial))).resolves.toEqual({ stage: "built", tx: { raw_data_hex: "0102", owner: "TWatchOnly" }, + hex: "0a02010202", + fee: { feeSun: "1000" }, }); expect(resolve).not.toHaveBeenCalled(); - expect(estimate).not.toHaveBeenCalled(); + expect(assertCanSign).not.toHaveBeenCalled(); + }); + + // Producing the unsigned hex IS the point of the mode, so an adapter that cannot serialise one has + // nothing to return — refused up front rather than yielding a hex-less "built" outcome. + it("refuses when the adapter cannot produce transaction hex", async () => { + const signers = { resolve: vi.fn(), assertCanSign: vi.fn() } as unknown as SignerResolver; + await expect(new TxPipeline(signers).run(params({} as Signer, { + ctx: scope({ resolveAddress: () => "TWatchOnly" }), + buildOnly: true, + build: async (address: string) => ({ raw_data_hex: "0102", owner: address }), + }))).rejects.toMatchObject({ code: "invalid_option" }); }); }); diff --git a/ts/src/application/services/transaction-mode.test.ts b/ts/src/application/services/transaction-mode.test.ts index c3bd9cce3..62b9bb037 100644 --- a/ts/src/application/services/transaction-mode.test.ts +++ b/ts/src/application/services/transaction-mode.test.ts @@ -31,7 +31,13 @@ describe("transactionMode", () => { }); it("--build-only → unsigned transaction without broadcast", () => { - expect(transactionMode({ buildOnly: true })).toEqual({ dryRun: false, buildOnly: true, broadcast: false }); + expect(transactionMode({ buildOnly: true })).toEqual({ + mode: "build-only", + dryRun: false, + buildOnly: true, + broadcast: false, + permissionId: 0, + }); }); it("--dry-run + --sign-only → invalid_option", () => { diff --git a/ts/src/application/use-cases/tron/account-service.ts b/ts/src/application/use-cases/tron/account-service.ts index c3e4161d4..2e25922bb 100644 --- a/ts/src/application/use-cases/tron/account-service.ts +++ b/ts/src/application/use-cases/tron/account-service.ts @@ -335,7 +335,9 @@ async function accountCreateFee(gateway: TronGateway) { const parameters = await gateway.getChainParameters(); const find = (key: string): bigint => { const value = parameters.find((entry) => entry.key === key)?.value; - if (!Number.isSafeInteger(value) || value! < 0) { + // `value` is typed `string | number` since the gateway port widened; isSafeInteger already + // rejects every non-number, so Number() here is a cast for the compiler, not a behaviour change. + if (!Number.isSafeInteger(value) || Number(value) < 0) { throw new ChainError( "provider_error", `chain parameter is unavailable: ${key}`, From 0f20a6f72f6cac28a982e58d191dc91bcefa4eac Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 11 Aug 2026 16:43:04 +0800 Subject: [PATCH 07/15] fix(ts): reject --offset without --records on backup The --records log filters are refused on an export path, but --offset slipped through: its .default(0) made "not given" indistinguishable from "given as 0" inside the refine, so `backup main --offset 5` was silently accepted and ignored. Make it optional, add it to RECORD_FILTERS, and skip its gap-fill prompt now that it has no default. The 0 already lives in backupRecords (query.offset ?? 0), so the emitted pagination is unchanged. --- .../cli/commands/wallet.backup.test.ts | 55 +++++++++++++++---- .../adapters/inbound/cli/commands/wallet.ts | 9 ++- 2 files changed, 51 insertions(+), 13 deletions(-) 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 16bf27db0..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,15 +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 }) }, - { append: () => {}, list: () => [] }, - ), - 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 = { @@ -72,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", () => { @@ -102,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.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 1c3e3b0fd..f04d2e0a8 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -448,10 +448,13 @@ export function registerWalletCommands( 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"), - offset: z.coerce.number().int().min(0).default(0) + // 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"] as const + 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 @@ -495,7 +498,7 @@ export function registerWalletCommands( 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" }, + 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"), From e9561fd210d677b3721b33e877e95eaf00f06595 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 12 Aug 2026 21:10:23 +0800 Subject: [PATCH 08/15] fix(ts): stop passing the current chain value off as a proposal's old value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `proposal list` and `proposal show` rendered every parameter as `currentValue → proposedValue`, which reads as "this proposal changes it from X to Y". Only the right-hand side comes from the proposal. The left was fetched live from getChainParameters at render time, so for anything past its voting window it was simply today's value, unrelated to the baseline the proposal was created against — and for an approved proposal it *is* the value that proposal installed, making the arrow read exactly backwards. Measured on Nile: of 21,739 parameter changes in settled proposals, 312 displayed a wrong old value, including 61 approved proposals shown as `X → X`, i.e. a change that took effect rendered as a no-op. The old value is not recoverable in general. `Proposal` carries only proposal_id / proposer_address / parameters / expiration_time / create_time / approvals / state; `parameters` is a map of parameter id to target value and nothing records the prior one. It can be reconstructed by replaying every approved proposal, but only for parameters some approved proposal has already touched — 30% of historical changes bottom out at a genesis default that later proposals have since overwritten, and that default is unverifiable precisely where it is needed. So list/show now report only what the proposal sets, per the v4.12.0 spec §1.1/§1.2, and point at `chain params` for the values in effect now. `proposal create` keeps the arrow (spec §1.3): there the baseline is read while building the transaction, so it is provably the value being changed, and it is what the user is deciding against. Consequently list/show no longer call getChainParameters at all — names and units come from the static definition table — so proposalParameters is a pure function of the proposal. The list/show JSON follows the spec's `parameters[]` of `{id, name, value, unit}` rather than `changes[]`, and gets its own type so the dropped field cannot quietly return; 4.12.0 is unreleased, so no consumer is broken. Text output adopts the spec's right-aligned value column, and an empty list prints `(none)` like every other list renderer instead of a bare header. --- ts/docs/commands/proposal/list.md | 4 +- ts/docs/commands/proposal/show.md | 6 +- .../adapters/inbound/cli/commands/proposal.ts | 15 +++- .../inbound/cli/render/governance.test.ts | 77 +++++++++++++++++++ .../adapters/inbound/cli/render/governance.ts | 59 ++++++++++---- .../use-cases/tron/proposal-service.test.ts | 8 +- .../use-cases/tron/proposal-service.ts | 18 ++--- .../governance/chain-parameters.test.ts | 18 ++--- ts/src/domain/governance/chain-parameters.ts | 19 +++-- 9 files changed, 169 insertions(+), 55 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/render/governance.test.ts diff --git a/ts/docs/commands/proposal/list.md b/ts/docs/commands/proposal/list.md index dd1547eaa..be815763a 100644 --- a/ts/docs/commands/proposal/list.md +++ b/ts/docs/commands/proposal/list.md @@ -12,6 +12,8 @@ wallet-cli proposal list [--state active|all] [--offset ] [--limit ] [opti `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`. +The `Value` column is what the proposal would set, not the value in effect now — a proposal does not record what the parameter was before it. See [`chain params`](../chain/params.md) for current values. + ## Options | Option | Description | @@ -30,7 +32,7 @@ wallet-cli proposal list --state all --offset 20 --limit 20 --network tron:nile ## 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. +`data.approvalThreshold` is 18 for the normal 27-member active SR set. `data.proposals[]` contains `id`, `proposerAddress`, normalized `state`, approval count, expiry, and sorted `parameters[]` — each entry `{ id, name, value, unit }`. `meta.pagination` contains `offset`, `limit`, and the filtered total. Text output prints `(none)` when nothing matches the filter. ## Exit status diff --git a/ts/docs/commands/proposal/show.md b/ts/docs/commands/proposal/show.md index b5d1e8150..4e504097c 100644 --- a/ts/docs/commands/proposal/show.md +++ b/ts/docs/commands/proposal/show.md @@ -1,6 +1,6 @@ # wallet-cli proposal show -Show one proposal, its parameter changes, and approval progress. +Show one proposal, the parameters it sets, and approval progress. ## Synopsis @@ -12,6 +12,8 @@ wallet-cli proposal show [options] 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. +Each parameter's value is the one the proposal would set, not the value in effect now — the chain does not record what the parameter was when the proposal was created. For a settled proposal the current value is unrelated to that baseline, and for an approved one it *is* the value the proposal installed. See [`chain params`](../chain/params.md) for the values in effect now. + ## Arguments | Argument | Description | @@ -26,7 +28,7 @@ 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. +Returns the proposer, create/expiry timestamps, threshold status, approving addresses, and `parameters[]` sorted by id — each entry `{ id, name, value, unit }`. ## Exit status diff --git a/ts/src/adapters/inbound/cli/commands/proposal.ts b/ts/src/adapters/inbound/cli/commands/proposal.ts index eec432218..0a1313aac 100644 --- a/ts/src/adapters/inbound/cli/commands/proposal.ts +++ b/ts/src/adapters/inbound/cli/commands/proposal.ts @@ -10,7 +10,11 @@ export const proposalListSpec: ChainSpec = { 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.", + description: + "List governance proposals. Each proposal is a set of chain parameters with the values\n" + + "it would set, for super representatives to vote on. Parameters are shown by name; the\n" + + "value column is what the proposal sets, not the current value on chain — see\n" + + "'chain params' for those. Active proposals are shown by default.", baseFields: z.object({ state: ciEnum(["active", "all"]).default("active") .describe("active voting proposals, or all proposal history"), @@ -34,7 +38,14 @@ export const proposalShowSpec: ChainSpec = { capability: "proposal.read", positionals: [{ field: "id" }], summary: "Show one governance proposal", - description: "Show parameter changes, approval progress, proposer, and voting-window timestamps.", + description: + "Show a single proposal in full: each parameter it sets (name, value, unit), approval\n" + + "progress, proposer, and voting-window timestamps. The addresses that approved are in\n" + + "the json output only.\n" + + "\n" + + "The value shown is the one the proposal sets, not the current value on chain — a\n" + + "proposal does not record what the parameter was. Use 'chain params' for the values in\n" + + "effect now.", baseFields: z.object({ id: z.coerce.number().int().positive().describe("proposal id"), }), diff --git a/ts/src/adapters/inbound/cli/render/governance.test.ts b/ts/src/adapters/inbound/cli/render/governance.test.ts new file mode 100644 index 000000000..485d245ee --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/governance.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { GovernanceFormatters } from "./governance.js"; + +const ctx = { accountLabel: "main" } as never; + +const show = { + id: 44, + proposerAddress: "TSRee5xhbTccpvyDNyRRVAt5MJDLnYzcvS", + state: "approved", + createTime: 1_784_534_400_000, + expirationTime: 1_784_620_800_000, + approvals: 18, + approvalThreshold: 18, + parameters: [ + { id: 0, name: "getMaintenanceTimeInterval", value: 10_800_000, unit: "ms" }, + { id: 13, name: "getMaxCpuTimeOfOneTx", value: 80, unit: "ms" }, + ], +}; + +const list = { + approvalThreshold: 18, + pagination: { offset: 0, limit: null, total: 2 }, + proposals: [ + { id: 47, state: "voting", approvals: 12, expirationTime: 1_784_707_200_000, parameters: [{ id: 3, name: "getTransactionFee", value: 15, unit: "sun/byte" }] }, + { id: 44, state: "disapproved", approvals: 8, expirationTime: 1_784_620_800_000, parameters: show.parameters }, + ], +}; + +describe("proposal text rendering", () => { + it("never shows a current value — the proposal only records what it would set", () => { + for (const rendered of [GovernanceFormatters.proposalShow(show), GovernanceFormatters.proposalList(list)]) { + expect(rendered).not.toContain("→"); + expect(rendered).not.toContain("unknown"); + } + expect(JSON.stringify([show, list])).not.toContain("currentValue"); + }); + + it("right-aligns values and keeps multi-parameter proposals on continuation rows", () => { + expect(GovernanceFormatters.proposalShow(show)).toContain([ + " Parameters (2)", + " getMaintenanceTimeInterval 10800000 ms", + " getMaxCpuTimeOfOneTx 80 ms", + ].join("\n")); + + const rows = GovernanceFormatters.proposalList(list).split("\n"); + expect(rows[1]).toContain("Parameter"); + expect(rows[1]?.trimEnd().endsWith("Value")).toBe(true); + // second parameter of #44 continues below it with the left-hand columns blank + expect(rows[4]?.trimStart().startsWith("getMaxCpuTimeOfOneTx")).toBe(true); + expect(rows[4]?.trimEnd().endsWith("80")).toBe(true); + }); + + it("marks an empty list (none) rather than leaving a bare header", () => { + const empty = GovernanceFormatters.proposalList({ approvalThreshold: 18, proposals: [], pagination: { offset: 0, limit: null, total: 0 } }); + expect(empty).toBe("Proposals (0)\n (none)"); + }); + + it("keeps the before/after arrow on a create receipt, where the baseline is what was just read", () => { + const receipt = GovernanceFormatters.governanceReceipt({ + kind: "proposal-create", + stage: "confirmed", + txId: "9c4", + blockNumber: 57_880_102, + proposalId: 48, + proposerAddress: "TSRee5xhbTccpvyDNyRRVAt5MJDLnYzcvS", + changes: [ + { id: 2, name: "getCreateAccountFee", currentValue: 100_000, proposedValue: 200_000, unit: "sun" }, + { id: 3, name: "getTransactionFee", currentValue: 10, proposedValue: 15, unit: "sun/byte" }, + ], + }, ctx); + expect(receipt).toContain([ + " Parameter changes (2)", + " getCreateAccountFee 100000 → 200000 sun", + " getTransactionFee 10 → 15 sun/byte", + ].join("\n")); + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/governance.ts b/ts/src/adapters/inbound/cli/render/governance.ts index 10e8513f2..14d1408d7 100644 --- a/ts/src/adapters/inbound/cli/render/governance.ts +++ b/ts/src/adapters/inbound/cli/render/governance.ts @@ -27,46 +27,47 @@ function renderProposalList(data: Obj): string { const title = paged ? `Proposals (showing ${proposals.length} of ${total})` : `Proposals (${proposals.length})`; - const headers = ["ID", "State", "Approvals", "Expiry (UTC)", "Parameter change"]; + if (proposals.length === 0) return `${title}\n (none)`; + const headers = ["ID", "State", "Approvals", "Expiry (UTC)", "Parameter", "Value"]; + const align: Align[] = ["left", "left", "right", "left", "left", "right"]; const rows: string[][] = []; for (const proposal of proposals) { - const changes = Array.isArray(proposal.changes) ? proposal.changes.map(asObj) : []; + const parameters = Array.isArray(proposal.parameters) ? proposal.parameters.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()) { + if (parameters.length === 0) rows.push([...base, "", ""]); + for (const [index, parameter] of parameters.entries()) { rows.push([ ...(index === 0 ? base : ["", "", "", ""]), - `${String(change.name ?? "")}: ${changeValue(change.currentValue)} → ${changeValue(change.proposedValue)}`, + String(parameter.name ?? ""), + changeValue(parameter.value), ]); } } - 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"); + return [title, ...table(headers, rows, align)].join("\n"); } function renderProposalShow(data: Obj): string { - const changes = Array.isArray(data.changes) ? data.changes.map(asObj) : []; + const parameters = Array.isArray(data.parameters) ? data.parameters.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})`], + ["Parameters", `(${parameters.length})`], ]); + const nameWidth = width(parameters.map((parameter) => String(parameter.name ?? ""))); + const valueWidth = width(parameters.map((parameter) => changeValue(parameter.value))); return [ body, - ...changes.map((change) => - ` ${String(change.name ?? "")} ${changeValue(change.currentValue)} → ${changeValue(change.proposedValue)}${change.unit ? ` ${String(change.unit)}` : ""}`, + ...parameters.map((parameter) => + ` ${String(parameter.name ?? "").padEnd(nameWidth)} ${changeValue(parameter.value).padStart(valueWidth)}` + + (parameter.unit ? ` ${String(parameter.unit)}` : ""), ), ].join("\n"); } @@ -145,11 +146,16 @@ function governanceRows(data: Obj, ctx: TextRenderContext): Array<[string, strin function appendChanges(rendered: string, data: Obj): string { const changes = Array.isArray(data.changes) ? data.changes.map(asObj) : []; if (changes.length === 0) return rendered; + const nameWidth = width(changes.map((change) => String(change.name ?? ""))); + const fromWidth = width(changes.map((change) => changeValue(change.currentValue))); + const toWidth = width(changes.map((change) => changeValue(change.proposedValue))); return [ rendered, ` Parameter changes (${changes.length})`, ...changes.map((change) => - ` ${String(change.name ?? "")} ${changeValue(change.currentValue)} → ${changeValue(change.proposedValue)}${change.unit ? ` ${String(change.unit)}` : ""}`, + ` ${String(change.name ?? "").padEnd(nameWidth)} ${changeValue(change.currentValue).padStart(fromWidth)}` + + ` → ${changeValue(change.proposedValue).padStart(toWidth)}` + + (change.unit ? ` ${String(change.unit)}` : ""), ), ].join("\n"); } @@ -205,6 +211,27 @@ function changeValue(value: unknown): string { return value === null || value === undefined ? "unknown" : String(value); } +type Align = "left" | "right"; + +function width(values: string[]): number { + return Math.max(0, ...values.map((value) => value.length)); +} + +/** Column-aligned rows; numeric columns are right-aligned so digits line up down the page. */ +function table(headers: string[], rows: string[][], align: Align[]): string[] { + 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) => align[index] === "right" + ? String(cell).padStart(widths[index] ?? 0) + : String(cell).padEnd(widths[index] ?? 0)) + .join(" ") + .trimEnd()}`; + return [line(headers), ...rows.map(line)]; +} + function utcMinute(value: unknown): string { const epoch = Number(value); return Number.isFinite(epoch) && epoch > 0 diff --git a/ts/src/application/use-cases/tron/proposal-service.test.ts b/ts/src/application/use-cases/tron/proposal-service.test.ts index 428739baf..51f8ed343 100644 --- a/ts/src/application/use-cases/tron/proposal-service.test.ts +++ b/ts/src/application/use-cases/tron/proposal-service.test.ts @@ -41,18 +41,14 @@ describe("TronProposalService", () => { { id: 3, proposerAddress: OWNER, parameters: { "3": "15", "2": "200000" }, expirationTime: now + 60_000, createTime: now, approvals: [OTHER], state: "PENDING" }, { id: 2, proposerAddress: OTHER, parameters: { "20": "1" }, expirationTime: now + 60_000, createTime: now, approvals: [], state: "PENDING" }, ] as TronProposal[]), - getChainParameters: async () => [ - { key: "getCreateAccountFee", value: 100_000 }, - { key: "getTransactionFee", value: 10 }, - { key: "getAllowMultiSign", value: 0 }, - ], + // no getChainParameters stub: listing must not reach for the values in effect now. getWitnesses: async () => Array.from({ length: 27 }, (_, index) => ({ address: `${OWNER}${index}`, voteCount: "0" })), }); await expect(service.list(NET, { state: "active", offset: 1, limit: 1 })).resolves.toMatchObject({ approvalThreshold: 18, pagination: { offset: 1, limit: 1, total: 2 }, - proposals: [{ id: 2, state: "voting", changes: [{ id: 20, name: "getAllowMultiSign" }] }], + proposals: [{ id: 2, state: "voting", parameters: [{ id: 20, name: "getAllowMultiSign", value: 1 }] }], }); }); diff --git a/ts/src/application/use-cases/tron/proposal-service.ts b/ts/src/application/use-cases/tron/proposal-service.ts index 296705a1c..494657160 100644 --- a/ts/src/application/use-cases/tron/proposal-service.ts +++ b/ts/src/application/use-cases/tron/proposal-service.ts @@ -2,7 +2,7 @@ import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index. import { ChainError } from "../../../domain/errors/index.js"; import { parseChainParameterAssignments, - proposalParameterChanges, + proposalParameters, type ChainParameterChange, } from "../../../domain/governance/chain-parameters.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; @@ -38,8 +38,6 @@ export interface ProposalDeleteInput extends GovernanceTransactionInput { id: number; } -type ChainParameters = Awaited>; - export class TronProposalService { constructor( private readonly gateways: ChainGatewayProvider, @@ -48,16 +46,15 @@ export class TronProposalService { async list(network: NetworkDescriptor, input: ProposalListInput) { const gateway = this.gateways.get(network, "tron"); - const [proposals, parameters, witnesses] = await Promise.all([ + const [proposals, witnesses] = await Promise.all([ gateway.getProposals(), - gateway.getChainParameters(), gateway.getWitnesses(27), ]); const approvalThreshold = threshold(witnesses.length); const views = proposals .filter((proposal) => input.state === "all" || isActive(proposal)) .sort((left, right) => right.id - left.id) - .map((proposal) => listView(proposal, parameters)); + .map((proposal) => listView(proposal)); const total = views.length; const proposalsPage = views.slice(input.offset, input.limit === undefined ? undefined : input.offset + input.limit); return { @@ -69,15 +66,14 @@ export class TronProposalService { async show(network: NetworkDescriptor, id: number) { const gateway = this.gateways.get(network, "tron"); - const [proposal, parameters, witnesses] = await Promise.all([ + const [proposal, witnesses] = await Promise.all([ gateway.getProposal(id), - gateway.getChainParameters(), gateway.getWitnesses(27), ]); if (!proposal) throw new ChainError("proposal_not_found", `proposal #${id} was not found`); const approvalThreshold = threshold(witnesses.length); return { - ...listView(proposal, parameters), + ...listView(proposal), createTime: proposal.createTime, approvalThreshold, reachedThreshold: proposal.approvals.length >= approvalThreshold, @@ -249,14 +245,14 @@ function stateName(state: TronProposal["state"]): "voting" | "approved" | "disap } as const)[state]; } -function listView(proposal: TronProposal, parameters: ChainParameters) { +function listView(proposal: TronProposal) { return { id: proposal.id, proposerAddress: proposal.proposerAddress, state: stateName(proposal.state), approvals: proposal.approvals.length, expirationTime: proposal.expirationTime, - changes: proposalParameterChanges(proposal.parameters, parameters), + parameters: proposalParameters(proposal.parameters), }; } diff --git a/ts/src/domain/governance/chain-parameters.test.ts b/ts/src/domain/governance/chain-parameters.test.ts index 7a0eb9b69..73f094ef5 100644 --- a/ts/src/domain/governance/chain-parameters.test.ts +++ b/ts/src/domain/governance/chain-parameters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseChainParameterAssignments, proposalParameterChanges } from "./chain-parameters.js"; +import { parseChainParameterAssignments, proposalParameters } from "./chain-parameters.js"; const current = [ { key: "getCreateAccountFee", value: 100_000 }, @@ -27,14 +27,14 @@ describe("chain parameter proposal mapping", () => { }); 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: "", - }, + expect(proposalParameters({ "999": "9223372036854775807" })).toEqual([ + { id: 999, name: "parameter-999", value: "9223372036854775807", unit: "" }, + ]); + }); + + it("never carries a current value — a proposal records only what it would set", () => { + expect(proposalParameters({ "3": "15" })).toEqual([ + { id: 3, name: "getTransactionFee", value: 15, unit: "sun/byte" }, ]); }); diff --git a/ts/src/domain/governance/chain-parameters.ts b/ts/src/domain/governance/chain-parameters.ts index 6cc1f9200..dfef89e4b 100644 --- a/ts/src/domain/governance/chain-parameters.ts +++ b/ts/src/domain/governance/chain-parameters.ts @@ -17,6 +17,13 @@ export interface ChainParameterChange { unit: string; } +export interface ChainParameterValue { + id: number; + name: string; + value: number | string; + unit: string; +} + const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); const INT64_MAX = (1n << 63n) - 1n; const BOOL = [0n, 1n] as const; @@ -201,24 +208,20 @@ export function parseChainParameterAssignments( return [...selected.values()].sort((left, right) => left.id - right.id); } -export function proposalParameterChanges( +export function proposalParameters( parameters: Readonly>, - current: ReadonlyArray<{ key: string; value?: number | string }>, -): ChainParameterChange[] { - const currentByName = new Map(current.map((entry) => [entry.key.toLowerCase(), entry.value])); +): ChainParameterValue[] { 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, + name: definition?.name ?? `parameter-${id}`, + value, unit: definition?.unit ?? "", }; }) From de0bd6f94f5fc024b563bdb9e9bbd60b893a89c9 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 13 Aug 2026 10:35:44 +0800 Subject: [PATCH 09/15] feat(ts) qa report review and fix --- .../java/org/tron/common/utils/Utils.java | 2 +- ts/package-lock.json | 4 +- ts/package.json | 2 +- .../adapters/inbound/cli/commands/account.ts | 3 +- ts/src/adapters/inbound/cli/commands/asset.ts | 3 +- .../cli/commands/contract.deploy.test.ts | 172 ++++++++++++++++++ .../adapters/inbound/cli/commands/contract.ts | 74 +++++++- .../adapters/inbound/cli/commands/wallet.ts | 9 +- ts/src/adapters/inbound/cli/output/index.ts | 8 +- .../inbound/cli/render/error-details.test.ts | 42 +++++ .../inbound/cli/render/error-details.ts | 54 ++++++ ts/src/adapters/inbound/cli/render/index.ts | 1 + .../use-cases/tron/asset-service.test.ts | 29 +++ .../use-cases/tron/asset-service.ts | 15 +- .../tron/contract-service.governance.test.ts | 152 ++++++++-------- .../use-cases/tron/exchange-service.test.ts | 11 ++ .../use-cases/tron/exchange-service.ts | 4 +- ts/src/bootstrap/runner.ts | 8 +- ts/src/domain/amounts/amounts.test.ts | 20 ++ ts/src/domain/amounts/index.ts | 11 +- ts/test/contract-deploy.test.ts | 12 +- ts/test/golden.test.ts | 35 ++-- 22 files changed, 556 insertions(+), 115 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts create mode 100644 ts/src/adapters/inbound/cli/render/error-details.test.ts create mode 100644 ts/src/adapters/inbound/cli/render/error-details.ts diff --git a/java/src/main/java/org/tron/common/utils/Utils.java b/java/src/main/java/org/tron/common/utils/Utils.java index 812959868..b5bd8cbd2 100644 --- a/java/src/main/java/org/tron/common/utils/Utils.java +++ b/java/src/main/java/org/tron/common/utils/Utils.java @@ -138,7 +138,7 @@ public class Utils { public static final int MIN_LENGTH = 2; public static final int MAX_LENGTH = 14; - public static final String VERSION = " v4.11.0"; + public static final String VERSION = " v4.12.0"; public static final String TRANSFER_METHOD_ID = "a9059cbb"; private static SecureRandom random = new SecureRandom(); diff --git a/ts/package-lock.json b/ts/package-lock.json index 16a9c2092..baec512e1 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "4.11.0", + "version": "4.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tron-walletcli/wallet-cli", - "version": "4.11.0", + "version": "4.12.0", "license": "LGPL-3.0-or-later", "dependencies": { "@ledgerhq/hw-app-trx": "^6.36.3", diff --git a/ts/package.json b/ts/package.json index d92364000..9d85f7e9e 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "4.11.0", + "version": "4.12.0", "description": "Agent-first TypeScript CLI wallet for TRON — deterministic commands, JSON output, and discoverable schemas", "type": "module", "bin": { diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index 69fb3374a..eb0319419 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronAccountService } from "../../../../application/use-cases/tron/account-service.js"; import { ciEnum } from "../arity/index.js"; +import { Schemas } from "../schemas/index.js"; import { TextFormatters } from "../render/index.js"; import { txModeFields } from "./shared.js"; @@ -43,7 +44,7 @@ export const accountActivateSpec: ChainSpec = { + "active; use --dry-run to inspect current creation fees. Note: a plain transfer also activates\n" + "the recipient, so use this command only when the address just needs to exist.", baseFields: z.object({ - address: z.string().min(1).describe("unactivated TRON base58 address"), + address: Schemas.addressFor("tron").describe("unactivated TRON base58 address"), ...txModeFields, }), baseRefine: transactionModeRefine, diff --git a/ts/src/adapters/inbound/cli/commands/asset.ts b/ts/src/adapters/inbound/cli/commands/asset.ts index acc729a9f..21f300443 100644 --- a/ts/src/adapters/inbound/cli/commands/asset.ts +++ b/ts/src/adapters/inbound/cli/commands/asset.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronAssetService } from "../../../../application/use-cases/tron/asset-service.js"; import { txModeFields } from "./shared.js"; +import { Schemas } from "../schemas/index.js"; import { TextFormatters } from "../render/index.js"; const LEDGER_NOTE = @@ -145,7 +146,7 @@ export const assetInfoSpec: ChainSpec = { 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"), + issuer: Schemas.addressFor("tron").optional().describe("look up the token issued by this address"), }), examples: [ { cmd: "wallet-cli asset info 1000123" }, diff --git a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts new file mode 100644 index 000000000..166065e15 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; +import { contractDeployTronBinding } from "./contract.js"; +import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; + +/** + * `contract deploy` input guards. + * + * The contract under test is ALIGNMENT: every input TronWeb's createSmartContract encoder accepts + * must still reach it, and every input it refuses must still be refused. The guards only change how + * two of those refusals are worded — one of which TronWeb does not word at all, but crashes on. + * + * Each expectation below was first measured against the real encoder + * (tronweb/lib/commonjs/lib/TransactionBuilder/TransactionBuilder.js:541, + * `'payable' === func.stateMutability.toLowerCase()`), and the measured behaviour is named in the + * test so a TronWeb upgrade that moves the boundary shows up as a failure here. + */ + +function deployWith(input: { abi: string; params?: string }) { + const deploy = vi.fn(async (_ctx: unknown, _net: unknown, _input: { parameters: unknown[] }) => + ({ kind: "tx-receipt" as const })); + const binding = contractDeployTronBinding({ deploy } as unknown as TronContractService); + const run = () => binding.run( + {} as never, + {} as never, + { bytecode: "6080", feeLimit: "1000000", ...input } as never, + ); + return { run, deploy }; +} + +const ctor = (over: Record = {}) => + JSON.stringify([{ type: "constructor", inputs: [{ name: "x", type: "uint256" }], ...over }]); + +describe("contract deploy — ABI constructor guard", () => { + // Measured: TronWeb only reads stateMutability on constructor entries (its && short-circuits), + // so an ABI without one encodes fine no matter what else it carries. + it("passes an ABI with no constructor straight through", async () => { + const { run, deploy } = deployWith({ abi: JSON.stringify([{ type: "function", name: "f" }]) }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + it("passes a constructor carrying stateMutability", async () => { + const { run, deploy } = deployWith({ abi: ctor({ stateMutability: "nonpayable" }) }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + it("passes a payable constructor", async () => { + const { run, deploy } = deployWith({ abi: ctor({ stateMutability: "payable" }) }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + // Measured: TronWeb SUCCEEDS on "" — ''.toLowerCase() is legal, the constructor is just not + // payable. Rejecting it would make this CLI stricter than the encoder it fronts. + it("passes an empty-string stateMutability, which TronWeb accepts", async () => { + const { run, deploy } = deployWith({ abi: ctor({ stateMutability: "" }) }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + // Measured: each of these crashes the encoder — "Cannot read properties of undefined/null + // (reading 'toLowerCase')" or "func.stateMutability.toLowerCase is not a function" — surfacing as + // rpc_error/exit 1 with no request ever sent. + it.each([ + ["absent", {}], + ["null", { stateMutability: null }], + ["a number", { stateMutability: 1 }], + ["a boolean", { stateMutability: false }], + ["an object", { stateMutability: {} }], + ])("rejects a constructor whose stateMutability is %s", async (_label, over) => { + const { run, deploy } = deployWith({ abi: ctor(over) }); + await expect(run()).rejects.toMatchObject({ + code: "invalid_value", + message: expect.stringContaining("stateMutability"), + }); + expect(deploy).not.toHaveBeenCalled(); + }); + + // The crash does not depend on the constructor taking arguments — TronWeb reads the key before + // it ever looks at `inputs`. + it.each([ + ["an empty inputs list", JSON.stringify([{ type: "constructor", inputs: [] }])], + ["no inputs key at all", JSON.stringify([{ type: "constructor" }])], + ])("rejects a stateMutability-less constructor with %s", async (_label, abi) => { + const { run } = deployWith({ abi }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value" }); + }); + + // Measured: TronWeb reads `abi.entrys` when present, so the guard has to see through that + // wrapper or it would wave through an ABI that still crashes. + it("looks inside the { entrys } wrapper TronWeb also accepts", async () => { + const { run } = deployWith({ abi: JSON.stringify({ entrys: [{ type: "constructor" }] }) }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value" }); + }); + + it("passes an { entrys } wrapper whose constructor is well-formed", async () => { + const abi = JSON.stringify({ entrys: [{ type: "constructor", stateMutability: "nonpayable" }] }); + const { run, deploy } = deployWith({ abi }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + // Measured: TronWeb answers this one clearly by itself ("Invalid options.abi provided"), so + // adding our own rejection would only move the goalposts. + it("leaves an ABI that is neither array nor { entrys } to TronWeb", async () => { + const { run, deploy } = deployWith({ abi: JSON.stringify({ foo: 1 }) }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + it("still rejects an ABI that is not JSON at all", async () => { + const { run } = deployWith({ abi: "{not json" }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value", message: /valid JSON/ }); + }); +}); + +describe("contract deploy — --params form guard", () => { + const ABI = ctor({ stateMutability: "nonpayable" }); + + it("passes raw positional values, the documented deploy form", async () => { + const { run, deploy } = deployWith({ abi: ABI, params: '[100, "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"]' }); + await expect(run()).resolves.toBeDefined(); + expect(deploy.mock.calls[0]![2]).toMatchObject({ + parameters: [100, "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"], + }); + }); + + it("defaults to no constructor args when --params is omitted", async () => { + const { run, deploy } = deployWith({ abi: ABI }); + await expect(run()).resolves.toBeDefined(); + expect(deploy.mock.calls[0]![2]).toMatchObject({ parameters: [] }); + }); + + // Measured: TronWeb rejects this too, as ethers' `invalid BigNumberish value (argument="value")` + // — an internal argument name that collides with the user's own key. Same refusal, named. + it("rejects the {type,value} form that contract call/send take", async () => { + const params = '[{"type":"uint256","value":"100"}]'; + const { run, deploy } = deployWith({ abi: ABI, params }); + await expect(run()).rejects.toMatchObject({ + code: "invalid_value", + message: expect.stringContaining("raw positional values"), + }); + expect(deploy).not.toHaveBeenCalled(); + }); + + it("rejects a multi-entry {type,value} array", async () => { + const params = '[{"type":"uint256","value":"1"},{"type":"address","value":"T..."}]'; + const { run } = deployWith({ abi: ABI, params }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value" }); + }); + + // Only the unambiguous all-typed array is claimed. Anything else could be a legitimate struct or + // a half-edited command line, and TronWeb's arity/type errors read fine on their own + // ("constructor needs 1 but 2 provided"). + it.each([ + ["a mixed array", '[100, {"type":"uint256","value":"1"}]'], + ["objects carrying a third key", '[{"type":"uint256","value":"1","name":"cap"}]'], + ["objects whose type is not a string", '[{"type":1,"value":"1"}]'], + ["objects whose type is empty", '[{"type":"","value":"1"}]'], + ["an empty array", "[]"], + ])("leaves %s to TronWeb", async (_label, params) => { + const { run, deploy } = deployWith({ abi: ABI, params }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + it("still rejects --params that is not a JSON array", async () => { + const { run } = deployWith({ abi: ABI, params: '{"type":"uint256"}' }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value", message: /JSON array/ }); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 9adf6f833..41de46129 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -37,6 +37,77 @@ function typedParams(raw: string | undefined): TronContractParameter[] { return arr as TronContractParameter[]; } +// ── deploy input guards ──────────────────────────────────────────────────────── +// Both guards below only restate a rejection TronWeb already makes — the accepted input set is +// unchanged. They exist because TronWeb states these two in terms of its own internals, and one +// of them not as a rejection at all but as a crash. + +/** the ABI's entry list, in either shape TronWeb reads (`abi` itself, or `abi.entrys`). */ +function abiEntries(abi: unknown): unknown[] | undefined { + if (Array.isArray(abi)) return abi; + const wrapped = (abi as { entrys?: unknown } | null)?.entrys; + return Array.isArray(wrapped) ? wrapped : undefined; +} + +/** + * TronWeb decides whether a constructor may take call value with an unguarded read — + * `'payable' === func.stateMutability.toLowerCase()` (TransactionBuilder.js) — so a constructor + * entry whose `stateMutability` is not a string dies inside the encoder as "Cannot read properties + * of undefined (reading 'toLowerCase')", naming neither the ABI nor the missing key, and arriving + * as rpc_error/exit 1 despite no request having been sent. + * + * Rejected here iff that read would throw — i.e. the value is not a string. An empty string is + * left alone on purpose: TronWeb accepts it (`''.toLowerCase()` is fine, the constructor is simply + * not payable), and rejecting it would make this CLI stricter than the encoder it fronts. + * A non-array, non-`entrys` ABI is likewise left alone — TronWeb's own "Invalid options.abi + * provided" already says that plainly. + */ +function assertConstructorEncodable(abi: unknown): void { + for (const entry of abiEntries(abi) ?? []) { + const e = entry as { type?: unknown; stateMutability?: unknown } | null; + if (e?.type !== "constructor") continue; // TronWeb's && short-circuits the same way + if (typeof e.stateMutability !== "string") { + throw new UsageError( + "invalid_value", + '--abi constructor entry needs a string "stateMutability" ("nonpayable" or "payable"); ' + + "solc emits it — add it by hand if the ABI was trimmed or came from solc < 0.5", + ); + } + } +} + +/** + * Constructor args are RAW positional values here (`[100, "T..."]`) — types come from the ABI — + * whereas `contract call` / `send` take `{type,value}` entries. TronWeb rejects the wrong one too, + * but as ethers' `invalid BigNumberish value (argument="value", ...)`: an internal argument name + * that collides with the user's own `value` key and reads like a bad number rather than a wrong + * format. The two-format split is this CLI's own design, so name it in our own words. + * + * Only the unambiguous case is claimed — every entry an object with exactly `type` (a non-empty + * string) and `value`. A mixed or partial array is left to TronWeb rather than guessed at, and a + * genuine struct arg with those two field names can still be passed in positional array form. + */ +function deployParameters(raw: string | undefined): unknown[] { + const values = jsonArray(raw); + const allTyped = values.length > 0 && values.every((v) => { + if (!v || typeof v !== "object" || Array.isArray(v)) return false; + const keys = Object.keys(v); + return keys.length === 2 + && keys.includes("type") + && keys.includes("value") + && typeof (v as { type: unknown }).type === "string" + && (v as { type: string }).type !== ""; + }); + if (allTyped) { + throw new UsageError( + "invalid_value", + '--params takes raw positional values for deploy (e.g. [100, "T..."]); {"type","value"} ' + + "entries are the `contract call`/`send` form — deploy reads the types from the ABI constructor", + ); + } + return values; +} + const callFields = z.object({ contract: Schemas.addressFor("tron").describe("TRON contract address"), method: z.string().min(1).describe("function signature, e.g. balanceOf(address)"), @@ -129,10 +200,11 @@ export const contractDeployTronBinding = (svc: TronContractService): FamilyBindi } catch { throw new UsageError("invalid_value", "--abi must be valid JSON"); } + assertConstructorEncodable(abi); return svc.deploy(ctx, net, { ...input, abi, - parameters: jsonArray(input.params), + parameters: deployParameters(input.params), }); }, }); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index f04d2e0a8..9675314b2 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -183,9 +183,12 @@ export function registerWalletCommands( // ── 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`). + // master password (to re-encrypt it locally). The file is read and structurally validated before + // EITHER password prompt, so a typo'd path costs no password typing — hence no `passwordMode` + // (which would prime the master password up in the shell, ahead of `run`); priming happens inside + // `run`, after the file (same reasoning as `backup`). Do not "restore" passwordMode here. + // The shell's secretsTtyOnly gate still runs first and reports tty_required without a terminal — + // correct, and uniform with the other TTY-only commands: there is no prompt to save there. 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"), diff --git a/ts/src/adapters/inbound/cli/output/index.ts b/ts/src/adapters/inbound/cli/output/index.ts index af32fece2..50e2c83bf 100644 --- a/ts/src/adapters/inbound/cli/output/index.ts +++ b/ts/src/adapters/inbound/cli/output/index.ts @@ -14,7 +14,7 @@ import type { ProgressEvent } from "../../../../application/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"; +import { renderErrorDetails, renderGenericText } from "../render/index.js"; import { sanitizeText } from "../render/scalars.js"; export interface OutputFormatter { @@ -101,7 +101,11 @@ class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatte } error(err: CliError): void { - this.streams.errorLine(sanitizeText(`error [${err.code}]: ${err.message}`)); + // Errors that are a choice rather than a dead end append their candidate table (see + // renderErrorDetails); everything else stays the single line it has always been. + const line = `error [${err.code}]: ${err.message}`; + const candidates = renderErrorDetails(err.details); + this.streams.errorLine(sanitizeText(candidates ? `${line}\n${candidates}` : line)); } event(e: ProgressEvent): string { diff --git a/ts/src/adapters/inbound/cli/render/error-details.test.ts b/ts/src/adapters/inbound/cli/render/error-details.test.ts new file mode 100644 index 000000000..762a4eeb7 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/error-details.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { renderErrorDetails } from "./error-details.js"; + +describe("renderErrorDetails", () => { + const matches = [ + { assetId: "1000123", issuerAddress: "TQkXm4vN", totalSupply: "1000000000000000", precision: 6 }, + { assetId: "1000488", issuerAddress: "TZx9kP2m", totalSupply: "5000000000", precision: 2 }, + ]; + + it("tables the candidates so a human can pick one", () => { + const out = renderErrorDetails({ name: "MyToken", assetIds: ["1000123", "1000488"], matches }); + expect(out).toContain("ID"); + expect(out).toContain("Total supply"); + expect(out).toContain("1000123"); + expect(out).toContain("1000488"); + }); + + // The row reports raw minimal units; text shows the same whole-token figure `asset info` does, + // so the two views of one token never disagree. + it("scales each row's supply by its own precision", () => { + const out = renderErrorDetails({ matches })!; + expect(out).toContain("1,000,000,000"); // 1e15 at precision 6 + expect(out).toContain("50,000,000"); // 5e9 at precision 2 + expect(out).not.toContain("1000000000000000"); + }); + + it("adds nothing to errors that are a dead end rather than a choice", () => { + expect(renderErrorDetails(undefined)).toBeNull(); + expect(renderErrorDetails({})).toBeNull(); + expect(renderErrorDetails({ assetIds: ["1000123"] })).toBeNull(); + expect(renderErrorDetails({ matches: [] })).toBeNull(); + expect(renderErrorDetails({ matches: [{}] })).toBeNull(); + }); + + // The contract is the `matches` key, not the asset shape — a future error opts in for free. + it("derives its columns from whatever keys the rows carry", () => { + const out = renderErrorDetails({ matches: [{ label: "cold", path: "m/44'/195'/1'/0/0" }] })!; + expect(out).toContain("label"); + expect(out).toContain("path"); + expect(out).toContain("cold"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/error-details.ts b/ts/src/adapters/inbound/cli/render/error-details.ts new file mode 100644 index 000000000..5fc605dfd --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/error-details.ts @@ -0,0 +1,54 @@ +/** + * Error-detail rendering — the text-mode counterpart to `error.details`. + * + * Text mode prints one line per error (`error [code]: message`). A few errors are a *choice* + * rather than a dead end — the caller has to pick one of several candidates — and a bare line + * leaves a human with no way to pick. Those errors put the candidates in `details.matches`, and + * this renders them as the same table the corresponding list command uses. + * + * Deliberately data-driven, not a per-error-code registry: the contract is `details.matches` (an + * array of flat rows sharing one key set), so any future error can opt in by carrying that key, + * without the output layer learning command names. JSON mode is untouched — it already carries + * `details` verbatim. + */ +import { fromBaseUnits } from "../../../../domain/amounts/index.js"; +import type { Obj } from "./layout.js"; +import { asObj, table } from "./layout.js"; +import { formatDecimal, formatInt, formatScalar, num } from "./scalars.js"; + +/** key → column header; keys not listed fall back to their own name. */ +const HEADERS: Record = { + assetId: "ID", + issuerAddress: "Issuer", + totalSupply: "Total supply", + precision: "Precision", +}; + +/** + * Rows report quantities raw (minimal units) so `details` stays machine-honest, but a human + * picking between tokens needs the same whole-token figure `asset info` / `asset list` show — + * hence the one semantic rule here: a row carrying `precision` scales its `totalSupply` by it. + */ +function cell(key: string, row: Obj): string { + const value = row[key]; + if (key === "totalSupply" && row.precision !== undefined) { + return value === undefined || value === null + ? "" + : formatDecimal(fromBaseUnits(String(value), num(row.precision, 0))); + } + if (key === "precision") return formatInt(value ?? 0); + return formatScalar(value); +} + +/** the candidate table for an error that carries one, or null when there is nothing to add. */ +export function renderErrorDetails(details: unknown): string | null { + const rows = asObj(details).matches; + if (!Array.isArray(rows) || rows.length === 0) return null; + const objects = rows.map(asObj); + const keys = Object.keys(objects[0] ?? {}); + if (keys.length === 0) return null; + return table( + keys.map((k) => HEADERS[k] ?? k), + objects.map((row) => keys.map((k) => cell(k, row))), + ); +} diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index 0d76b5d14..44a68b304 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -32,6 +32,7 @@ import { ContactFormatters } from "./contact.js" import { EncodingFormatters } from "./encoding.js" export { FAMILY_RENDER, renderFamily } from "./family.js" +export { renderErrorDetails } from "./error-details.js" export const TextFormatters = { ...WalletFormatters, diff --git a/ts/src/application/use-cases/tron/asset-service.test.ts b/ts/src/application/use-cases/tron/asset-service.test.ts index 5b9d6ecf4..b9f2cf5a1 100644 --- a/ts/src/application/use-cases/tron/asset-service.test.ts +++ b/ts/src/application/use-cases/tron/asset-service.test.ts @@ -202,6 +202,35 @@ describe("asset participate", () => { }); }); +describe("ambiguous asset names", () => { + // A name collision is a choice, not a dead end: the error has to carry enough for the caller to + // pick — ids for a machine, and the columns a human compares on (§3.7). + it("carries the queried name, the ids, and one comparable row per match", async () => { + const svc = service({ + getAssetsByName: async () => [ + asset({ id: "1000123", owner_address: OTHER_HEX, total_supply: 1_000_000_000, precision: 6 }), + asset({ id: "1000488", owner_address: OWNER_HEX, total_supply: 50_000_000, precision: 2 }), + ], + }); + await expect(svc.info(NET, { assetRef: "MyToken" })).rejects.toMatchObject({ + code: "ambiguous_asset_name", + details: { + name: "MyToken", + assetIds: ["1000123", "1000488"], + matches: [ + { assetId: "1000123", totalSupply: "1000000000", precision: 6 }, + { assetId: "1000488", totalSupply: "50000000", precision: 2 }, + ], + }, + }); + }); + + it("resolves a name matching exactly one asset instead of erroring", async () => { + const svc = service({ getAssetsByName: async () => [asset({ id: "1000123" })] }); + await expect(svc.info(NET, { assetRef: "MyToken" })).resolves.toMatchObject({ assetId: "1000123" }); + }); +}); + describe("asset unfreeze", () => { const tranches = [ { frozen_amount: 100_000_000_000_000, frozen_days: 30 }, diff --git a/ts/src/application/use-cases/tron/asset-service.ts b/ts/src/application/use-cases/tron/asset-service.ts index 8d0c05bc8..12d695646 100644 --- a/ts/src/application/use-cases/tron/asset-service.ts +++ b/ts/src/application/use-cases/tron/asset-service.ts @@ -345,11 +345,22 @@ export class TronAssetService { if (/^\d+$/.test(reference)) return gateway.getAssetById(reference); const matches = await gateway.getAssetsByName(reference); if (matches.length > 1) { - const ids = matches.map((a) => String(a.id)); + // `assetIds` is the machine-readable answer ("which ids do I re-run with?"); `matches` carries + // the columns a human needs to actually pick one, and is what the text renderer tables up. + // Quantities stay raw (minimal units) here, exactly as the success payload reports them. throw new ChainError( "ambiguous_asset_name", `${matches.length} TRC10 tokens are named ${reference}; re-run with the id`, - { assetIds: ids }, + { + name: reference, + assetIds: matches.map((a) => String(a.id)), + matches: matches.map((a) => ({ + assetId: String(a.id), + issuerAddress: tronHexToBase58(a.owner_address), + totalSupply: String(a.total_supply), + precision: a.precision ?? 0, + })), + }, ); } return matches[0]; diff --git a/ts/src/application/use-cases/tron/contract-service.governance.test.ts b/ts/src/application/use-cases/tron/contract-service.governance.test.ts index 88b5deb10..f0ebbc2b6 100644 --- a/ts/src/application/use-cases/tron/contract-service.governance.test.ts +++ b/ts/src/application/use-cases/tron/contract-service.governance.test.ts @@ -1,109 +1,115 @@ -import { describe, expect, it, vi } from "vitest"; -import type { NetworkDescriptor } from "../../../domain/types/index.js"; -import { ChainError } from "../../../domain/errors/index.js"; -import type { TransactionScope } from "../../contracts/execution-scope.js"; -import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; -import type { TronGateway } from "../../ports/chain/tron-gateway.js"; -import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; -import { TronContractService } from "./contract-service.js"; +import { describe, expect, it, vi } from "vitest" +import type { NetworkDescriptor } from "../../../domain/types/index.js" +import { ChainError } from "../../../domain/errors/index.js" +import type { TransactionScope } from "../../contracts/execution-scope.js" +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js" +import type { TronGateway } from "../../ports/chain/tron-gateway.js" +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js" +import { TronContractService } from "./contract-service.js" -const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] }; -const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; -const CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; -const OTHER = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; +const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] } +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7" +const CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" +const OTHER = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb" const scope: TransactionScope = { - activeAccount: "wlt_test.0", resolveAddress: () => OWNER, - timeoutMs: 60_000, wait: false, waitTimeoutMs: 60_000, emit: () => {}, warn: () => {}, -}; + activeAccount: "wlt_test.0", + resolveAddress: () => OWNER, + timeoutMs: 60_000, + wait: false, + waitTimeoutMs: 60_000, + emit: () => {}, + warn: () => {}, +} function createService(gateway: Partial) { - const concrete = gateway as TronGateway; + const concrete = gateway as TronGateway const pipeline = { assertCanSign: vi.fn(), run: async (params: TxPipelineParams) => { - await params.build(OWNER); - return { stage: "submitted", txId: "tx-contract" } as never; + await params.build(OWNER) + return { stage: "submitted", txId: "tx-contract" } as never }, - } as unknown as TxPipeline; - return new TronContractService( - { get: () => concrete } as unknown as ChainGatewayProvider, - pipeline, - ); + } as unknown as TxPipeline + return new TronContractService({ get: () => concrete } as unknown as ChainGatewayProvider, pipeline) } describe("TronContractService governance", () => { - it("applies v4.11 permission and expiration controls to contract send", async () => { - const trigger = vi.fn(async () => ({ raw_data: {} })); - const extend = vi.fn(async (transaction) => ({ ...transaction as object, extended: true })); + it("applies v4.12 permission and expiration controls to contract send", async () => { + const trigger = vi.fn(async () => ({ raw_data: {} })) + const extend = vi.fn(async (transaction) => ({ ...(transaction as object), extended: true })) const service = createService({ triggerSmartContract: trigger, extendTransactionExpiration: extend, estimateResources: async () => ({ feeModel: "tron-resource", energy: 0 }), - }); - await expect(service.send(scope, NET, { - contract: CONTRACT, - method: "set(uint256)", - parameters: [{ type: "uint256", value: "1" }], - callValueSun: "0", - feeLimit: "100000000", - permissionId: 2, - expiration: 120_000, - signOnly: true, - })).resolves.toMatchObject({ kind: "contract-send", txId: "tx-contract" }); - expect(trigger).toHaveBeenCalledWith( - OWNER, - CONTRACT, - "set(uint256)", - [{ type: "uint256", value: "1" }], - { feeLimit: "100000000", callValue: "0", permissionId: 2 }, - ); - expect(extend).toHaveBeenCalledWith(expect.anything(), 120_000); - }); + }) + await expect( + service.send(scope, NET, { + contract: CONTRACT, + method: "set(uint256)", + parameters: [{ type: "uint256", value: "1" }], + callValueSun: "0", + feeLimit: "100000000", + permissionId: 2, + expiration: 120_000, + signOnly: true, + }), + ).resolves.toMatchObject({ kind: "contract-send", txId: "tx-contract" }) + expect(trigger).toHaveBeenCalledWith(OWNER, CONTRACT, "set(uint256)", [{ type: "uint256", value: "1" }], { feeLimit: "100000000", callValue: "0", permissionId: 2 }) + expect(extend).toHaveBeenCalledWith(expect.anything(), 120_000) + }) it("requires SmartContract.origin_address to equal the selected account", async () => { - const build = vi.fn(); + const build = vi.fn() const service = createService({ getContractMetadata: async () => ({ methods: [], originAddress: OTHER, contract: {} }), buildClearContractAbi: build, - }); - await expect(service.clearAbi(scope, NET, { address: CONTRACT, permissionId: 0 })) - .rejects.toMatchObject({ code: "not_contract_deployer" }); - expect(build).not.toHaveBeenCalled(); - }); + }) + await expect(service.clearAbi(scope, NET, { address: CONTRACT, permissionId: 0 })).rejects.toMatchObject({ code: "not_contract_deployer" }) + expect(build).not.toHaveBeenCalled() + }) it("maps the generic adapter absence to contract_not_found", async () => { const service = createService({ - getContractMetadata: async () => { throw new ChainError("not_found", "missing"); }, - }); - await expect(service.clearAbi(scope, NET, { address: CONTRACT, permissionId: 0 })) - .rejects.toMatchObject({ code: "contract_not_found" }); - }); + getContractMetadata: async () => { + throw new ChainError("not_found", "missing") + }, + }) + await expect(service.clearAbi(scope, NET, { address: CONTRACT, permissionId: 0 })).rejects.toMatchObject({ code: "contract_not_found" }) + }) it("passes the caller-paid percentage through without reversing it", async () => { - const build = vi.fn(async () => ({})); + const build = vi.fn(async () => ({})) const service = createService({ getContractMetadata: async () => ({ methods: [], originAddress: OWNER, contract: {} }), buildUpdateUserResourcePercent: build, - }); - await expect(service.setUserResourcePercent(scope, NET, { - address: CONTRACT, percent: 100, permissionId: 2, - })).resolves.toMatchObject({ + }) + await expect( + service.setUserResourcePercent(scope, NET, { + address: CONTRACT, + percent: 100, + permissionId: 2, + }), + ).resolves.toMatchObject({ contractAddress: CONTRACT, deployerAddress: OWNER, consumeUserResourcePercent: 100, - }); - expect(build).toHaveBeenCalledWith(OWNER, CONTRACT, 100, { permissionId: 2 }); - }); + }) + expect(build).toHaveBeenCalledWith(OWNER, CONTRACT, 100, { permissionId: 2 }) + }) it("accepts an energy limit above TronWeb's obsolete 10M client cap", async () => { - const build = vi.fn(async () => ({})); + const build = vi.fn(async () => ({})) const service = createService({ getContractMetadata: async () => ({ methods: [], originAddress: OWNER, contract: {} }), buildUpdateOriginEnergyLimit: build, - }); - await expect(service.setOriginEnergyLimit(scope, NET, { - address: CONTRACT, energy: 50_000_000, permissionId: 0, - })).resolves.toMatchObject({ originEnergyLimit: 50_000_000 }); - expect(build).toHaveBeenCalledWith(OWNER, CONTRACT, 50_000_000, { permissionId: 0 }); - }); -}); + }) + await expect( + service.setOriginEnergyLimit(scope, NET, { + address: CONTRACT, + energy: 50_000_000, + permissionId: 0, + }), + ).resolves.toMatchObject({ originEnergyLimit: 50_000_000 }) + expect(build).toHaveBeenCalledWith(OWNER, CONTRACT, 50_000_000, { permissionId: 0 }) + }) +}) diff --git a/ts/src/application/use-cases/tron/exchange-service.test.ts b/ts/src/application/use-cases/tron/exchange-service.test.ts index bda026395..37d17ca46 100644 --- a/ts/src/application/use-cases/tron/exchange-service.test.ts +++ b/ts/src/application/use-cases/tron/exchange-service.test.ts @@ -180,6 +180,17 @@ describe("exchange trade", () => { .resolves.toMatchObject({ minReceivedQuant: String(4_900n * 1_000_000n) }); }); + // The rejection has to point at --min-received, not at --amount: the caller would otherwise go + // and correct an option they never passed (and which carries a different meaning on this command). + it("blames --min-received, not --amount, for an over-precise floor", async () => { + const svc = service({ getExchangeById: async () => pool() }); + await expect(svc.trade(scope, NET, { ...base, minReceived: "1.1234567" })) + .rejects.toMatchObject({ + code: "invalid_amount", + message: expect.stringContaining("--min-received has too many decimal places"), + }); + }); + it("sends expected=1 and warns when no protection is asked for", async () => { warnings.length = 0; const svc = service({ getExchangeById: async () => pool() }); diff --git a/ts/src/application/use-cases/tron/exchange-service.ts b/ts/src/application/use-cases/tron/exchange-service.ts index 01e9700ff..bfdba0d56 100644 --- a/ts/src/application/use-cases/tron/exchange-service.ts +++ b/ts/src/application/use-cases/tron/exchange-service.ts @@ -405,7 +405,9 @@ export class TronExchangeService { if (given > 1) { throw new UsageError("invalid_option", "choose at most one of --min-received, --raw-min-received, --slippage"); } - if (input.minReceived !== undefined) return BigInt(toBaseUnits(input.minReceived, buyMeta.decimals, buyMeta.label)); + if (input.minReceived !== undefined) { + return BigInt(toBaseUnits(input.minReceived, buyMeta.decimals, buyMeta.label, "--min-received")); + } if (input.rawMinReceived !== undefined) return BigInt(input.rawMinReceived); if (input.slippage !== undefined) return slippageFloor(predicted, input.slippage); scope.warn( diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index c9563b222..85f653d6b 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -9,7 +9,7 @@ import { StreamManager } from "../adapters/inbound/cli/stream/index.js" import { hasCommand, parseGlobals } from "./argv.js" import { composeCliRuntime } from "./composition.js" -export const VERSION = "4.11.0" +export const VERSION = "4.12.0" /** * Report a failure raised while the composition root was still being built — an unreadable, @@ -24,11 +24,7 @@ export const VERSION = "4.11.0" * may sit next to a service credential, so it is classified to a generic `internal_error` rather * than surfaced. */ -function reportBootstrapFailure( - error: unknown, - globals: { output?: OutputMode; verbose?: boolean }, - startedAt: number, -): ExitCode { +function reportBootstrapFailure(error: unknown, globals: { output?: OutputMode; verbose?: boolean }, startedAt: number): ExitCode { const output = globals.output ?? "text" const normalized = normalizeError(error) const streams = new StreamManager(output, globals.verbose ?? false) diff --git a/ts/src/domain/amounts/amounts.test.ts b/ts/src/domain/amounts/amounts.test.ts index 50f122e47..941ad5a3b 100644 --- a/ts/src/domain/amounts/amounts.test.ts +++ b/ts/src/domain/amounts/amounts.test.ts @@ -41,4 +41,24 @@ describe("toBaseUnits", () => { it("rejects more fractional digits than the unit supports", () => { expect(() => toBaseUnits("1.1234567", 6, "TRX")).toThrow(/too many decimal places/); }); + + // The message has to name the option the caller actually typed: commands take a decimal amount + // under names of their own, and pointing at `--amount` sends them to fix a flag they never used. + describe("names the offending option", () => { + it("defaults to --amount for the commands whose option is that", () => { + expect(() => toBaseUnits("1.1234567", 6, "TRX")).toThrow(/^--amount has too many/); + expect(() => toBaseUnits("abc", 6, "TRX")).toThrow(/^--amount must be/); + }); + + it("uses the caller's option name in both messages", () => { + expect(() => toBaseUnits("1.1234567", 6, "TRX", "--min-received")) + .toThrow("--min-received has too many decimal places for TRX (max 6)"); + expect(() => toBaseUnits("abc", 6, "TRX", "--min-received")) + .toThrow("--min-received must be a non-negative decimal TRX amount"); + }); + + it("still returns the converted value when the input is fine", () => { + expect(toBaseUnits("1.5", 6, "TRX", "--min-received")).toBe("1500000"); + }); + }); }); diff --git a/ts/src/domain/amounts/index.ts b/ts/src/domain/amounts/index.ts index f2244ed3e..2a1fdf2f7 100644 --- a/ts/src/domain/amounts/index.ts +++ b/ts/src/domain/amounts/index.ts @@ -26,15 +26,20 @@ export function fromBaseUnits(value: string | number | bigint, decimals: number) * Human decimal amount → integer base-unit string (e.g. "1204.56", 6 → "1204560000"). * Rejects negative, over-precise, or non-numeric input with a usage error; `unitLabel` names * the unit in the message. + * + * `flag` names the option the value came from. Several commands take a decimal amount under a + * name of their own (`--pay`, `--amounts`, `--min-received`), and a message that always said + * `--amount` sent the caller to fix a flag they had not typed — one that, on `asset participate`, + * does not exist. Callers whose option really is `--amount` need not pass it. */ -export function toBaseUnits(value: string, decimals: number, unitLabel: string): string { +export function toBaseUnits(value: string, decimals: number, unitLabel: string, flag = "--amount"): string { const v = value.trim(); if (!/^\d+(\.\d+)?$/.test(v)) { - throw new UsageError("invalid_amount", `--amount must be a non-negative decimal ${unitLabel} amount`); + throw new UsageError("invalid_amount", `${flag} must be a non-negative decimal ${unitLabel} amount`); } const [whole, frac = ""] = v.split("."); if (frac.length > decimals) { - throw new UsageError("invalid_amount", `--amount has too many decimal places for ${unitLabel} (max ${decimals})`); + throw new UsageError("invalid_amount", `${flag} has too many decimal places for ${unitLabel} (max ${decimals})`); } const digits = `${whole}${frac.padEnd(decimals, "0")}`.replace(/^0+(?=\d)/, ""); return digits === "" ? "0" : digits; diff --git a/ts/test/contract-deploy.test.ts b/ts/test/contract-deploy.test.ts index 7a83560dd..724f82fbd 100644 --- a/ts/test/contract-deploy.test.ts +++ b/ts/test/contract-deploy.test.ts @@ -11,7 +11,9 @@ import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.j // Regression coverage for issue #2: `contract deploy` constructor params. // • --constructor-sig was a dead flag (types come from the ABI); it was removed. // • --params must be RAW positional values ([100, "T..."]) — the {type,value} form that -// contract call/send use is rejected by TronWeb's createSmartContract ABI encoder. +// contract call/send use is rejected by TronWeb's createSmartContract ABI encoder, and is now +// named as a format error at the command boundary before it gets there (see +// commands/contract.deploy.test.ts for that guard's own alignment coverage). // // The negative case fails at client-side ABI encoding *before* any node call, so it runs // hermetically (random key, no network, no funds). The positive/broadcast cases hit real Nile @@ -76,10 +78,14 @@ describe("contract deploy — constructor params (issue #2)", () => { }); it("rejects the {type,value} param form (raw positional values are required)", () => { - seed(randomBytes(32).toString("hex")); // encoding fails before any node call → hermetic + seed(randomBytes(32).toString("hex")); // rejected at the command boundary → hermetic const out = deploy(TYPED_PARAMS, { dryRun: true }); expect(out.success).toBe(false); - expect(out.error.message).toMatch(/BigNumberish/i); + // A malformed call, not a failed execution: deterministic on retry, so exit 2 / invalid_value. + // (Before the guard this reached TronWeb and came back as rpc_error / `invalid BigNumberish + // value (argument="value")` — same refusal, worded in ethers' internals.) + expect(out.error.code).toBe("invalid_value"); + expect(out.error.message).toMatch(/raw positional values/i); }); const PK = loadTestPrivateKey(); diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index e8812d89a..a7005201c 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -35,7 +35,10 @@ function run(args: string[], opts: { input?: string; password?: string | null } // `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, + input: stdin, + encoding: "utf8", + env, + timeout: 25_000, }) let json: any try { @@ -63,7 +66,7 @@ describe("golden CLI — meta & introspection", () => { it("--version prints the version, exit 0", () => { const r = run(["--version"]) expect(r.status).toBe(0) - expect(r.stdout.trim()).toBe("4.11.0") + expect(r.stdout.trim()).toBe("4.12.0") }) it("root --help shows the TRON first-release command surface", () => { @@ -363,6 +366,18 @@ describe("golden CLI — error contract (exit codes)", () => { expect(r.json.error.code).toBe("invalid_value") }) + // Every address-shaped flag is validated locally, so a typo is exit 2 everywhere rather than a + // node round-trip that reports the same typo as an execution failure (exit 1, rpc_error). + it.each([ + ["asset info --issuer", ["asset", "info", "--issuer", "notanaddress"]], + ["account activate --address", ["account", "activate", "--address", "notanaddress", "--dry-run"]], + ])("%s with a malformed address → invalid_value, exit 2", (_label, args) => { + const r = run(["--output", "json", ...args, "--network", "tron:nile"]) + expect(r.status).toBe(2) + expect(r.json.error.code).toBe("invalid_value") + expect(r.json.error.message).toMatch(/invalid tron address/) + }) + it("stake delegate --lock-period without --lock → invalid_value, exit 2", () => { const r = run([ "--output", @@ -613,12 +628,7 @@ describe("golden CLI — v4.12 governance surface", () => { }) 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 }) + 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", @@ -636,16 +646,11 @@ describe("golden CLI — v4.12 governance surface", () => { }) it("rejects brokerage and origin-energy int64 overflow before wallet or RPC access", () => { - const brokerage = run([ - "-o", "json", "witness", "set-brokerage", "101", - ], { password: null }) + 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 }) + 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") }) From f38e529bdee5393bbf66fcecda4f403d1dd37c97 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Fri, 14 Aug 2026 01:22:16 +0800 Subject: [PATCH 10/15] fix(ts): unify governance transaction preparation on the pipeline's prepare hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a0ce486c closed with "Unifying on prepare is a separate refactor". This is that refactor, and it turns out three defects were downstream of the split. All nine governance writes advertised --permission-id and --expiration and neither worked in ANY execution mode. The builders bound both options themselves, so the services passed no `prepare` hook, and the pipeline reads "no prepare hook" as "this adapter cannot apply these options" — it threw invalid_option after the transaction had already been built. 9/9 commands, exit 2, including --dry-run and the default broadcast path. The multi-sig story for governance was simply unreachable. Routing the group through the shared tronTransactionHooks fixes that and removes the second expiration mechanism, which disagreed with the first in two ways: - Semantics. extendTransactionExpiration INCREMENTS raw_data.expiration; prepareTransaction SETS it to timestamp + N. So --expiration 60000 meant 120s here and 60s on tx send / contract send / contract deploy, and the governance value depended on whichever block timestamp the node served — the opposite of what an offline signing window needs. docs/commands/* already documented the absolute meaning, so the docs were right and the implementation was not. - Units. tronweb's extendExpiration takes SECONDS: it does parseInt(1e3 * extension) before adding. We passed milliseconds, so the window was multiplied by 1000 — --expiration 60000 would have produced 16.7 hours, and the documented 24h maximum 1000 days. This was unreachable only because the guard above rejected it first, which is why it had not been observed; fixing the guard alone would have converted a hard failure into a silent one. With prepare owning both options, extendTransactionExpiration and withExtendedExpiration have no callers and are removed along with the port declaration. Verified that prepare is lossless for the two custom-encoded governance types: toProtobuf consults the same override table, and with permissionId 0 and no expiration the re-derived bytes and txID are byte-identical to the builder's. The int64-boundary case still fails closed, now with "parameter value is outside int64" instead of tronweb's "Error generating a new transaction id." Two more defects in the same area: - Governance build-only/sign-only text output was built around `data.unsignedHex`, a field the pipeline never produces (it emits `hex`). Since kv() drops empty rows the artifact did not render blank, it vanished — the flag's entire purpose, available only via -o json. Print the bare hex as tx send does, so it pipes into a file or `tx sign`. - --permission-id was re-declared for governance with an int32 ceiling while transactionMode() accepts 0..9, so --help advertised a range the runtime refused, and the same mistake was classified invalid_option here but invalid_value everywhere else. TRON has at most eight active permissions (2..9) plus owner, so the override was simply wrong; dropping it inherits the correct bound. Also drops a dead `mode` in contract send: governanceTransactionMode's result was computed and discarded (the pipeline received transactionMode's), and both of its other effects were already performed on the two lines above. Tests drive the real TxPipeline. The existing governance suites pass a fake pipeline that records params, which is exactly why the guard never fired in test — only the real one has it. The pipeline's binding guard had no regression test at all; the one added here was verified to fail against a mutated guard rather than passing vacuously. Verified end-to-end against a controllable node: --permission-id 2 lands as Permission_id=2, --expiration 60000 yields exactly 60000ms on governance and on tx send, and build-only/sign-only write pipeable hex to stdout. --- .../adapters/inbound/cli/commands/shared.ts | 4 - .../cli/commands/transaction-options.test.ts | 25 ++- .../inbound/cli/render/governance.test.ts | 18 ++ .../adapters/inbound/cli/render/governance.ts | 6 +- ts/src/adapters/outbound/chain/tron/tron.ts | 9 - .../application/ports/chain/tron-gateway.ts | 1 - .../services/pipeline/pipeline.test.ts | 23 +++ .../tron/contract-service.governance.test.ts | 22 ++- .../use-cases/tron/contract-service.ts | 17 +- .../tron/governance-transaction-mode.test.ts | 187 ++++++++++++++++++ .../use-cases/tron/governance-transaction.ts | 26 --- .../use-cases/tron/proposal-service.test.ts | 11 +- .../use-cases/tron/proposal-service.ts | 27 +-- .../use-cases/tron/witness-service.ts | 27 +-- 14 files changed, 293 insertions(+), 110 deletions(-) create mode 100644 ts/src/application/use-cases/tron/governance-transaction-mode.test.ts diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index dda526d04..4a4482a68 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -31,10 +31,6 @@ 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( diff --git a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts index 2254aa26d..f36980601 100644 --- a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts +++ b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts @@ -3,7 +3,7 @@ import { readFileSync, readdirSync } from "node:fs"; import { join, relative } from "node:path"; import { z } from "zod"; import { permissionUpdateSpec } from "./permission.js"; -import { txModeFields } from "./shared.js"; +import { governanceTxModeFields, txModeFields } from "./shared.js"; describe("transaction option argv coercion", () => { it("accepts numeric --permission-id and --expiration values from argv", () => { @@ -131,3 +131,26 @@ describe("reference pages keep up with the shared transaction options", () => { expect(behind).toEqual([]); }); }); + +/** + * The governance group re-declared `--permission-id` with an int32 ceiling, so `--help` advertised a + * range the application layer then refused: `transactionMode()` accepts 0..9 and rejects anything + * above it with `invalid_option`, after the command has already started. TRON has at most eight + * active permissions (ids 2..9) plus owner (0), so 0..9 is the real bound and the override was + * simply wrong — it also downgraded the failure from a schema `invalid_value` to a runtime + * `invalid_option`, classifying the same mistake differently from every non-governance command. + */ +describe("governance --permission-id shares the protocol bound with every other command", () => { + const field = (fields: Record) => z.object(fields as never); + + it("rejects a permission id above the protocol maximum at the schema, as txModeFields does", () => { + expect(field(governanceTxModeFields).safeParse({ permissionId: "10" }).success).toBe(false); + expect(field(txModeFields).safeParse({ permissionId: "10" }).success).toBe(false); + }); + + it("still accepts the whole valid range", () => { + for (const id of ["0", "2", "9"]) { + expect(field(governanceTxModeFields).safeParse({ permissionId: id }).success).toBe(true); + } + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/governance.test.ts b/ts/src/adapters/inbound/cli/render/governance.test.ts index 485d245ee..59b4604cb 100644 --- a/ts/src/adapters/inbound/cli/render/governance.test.ts +++ b/ts/src/adapters/inbound/cli/render/governance.test.ts @@ -75,3 +75,21 @@ describe("proposal text rendering", () => { ].join("\n")); }); }); + +/** + * `--build-only` and `--sign-only` exist to hand the transaction hex to the next step (collect a + * co-signature, relay it offline). `tx send` prints that hex bare, so it pipes straight into a file + * or another command; the governance renderer printed a receipt built around `data.unsignedHex`, a + * field the pipeline never produces — the canonical name is `hex`. `kv()` drops empty rows, so the + * artifact did not merely render blank, it vanished, and only `-o json` carried it. + */ +describe("governance build-only / sign-only emit the transaction artifact", () => { + const HEX = "0a83010a02c1112208bd2a9a677fccd7dc"; + + it.each([ + ["build-only", { kind: "witness-update", mode: "build-only", hex: HEX }], + ["sign-only", { kind: "witness-update", mode: "sign-only", hex: HEX, txId: "abc", signed: {} }], + ])("`%s` prints the hex and nothing else, exactly like `tx send`", (_label, data) => { + expect(GovernanceFormatters.governanceReceipt(data, ctx)).toBe(HEX); + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/governance.ts b/ts/src/adapters/inbound/cli/render/governance.ts index 14d1408d7..19aa4219f 100644 --- a/ts/src/adapters/inbound/cli/render/governance.ts +++ b/ts/src/adapters/inbound/cli/render/governance.ts @@ -81,11 +81,15 @@ function renderGovernanceReceipt(data: Obj, ctx: TextRenderContext): string { fields.push(["Fee", estimateFee(data)]); return appendChanges(receipt(pending(), `Dry run ${label}`, fields), data); } + // Both early-exit modes exist to produce a relayable artifact, so the hex IS the output — + // bare, as `tx send` prints it, so it pipes into a file or the next command unchanged. The + // receipts below remain for the case the pipeline could not serialise one. if (mode === "build-only") { - fields.push(["Unsigned hex", String(data.unsignedHex ?? "")]); + if (data.hex) return String(data.hex); return appendChanges(receipt(ok(), `Built unsigned ${label}`, fields), data); } if (mode === "sign-only") { + if (data.hex) return String(data.hex); fields.push(["TxID", String(data.txId ?? "")]); fields.push(["Signed", signedSummary(data.signed)]); return appendChanges(receipt(ok(), `Signed ${label}`, fields), data); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index b349b2414..1a4faedfc 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -1169,15 +1169,6 @@ export class TronRpcClient implements TronGateway, Broadcaster { ), ); } - async extendTransactionExpiration(transaction: unknown, extensionMs: number): Promise { - return this.#wrap("extendExpiration", async () => - await this.#tw.transactionBuilder.extendExpiration( - transaction as Types.Transaction, - extensionMs, - { txLocal: true }, - ), - ); - } /** Build a one-contract transaction using TronWeb's public protobuf codec and local ref block. * This is equivalent to TransactionBuilder.createTransaction but does not inherit individual diff --git a/ts/src/application/ports/chain/tron-gateway.ts b/ts/src/application/ports/chain/tron-gateway.ts index 44b382e5f..9b8ec9cd8 100644 --- a/ts/src/application/ports/chain/tron-gateway.ts +++ b/ts/src/application/ports/chain/tron-gateway.ts @@ -441,5 +441,4 @@ export interface TronGateway extends Broadcaster { percent: number, options?: TronTransactionBuildOptions, ): Promise; - extendTransactionExpiration(transaction: UnsignedTx, extensionMs: number): Promise; } diff --git a/ts/src/application/services/pipeline/pipeline.test.ts b/ts/src/application/services/pipeline/pipeline.test.ts index 4ce028372..154a28fe0 100644 --- a/ts/src/application/services/pipeline/pipeline.test.ts +++ b/ts/src/application/services/pipeline/pipeline.test.ts @@ -174,3 +174,26 @@ describe("TxPipeline build-only", () => { }))).rejects.toMatchObject({ code: "invalid_option" }); }); }); + +/** + * `--permission-id` / `--expiration` need someone to bind them into the transaction, and that + * someone is the `prepare` hook. An adapter that offers no hook cannot apply either option, so the + * pipeline refuses rather than silently dropping them. + */ +describe("TxPipeline permission/expiration binding guard", () => { + const buildOnly = { + buildOnly: true, + mode: "build-only" as const, + build: async () => ({ raw_data_hex: "0102" }) as never, + artifact: () => "0a02010202", + }; + const signers = () => ({ resolve: vi.fn(), assertCanSign: vi.fn() } as unknown as SignerResolver); + + it.each([ + ["--permission-id", { permissionId: 2 }], + ["--expiration", { expiration: 60_000 }], + ])("refuses %s when the adapter has no prepare hook", async (_label, opts) => { + await expect(new TxPipeline(signers()).run(params({} as Signer, { ...buildOnly, ...opts }))) + .rejects.toMatchObject({ code: "invalid_option" }); + }); +}); diff --git a/ts/src/application/use-cases/tron/contract-service.governance.test.ts b/ts/src/application/use-cases/tron/contract-service.governance.test.ts index f0ebbc2b6..bb8b22682 100644 --- a/ts/src/application/use-cases/tron/contract-service.governance.test.ts +++ b/ts/src/application/use-cases/tron/contract-service.governance.test.ts @@ -23,23 +23,26 @@ const scope: TransactionScope = { function createService(gateway: Partial) { const concrete = gateway as TronGateway + const captured: TxPipelineParams[] = [] const pipeline = { assertCanSign: vi.fn(), run: async (params: TxPipelineParams) => { + captured.push(params) await params.build(OWNER) return { stage: "submitted", txId: "tx-contract" } as never }, } as unknown as TxPipeline - return new TronContractService({ get: () => concrete } as unknown as ChainGatewayProvider, pipeline) + return { + service: new TronContractService({ get: () => concrete } as unknown as ChainGatewayProvider, pipeline), + captured, + } } describe("TronContractService governance", () => { it("applies v4.12 permission and expiration controls to contract send", async () => { const trigger = vi.fn(async () => ({ raw_data: {} })) - const extend = vi.fn(async (transaction) => ({ ...(transaction as object), extended: true })) - const service = createService({ + const { service, captured } = createService({ triggerSmartContract: trigger, - extendTransactionExpiration: extend, estimateResources: async () => ({ feeModel: "tron-resource", energy: 0 }), }) await expect( @@ -55,12 +58,13 @@ describe("TronContractService governance", () => { }), ).resolves.toMatchObject({ kind: "contract-send", txId: "tx-contract" }) expect(trigger).toHaveBeenCalledWith(OWNER, CONTRACT, "set(uint256)", [{ type: "uint256", value: "1" }], { feeLimit: "100000000", callValue: "0", permissionId: 2 }) - expect(extend).toHaveBeenCalledWith(expect.anything(), 120_000) + expect(captured[0]).toMatchObject({ permissionId: 2, expiration: 120_000 }) + expect(typeof captured[0]!.prepare).toBe("function") }) it("requires SmartContract.origin_address to equal the selected account", async () => { const build = vi.fn() - const service = createService({ + const { service } = createService({ getContractMetadata: async () => ({ methods: [], originAddress: OTHER, contract: {} }), buildClearContractAbi: build, }) @@ -69,7 +73,7 @@ describe("TronContractService governance", () => { }) it("maps the generic adapter absence to contract_not_found", async () => { - const service = createService({ + const { service } = createService({ getContractMetadata: async () => { throw new ChainError("not_found", "missing") }, @@ -79,7 +83,7 @@ describe("TronContractService governance", () => { it("passes the caller-paid percentage through without reversing it", async () => { const build = vi.fn(async () => ({})) - const service = createService({ + const { service } = createService({ getContractMetadata: async () => ({ methods: [], originAddress: OWNER, contract: {} }), buildUpdateUserResourcePercent: build, }) @@ -99,7 +103,7 @@ describe("TronContractService governance", () => { it("accepts an energy limit above TronWeb's obsolete 10M client cap", async () => { const build = vi.fn(async () => ({})) - const service = createService({ + const { service } = createService({ getContractMetadata: async () => ({ methods: [], originAddress: OWNER, contract: {} }), buildUpdateOriginEnergyLimit: build, }) diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index d901201d1..1ecdf9095 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -7,10 +7,8 @@ import { ChainError } from "../../../domain/errors/index.js"; import { computeTronCreate2Address } from "../../../domain/governance/create2.js"; import type { UnsignedTx } from "../../../domain/types/index.js"; import { - governanceArtifact, governanceTransactionMode, transactionResource, - withExtendedExpiration, type GovernanceTransactionInput, } from "./governance-transaction.js"; import { @@ -56,7 +54,6 @@ export class TronContractService { ) { if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); - const mode = governanceTransactionMode(this.pipeline, scope, input); const outcome = await this.pipeline.run({ ctx: scope, net: network, @@ -65,17 +62,13 @@ export class TronContractService { ...transactionMode(input), ...tronTransactionHooks(gateway), confirm: tronConfirmation(gateway, scope), - build: async (from) => withExtendedExpiration( - gateway, - await gateway.triggerSmartContract( + build: async (from) => gateway.triggerSmartContract( from, input.contract, input.method, input.parameters, { feeLimit: input.feeLimit, callValue: input.callValueSun, permissionId: input.permissionId }, ), - input.expiration, - ), estimate: async () => { const estimate = await gateway.estimateResources( scope.resolveAddress("tron"), @@ -254,12 +247,8 @@ export class TronContractService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), - ...governanceArtifact(gateway), - build: async (address) => withExtendedExpiration( - gateway, - await build(gateway, address), - input.expiration, - ), + ...tronTransactionHooks(gateway), + build: async (address) => await build(gateway, address), estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "contract governance uses bandwidth only" }), }); const data = outcomeData(outcome); diff --git a/ts/src/application/use-cases/tron/governance-transaction-mode.test.ts b/ts/src/application/use-cases/tron/governance-transaction-mode.test.ts new file mode 100644 index 000000000..1df837b6b --- /dev/null +++ b/ts/src/application/use-cases/tron/governance-transaction-mode.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NetworkDescriptor, Signer } from "../../../domain/types/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { SignerResolver } from "../../services/signer/index.js"; +import { TxPipeline } from "../../services/pipeline/index.js"; +import { TronWitnessService } from "./witness-service.js"; +import { TronProposalService } from "./proposal-service.js"; +import { TronContractService } from "./contract-service.js"; + +/** + * The nine governance writes advertise `--permission-id` and `--expiration`. They once bound both + * inside their own builders instead of through the pipeline's `prepare` hook, and the pipeline's + * guard reads "no prepare hook ⇒ this adapter cannot apply these options" — so every non-default + * value was rejected after the transaction had already been built, in every execution mode. The + * multi-sig story for governance was simply unreachable. + * + * These tests drive the REAL `TxPipeline`. The other governance suites pass a fake pipeline that + * records params, which is exactly why the guard never fired in test: only the real one has it. + */ +const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] }; +const OWNER = "TNmoJ3Be59WFEq5dsW6eCkZjveiL3G8HVB"; +const CONTRACT = "TPgmqJ9ixVReY2Zc5FSYiC8qp4yZybbMhU"; + +const scope: TransactionScope = { + activeAccount: "wlt_test.0" as never, + resolveAddress: () => OWNER, + timeoutMs: 60_000, wait: false, waitTimeoutMs: 60_000, emit: () => {}, warn: () => {}, +}; + +function harness(overrides: Partial = {}) { + const built = { raw_data: { timestamp: 1_000_000, expiration: 1_060_000, contract: [{}] } }; + // Mirrors the observable half of the real `prepareTransaction`: binds Permission_id and sets + // expiration relative to the transaction's own timestamp. Clones, because the real one does + // (`structuredClone`) and because `built` is shared across the cases below. + const prepareTransaction = vi.fn((tx: unknown, options: { permissionId: number; expiration?: number }) => { + const prepared = structuredClone(tx) as typeof built & { raw_data: { contract: Array<{ Permission_id?: number }> } }; + if (options.permissionId !== 0) prepared.raw_data.contract[0]!.Permission_id = options.permissionId; + if (options.expiration !== undefined) prepared.raw_data.expiration = prepared.raw_data.timestamp + options.expiration; + return prepared; + }); + const gateway = { + encodeTransactionHex: vi.fn(() => "0a02deadbeef"), + prepareTransaction, + getWitness: async () => ({ address: OWNER, voteCount: "1", url: "u" }), + getAccount: async () => ({ address: OWNER, balance: "10000000000" }), + getChainParameters: async () => [ + { key: "getAccountUpgradeCost", value: 9_999_000_000 }, + { key: "getMaintenanceTimeInterval", value: 1_800_000 }, + ], + getProposals: async () => [{ + id: 7, proposerAddress: OWNER, parameters: { "0": "100000" }, + expirationTime: Date.now() + 600_000, createTime: Date.now(), approvals: [], state: "PENDING" as const, + }], + getProposal: async () => ({ + id: 7, proposerAddress: OWNER, parameters: { "0": "100000" }, + expirationTime: Date.now() + 600_000, createTime: Date.now(), approvals: [], state: "PENDING" as const, + }), + getWitnesses: async () => [{ address: OWNER, voteCount: "1", url: "u" }], + getContractMetadata: async () => ({ name: "t", methods: [], originAddress: OWNER, contract: {}, info: {} }), + buildWitnessCreate: async () => built, buildWitnessUpdate: async () => built, + buildWitnessSetBrokerage: async () => built, buildProposalCreate: async () => built, + buildProposalApprove: async () => built, buildProposalDelete: async () => built, + buildClearContractAbi: async () => built, buildUpdateOriginEnergyLimit: async () => built, + buildUpdateUserResourcePercent: async () => built, + ...overrides, + } as unknown as TronGateway; + + const signer: Signer = { + kind: "software", + address: OWNER, + sign: async (tx) => tx as never, + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + const signers = { assertCanSign: () => {}, resolve: () => signer } as unknown as SignerResolver; + const pipeline = new TxPipeline(signers); + const provider = { get: () => gateway } as unknown as ChainGatewayProvider; + return { + gateway, + prepareTransaction, + witness: new TronWitnessService(provider, pipeline), + proposal: new TronProposalService(provider, pipeline), + contract: new TronContractService(provider, pipeline), + }; +} + +type Call = (h: ReturnType, opts: Record) => Promise; + +const WRITES: Array<[string, Call]> = [ + ["witness update", (h, o) => h.witness.update(scope, NET, { url: "https://sr.example", ...o } as never)], + ["witness set-brokerage", (h, o) => h.witness.setBrokerage(scope, NET, { percent: 20, ...o } as never)], + ["proposal create", (h, o) => h.proposal.create(scope, NET, { set: ["getMaintenanceTimeInterval=100000"], ...o } as never)], + ["proposal approve", (h, o) => h.proposal.approve(scope, NET, { id: 7, ...o } as never)], + ["proposal delete", (h, o) => h.proposal.delete(scope, NET, { id: 7, ...o } as never)], + ["contract clear-abi", (h, o) => h.contract.clearAbi(scope, NET, { address: CONTRACT, ...o } as never)], + ["contract set-origin-energy-limit", (h, o) => h.contract.setOriginEnergyLimit(scope, NET, { address: CONTRACT, energy: "15000000", ...o } as never)], + ["contract set-user-resource-percent", (h, o) => h.contract.setUserResourcePercent(scope, NET, { address: CONTRACT, percent: 60, ...o } as never)], +]; + +describe("governance writes accept --permission-id through the real pipeline", () => { + it.each(WRITES)("`%s` builds with --permission-id 2", async (_label, call) => { + const h = harness(); + const result = await call(h, { permissionId: 2, buildOnly: true }) as { hex?: string }; + expect(result.hex).toBe("0a02deadbeef"); + expect(h.prepareTransaction).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ permissionId: 2 })); + }); + + // The ninth write; it burns 9999 TRX so it needs a gateway that reports "not yet a witness". + it("`witness create` builds with --permission-id 2", async () => { + const h = harness({ getWitness: async () => null }); + const result = await h.witness.create( + scope, NET, { url: "https://sr.example", permissionId: 2, buildOnly: true } as never, + ) as { hex?: string }; + expect(result.hex).toBe("0a02deadbeef"); + }); +}); + +describe("governance writes accept --expiration through the real pipeline", () => { + it.each(WRITES)("`%s` builds with --expiration 60000", async (_label, call) => { + const h = harness(); + const result = await call(h, { expiration: 60_000, buildOnly: true }) as { hex?: string }; + expect(result.hex).toBe("0a02deadbeef"); + expect(h.prepareTransaction).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ expiration: 60_000 })); + }); +}); + +/** + * `--expiration` must mean the same thing on every command that offers it: the transaction expires + * that many milliseconds after its own timestamp. `tx send`, `contract send` and `contract deploy` + * all get that from the pipeline's `prepare` hook. + * + * The governance group instead extended the node's default window, so the same flag produced a + * different window here (node default + N, i.e. ~60s more than asked) and the result depended on + * whichever block timestamp the node happened to serve — the opposite of what an offline multi-sig + * window needs. These tests pin the shared meaning; they run the real adapter so the transaction + * bytes are the ones a node would receive. + */ +describe("--expiration means the same on governance as everywhere else", () => { + // Anchored to the present: tronweb's extendExpiration refuses a window that has already passed, + // which would fail these tests for a reason that has nothing to do with the semantics they pin. + const NOW = Date.now(); + + async function realGatewayHarness() { + const { TronRpcClient } = await import("../../../adapters/outbound/chain/tron/tron.js"); + const rpc = new TronRpcClient("https://node.invalid", 1000); + rpc.tronweb.trx.getCurrentRefBlockParams = (async () => ({ + ref_block_bytes: "4b6b", + ref_block_hash: "4ad4875499feb0de", + expiration: NOW + 60_000, + timestamp: NOW, + })) as never; + + // Real transaction construction/preparation, faked reads: the expiration maths is what is + // under test, not the chain state the command happens to need. + const gateway = { + buildProposalCreate: rpc.buildProposalCreate.bind(rpc), + prepareTransaction: rpc.prepareTransaction.bind(rpc), + encodeTransactionHex: rpc.encodeTransactionHex.bind(rpc), + getChainParameters: async () => [{ key: "getMaintenanceTimeInterval", value: 1_800_000 }], + getWitnesses: async () => [{ address: OWNER, voteCount: "1", url: "u" }], + getWitness: async () => ({ address: OWNER, voteCount: "1", url: "u" }), + } as unknown as TronGateway; + + const signer: Signer = { + kind: "software", address: OWNER, + sign: async (tx) => tx as never, + signMessage: async () => "", signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + const signers = { assertCanSign: () => {}, resolve: () => signer } as unknown as SignerResolver; + const provider = { get: () => gateway } as unknown as ChainGatewayProvider; + return new TronProposalService(provider, new TxPipeline(signers)); + } + + it.each([ + ["60 seconds", 60_000], + ["24 hours", 86_400_000], + ])("`proposal create --expiration` expires that long after the transaction timestamp: %s", async (_l, ms) => { + const service = await realGatewayHarness(); + const out = await service.create(scope, NET, { + set: ["getMaintenanceTimeInterval=100000"], expiration: ms, buildOnly: true, + } as never) as unknown as { tx: { raw_data: { timestamp: number; expiration: number } } }; + + expect(out.tx.raw_data.expiration - out.tx.raw_data.timestamp).toBe(ms); + }); +}); diff --git a/ts/src/application/use-cases/tron/governance-transaction.ts b/ts/src/application/use-cases/tron/governance-transaction.ts index d69240c9e..687182b2f 100644 --- a/ts/src/application/use-cases/tron/governance-transaction.ts +++ b/ts/src/application/use-cases/tron/governance-transaction.ts @@ -1,7 +1,5 @@ -import type { UnsignedTx } from "../../../domain/types/index.js"; import { UsageError } from "../../../domain/errors/index.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; -import type { TronGateway } from "../../ports/chain/tron-gateway.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; import { transactionMode, @@ -29,30 +27,6 @@ export function governanceTransactionMode( return mode; } -/** - * The hook `--build-only` and `--sign-only` need: complete transaction hex. Without it the pipeline - * refuses build-only outright ("this chain adapter cannot produce transaction hex") and omits `hex` - * from sign-only, which is the whole point of both flags. - * - * Only `artifact` — deliberately NOT the full `tronTransactionHooks`. This group binds - * `--permission-id` inside each builder and applies `--expiration` via `withExtendedExpiration` - * before the pipeline sees the transaction, so also supplying `prepare` would rebind Permission_id - * and extend the expiration a SECOND time. Unifying on `prepare` is a separate refactor. - */ -export function governanceArtifact(gateway: TronGateway) { - return { artifact: (transaction: UnsignedTx) => gateway.encodeTransactionHex(transaction) }; -} - -export async function withExtendedExpiration( - gateway: TronGateway, - transaction: UnsignedTx, - extensionMs: number | undefined, -): Promise { - return extensionMs === undefined - ? transaction - : await gateway.extendTransactionExpiration(transaction, extensionMs); -} - /** Canonical nested resource view required by governance JSON receipts. */ export function transactionResource(data: Readonly>): Record | undefined { const resource = { diff --git a/ts/src/application/use-cases/tron/proposal-service.test.ts b/ts/src/application/use-cases/tron/proposal-service.test.ts index 51f8ed343..c224ae4ba 100644 --- a/ts/src/application/use-cases/tron/proposal-service.test.ts +++ b/ts/src/application/use-cases/tron/proposal-service.test.ts @@ -22,14 +22,16 @@ const scope: TransactionScope = { function createService(gateway: Partial, run?: (params: TxPipelineParams) => Promise) { const concrete = gateway as TronGateway; const gateways = { get: () => concrete } as unknown as ChainGatewayProvider; + const captured: TxPipelineParams[] = []; const pipeline = { assertCanSign: vi.fn(), run: run ?? (async (params: TxPipelineParams) => { + captured.push(params); await params.build(OWNER); return { stage: "submitted", txId: "tx-proposal" } as never; }), } as unknown as TxPipeline; - return { service: new TronProposalService(gateways, pipeline), pipeline }; + return { service: new TronProposalService(gateways, pipeline), pipeline, captured }; } describe("TronProposalService", () => { @@ -54,8 +56,7 @@ describe("TronProposalService", () => { it("maps --cancel to Java is_add_approval=false and preserves permission/expiration", async () => { const build = vi.fn(async () => ({ raw_data: { contract: [{ type: "ProposalApproveContract" }] } })); - const extend = vi.fn(async (tx) => ({ ...tx as object, extended: true })); - const { service } = createService({ + const { service, captured } = createService({ getProposal: async () => ({ id: 47, proposerAddress: OTHER, @@ -68,7 +69,6 @@ describe("TronProposalService", () => { getWitness: async () => ({ address: OWNER, voteCount: "1" }), getWitnesses: async () => Array.from({ length: 27 }, () => ({ address: OTHER, voteCount: "1" })), buildProposalApprove: build, - extendTransactionExpiration: extend, }); await expect(service.approve(scope, NET, { @@ -79,7 +79,8 @@ describe("TronProposalService", () => { signOnly: true, })).resolves.toMatchObject({ addApproval: false, approvals: 0, approvalThreshold: 18 }); expect(build).toHaveBeenCalledWith(OWNER, 47, false, { permissionId: 2 }); - expect(extend).toHaveBeenCalledWith(expect.anything(), 120_000); + expect(captured[0]).toMatchObject({ permissionId: 2, expiration: 120_000 }); + expect(typeof captured[0]!.prepare).toBe("function"); }); it("rejects a non-witness before proposal creation is built", async () => { diff --git a/ts/src/application/use-cases/tron/proposal-service.ts b/ts/src/application/use-cases/tron/proposal-service.ts index 494657160..5817c26ff 100644 --- a/ts/src/application/use-cases/tron/proposal-service.ts +++ b/ts/src/application/use-cases/tron/proposal-service.ts @@ -11,11 +11,10 @@ import type { TronGateway, TronProposal } from "../../ports/chain/tron-gateway.j import type { TxPipeline } from "../../services/pipeline/index.js"; import { outcomeData } from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; import { - governanceArtifact, governanceTransactionMode, transactionResource, - withExtendedExpiration, type GovernanceTransactionInput, } from "./governance-transaction.js"; @@ -97,16 +96,12 @@ export class TronProposalService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), - ...governanceArtifact(gateway), - build: async (address) => withExtendedExpiration( - gateway, - await gateway.buildProposalCreate( + ...tronTransactionHooks(gateway), + build: async (address) => gateway.buildProposalCreate( address, changes.map((change) => ({ key: change.id, value: change.proposedValue })), { permissionId: input.permissionId }, ), - input.expiration, - ), estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "proposal creation uses bandwidth only" }), }); const data = outcomeData(outcome); @@ -148,12 +143,8 @@ export class TronProposalService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), - ...governanceArtifact(gateway), - build: async (address) => withExtendedExpiration( - gateway, - await gateway.buildProposalApprove(address, input.id, addApproval, { permissionId: input.permissionId }), - input.expiration, - ), + ...tronTransactionHooks(gateway), + build: async (address) => gateway.buildProposalApprove(address, input.id, addApproval, { permissionId: input.permissionId }), estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "proposal approval uses bandwidth only" }), }); const data = outcomeData(outcome); @@ -191,12 +182,8 @@ export class TronProposalService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), - ...governanceArtifact(gateway), - build: async (address) => withExtendedExpiration( - gateway, - await gateway.buildProposalDelete(address, input.id, { permissionId: input.permissionId }), - input.expiration, - ), + ...tronTransactionHooks(gateway), + build: async (address) => gateway.buildProposalDelete(address, input.id, { permissionId: input.permissionId }), estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "proposal deletion uses bandwidth only" }), }); const data = outcomeData(outcome); diff --git a/ts/src/application/use-cases/tron/witness-service.ts b/ts/src/application/use-cases/tron/witness-service.ts index e6323dffe..d2193b76c 100644 --- a/ts/src/application/use-cases/tron/witness-service.ts +++ b/ts/src/application/use-cases/tron/witness-service.ts @@ -6,11 +6,10 @@ import type { TronGateway } from "../../ports/chain/tron-gateway.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; import { outcomeData } from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; import { - governanceArtifact, governanceTransactionMode, transactionResource, - withExtendedExpiration, type GovernanceTransactionInput, } from "./governance-transaction.js"; @@ -73,12 +72,8 @@ export class TronWitnessService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), - ...governanceArtifact(gateway), - build: async (address) => withExtendedExpiration( - gateway, - await gateway.buildWitnessCreate(address, input.url, { permissionId: input.permissionId }), - input.expiration, - ), + ...tronTransactionHooks(gateway), + build: async (address) => gateway.buildWitnessCreate(address, input.url, { permissionId: input.permissionId }), estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", feeSun: registrationFeeSun.toString(), @@ -106,12 +101,8 @@ export class TronWitnessService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), - ...governanceArtifact(gateway), - build: async (address) => withExtendedExpiration( - gateway, - await gateway.buildWitnessUpdate(address, input.url, { permissionId: input.permissionId }), - input.expiration, - ), + ...tronTransactionHooks(gateway), + build: async (address) => gateway.buildWitnessUpdate(address, input.url, { permissionId: input.permissionId }), estimate: bandwidthEstimate, }); return witnessReceipt("witness-update", outcomeData(outcome), owner, { url: input.url }); @@ -129,12 +120,8 @@ export class TronWitnessService { broadcaster: gateway, ...mode, confirm: tronConfirmation(gateway, scope), - ...governanceArtifact(gateway), - build: async (address) => withExtendedExpiration( - gateway, - await gateway.buildWitnessSetBrokerage(address, input.percent, { permissionId: input.permissionId }), - input.expiration, - ), + ...tronTransactionHooks(gateway), + build: async (address) => gateway.buildWitnessSetBrokerage(address, input.percent, { permissionId: input.permissionId }), estimate: bandwidthEstimate, }); return witnessReceipt("witness-set-brokerage", outcomeData(outcome), owner, { brokerage: input.percent }); From 48cdafd306f0a6129600382f092b5e0b6782f7f6 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Fri, 14 Aug 2026 01:54:32 +0800 Subject: [PATCH 11/15] fix(ts): read TRC10, exchange and transaction-info losslessly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `asset info 1002438` reported a total supply of 666666666666666600. The node had said 666666666666666666. Sampling 1,400 mainnet assets: 252 carry a supply above 2^53, 10 rendered a wrong figure, and six of those printed 9223372036854776000 — larger than int64 permits, so a value the chain cannot hold and no asset can have. total_supply and frozen_amount are protocol int64. These endpoints were the last ones still reaching the node through tronweb, whose HTTP provider parses the body with a plain JSON.parse: the value was already a rounded float64 before this class saw it, so the String() the service applies downstream can only stringify damage that has already happened. The port then declared that damage (`total_supply: number`), which is why the compiler had nothing to say. The repository already solved this — getAccount, getBlock, listwitnesses and listproposals fetch and run parseLosslessJson. The TRC10 and exchange reads simply never followed. This brings the remaining seven into line: /wallet/getassetissuebyid /wallet/getexchangebyid /wallet/getassetissuebyaccount /wallet/getpaginatedexchangelist /wallet/getassetissuelistbyname /wallet/gettransactioninfobyid /wallet/getpaginatedassetissuelist tronweb is otherwise untouched — it still builds every transaction, converts addresses and serialises protobuf. What it also did for these calls, we now do: decoding the hex text fields and handling each response shape. Those shapes were checked against mainnet rather than assumed, which is what the new tests encode. The read and write models of a frozen tranche split, because they genuinely differ. Reading carries the int64 exactly, as a decimal string. Issuance keeps `number`: java-tron rebuilds the contract from raw_data json on the non-visible broadcast path, and a numeric *string* does not parse into an int64 field there. So this client can now report a supply above 2^53 correctly while still refusing to issue one — an asymmetry that is the protocol's, and is now deliberate rather than accidental. Realised amounts in a --wait receipt (unfreeze_amount, withdraw_amount and the three exchange_* quantities) become exact for the same reason: on a high-supply TRC10 they exceed 2^53. Fees and resource counters deliberately stay numbers — 2^53 sun is nine billion TRX, so widening them would change the machine contract of every --wait receipt and buy nothing. Exchange reserves are included even though no mainnet pair is near the boundary today (0 of 252 balances sampled). They are the one quantity here that is not merely displayed: proportionalOther and bancorOutput compute the amounts that get signed into inject/withdraw/trade from them. The CLI's json contract is unchanged throughout: totalSupply was already emitted as a string. Only the value is now the one the chain reports. before: "totalSupply": "666666666666666600" after: "totalSupply": "666666666666666666" --- .../chain/tron/asset-response.test.ts | 66 +++++++++ .../chain/tron/tron.asset-reads.test.ts | 122 +++++++++++++++++ ts/src/adapters/outbound/chain/tron/tron.ts | 129 ++++++++++++++---- .../chain/tron/tron.tx-info-lossless.test.ts | 62 +++++++++ .../application/ports/chain/tron-gateway.ts | 21 ++- .../use-cases/tron/asset-service.test.ts | 12 +- .../use-cases/tron/exchange-service.test.ts | 2 +- 7 files changed, 380 insertions(+), 34 deletions(-) create mode 100644 ts/src/adapters/outbound/chain/tron/asset-response.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/tron.asset-reads.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/tron.tx-info-lossless.test.ts diff --git a/ts/src/adapters/outbound/chain/tron/asset-response.test.ts b/ts/src/adapters/outbound/chain/tron/asset-response.test.ts new file mode 100644 index 000000000..4b70138cf --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/asset-response.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { parseTronAssetResponse } from "./tron.js"; + +/** + * TRC10 supply and frozen tranches are protocol int64. They used to arrive through tronweb, whose + * HTTP provider parses the body with a plain `JSON.parse`, so anything above 2^53 was rounded to the + * nearest float64 before this code ever saw it — and the port then declared the damage by typing the + * field `number`. On mainnet that is not theoretical: of 1,400 assets sampled, 252 carry a supply + * above 2^53 and 10 rendered a wrong figure, six of them as 9223372036854776000 — a value larger + * than int64 allows, i.e. one the chain cannot possibly hold. + * + * The account and block endpoints already avoided this by parsing losslessly; these tests hold the + * asset endpoints to the same standard. + */ +describe("parseTronAssetResponse", () => { + it("keeps an int64 total supply exact instead of rounding it to a float64", () => { + const asset = parseTronAssetResponse(`{ + "id": "1002438", + "total_supply": 666666666666666666, + "frozen_supply": [{ "frozen_amount": 899999999999999999, "frozen_days": 30 }] + }`); + + expect(asset.total_supply).toBe("666666666666666666"); + expect(asset.frozen_supply?.[0]?.frozen_amount).toBe("899999999999999999"); + }); + + it("keeps int64 max exact — the value that rendered as an impossible 9223372036854776000", () => { + expect(parseTronAssetResponse('{"total_supply": 9223372036854775807}').total_supply) + .toBe("9223372036854775807"); + }); + + it("normalizes safe quantities to strings too, so the read model has one stable shape", () => { + const asset = parseTronAssetResponse('{"total_supply": 1000000, "frozen_supply": [{"frozen_amount": 2, "frozen_days": 1}]}'); + expect(asset.total_supply).toBe("1000000"); + expect(asset.frozen_supply?.[0]?.frozen_amount).toBe("2"); + }); + + // tronweb decoded these; asking the node for `visible: false` keeps addresses in the hex form + // tronHexToBase58 expects, at the cost of owning the text decoding here. + it("decodes the node's hex-encoded text fields", () => { + const asset = parseTronAssetResponse( + '{"name": "4556494c", "abbr": "4556", "url": "782e636f6d", "description": "64"}', + ); + + expect(asset.name).toBe("EVIL"); + expect(asset.abbr).toBe("EV"); + expect(asset.url).toBe("x.com"); + expect(asset.description).toBe("d"); + }); + + it("leaves the rate pair, precision and window as numbers — they are int32/timestamps", () => { + const asset = parseTronAssetResponse( + '{"trx_num": 1000000, "num": 666, "precision": 6, "start_time": 1558134600062, "end_time": 1589755860062}', + ); + + expect(asset.trx_num).toBe(1_000_000); + expect(asset.num).toBe(666); + expect(asset.precision).toBe(6); + expect(asset.start_time).toBe(1_558_134_600_062); + }); + + it("keeps the owner address in the hex form the address helper expects", () => { + expect(parseTronAssetResponse('{"owner_address": "418225f3aa48a2d30643a64410abb1e914dfa0bd2f"}').owner_address) + .toBe("418225f3aa48a2d30643a64410abb1e914dfa0bd2f"); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.asset-reads.test.ts b/ts/src/adapters/outbound/chain/tron/tron.asset-reads.test.ts new file mode 100644 index 000000000..0f8f9bede --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/tron.asset-reads.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TronRpcClient } from "./tron.js"; + +/** + * The TRC10 and exchange reads were the last endpoints still going through tronweb, whose HTTP + * provider parses with a plain `JSON.parse` — so a protocol int64 was already a rounded float64 by + * the time it reached this class, and no amount of `String(...)` downstream could recover it. + * + * These tests pin the whole read: the request the node receives, and the exactness of what comes + * back. `fetch` is stubbed rather than a live node dialled, so the byte-level JSON (which is the + * entire point) is under the test's control. + */ +const OWNER_HEX = "418225f3aa48a2d30643a64410abb1e914dfa0bd2f"; +const OWNER = "TMqNJwD3qVmuRxzzP3Q4A24fuByVBKQ39E"; + +function nodeReturns(body: string) { + const calls: Array<{ url: string; body: unknown }> = []; + vi.stubGlobal("fetch", vi.fn(async (url: string, init: { body: string }) => { + calls.push({ url, body: JSON.parse(init.body) }); + return { ok: true, status: 200, text: async () => body } as never; + })); + return calls; +} + +afterEach(() => vi.unstubAllGlobals()); + +const client = () => new TronRpcClient("https://node.invalid", 1000); + +const ASSET = `{ + "id": "1002438", + "owner_address": "${OWNER_HEX}", + "name": "4556494c", + "total_supply": 666666666666666666, + "trx_num": 1000000, + "num": 666, + "precision": 6, + "start_time": 1558134600062, + "end_time": 1589755860062, + "frozen_supply": [{ "frozen_amount": 899999999999999999, "frozen_days": 30 }] +}`; + +describe("TRC10 reads carry int64 quantities exactly", () => { + it("getAssetById returns the supply the node actually sent", async () => { + const calls = nodeReturns(ASSET); + const asset = await client().getAssetById("1002438"); + + expect(asset?.total_supply).toBe("666666666666666666"); + expect(asset?.frozen_supply?.[0]?.frozen_amount).toBe("899999999999999999"); + expect(asset?.name).toBe("EVIL"); + expect(calls[0]!.url).toContain("/wallet/getassetissuebyid"); + expect(calls[0]!.body).toMatchObject({ value: "1002438" }); + }); + + it("getAssetByIssuer returns the supply the node actually sent", async () => { + nodeReturns(`{"assetIssue": [${ASSET}]}`); + const asset = await client().getAssetByIssuer(OWNER); + expect(asset?.total_supply).toBe("666666666666666666"); + }); + + it("getAssetsByName returns every match with exact supplies", async () => { + nodeReturns(`{"assetIssue": [${ASSET}, ${ASSET}]}`); + const assets = await client().getAssetsByName("EVIL"); + + expect(assets).toHaveLength(2); + expect(assets[0]!.total_supply).toBe("666666666666666666"); + }); + + it("listAssets returns a page with exact supplies", async () => { + const calls = nodeReturns(`{"assetIssue": [${ASSET}]}`); + const assets = await client().listAssets(10, 20); + + expect(assets[0]!.total_supply).toBe("666666666666666666"); + expect(calls[0]!.body).toMatchObject({ limit: 10, offset: 20 }); + }); + + // An unknown id is an empty answer, not a fault: the caller maps absence to asset_not_found. + it("reports an unknown id as absent rather than inventing an asset", async () => { + nodeReturns("{}"); + expect(await client().getAssetById("999")).toBeUndefined(); + }); + + it("reports no match for a name as an empty list", async () => { + nodeReturns("{}"); + expect(await client().getAssetsByName("nope")).toEqual([]); + }); +}); + +describe("exchange reads carry int64 reserves exactly", () => { + // Reserves are not merely displayed: they drive proportionalOther/bancorOutput, whose results are + // the quantities signed into inject/withdraw/trade. + const PAIR = `{ + "exchange_id": 12, + "creator_address": "${OWNER_HEX}", + "create_time": 1558134600062, + "first_token_id": "5f", + "first_token_balance": 9007199254740993, + "second_token_id": "31303035303338", + "second_token_balance": 900000000000000001 + }`; + + it("getExchangeById keeps both reserves exact", async () => { + const calls = nodeReturns(PAIR); + const pair = await client().getExchangeById(12); + + expect(pair?.firstTokenBalance).toBe("9007199254740993"); + expect(pair?.secondTokenBalance).toBe("900000000000000001"); + expect(pair?.firstTokenId).toBe("_"); + expect(pair?.secondTokenId).toBe("1005038"); + expect(calls[0]!.body).toMatchObject({ id: 12 }); + }); + + it("listExchanges keeps reserves exact", async () => { + nodeReturns(`{"exchanges": [${PAIR}]}`); + const pairs = await client().listExchanges(10, 0); + expect(pairs[0]!.secondTokenBalance).toBe("900000000000000001"); + }); + + it("reports an unknown pair as absent", async () => { + nodeReturns("{}"); + expect(await client().getExchangeById(999)).toBeUndefined(); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 1a4faedfc..dcd84f776 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -385,6 +385,18 @@ export class TronRpcClient implements TronGateway, Broadcaster { } // ── account / query ────────────────────────────────────────────────────────── + /** node POST returning the raw body, so the caller can parse it losslessly. */ + async #post(path: string, body: unknown): Promise { + const response = await fetch(`${this.#fullHost}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(this.#timeoutMs), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return await response.text(); + } + async getAccount(address: string): Promise { return this.#wrap("getAccount", async () => { const response = await fetch(`${this.#fullHost}/wallet/getaccount`, { @@ -436,7 +448,10 @@ export class TronRpcClient implements TronGateway, Broadcaster { // Full-node (unconfirmed) info: available ~one block after inclusion (~3s), not after // solidification (~19 blocks / ~60s). So `--wait` confirms at "mined in a block" rather than // "irreversible" — the receipt (fee/energy/result) is already final by then. Same response shape. - return this.#wrap("getTransactionInfo", async () => parseTronTxInfo(await this.#tw.trx.getUnconfirmedTransactionInfo(txid))); + return this.#wrap("getTransactionInfo", async () => + parseTronTxInfo(normalizeTxInfoValue(parseLosslessJson( + await this.#post("/wallet/gettransactioninfobyid", { value: txid }), + )))); } decodeTransaction(transaction: TronTx): DecodedTronTransaction { return decodeTronTransaction(transaction); @@ -524,38 +539,33 @@ export class TronRpcClient implements TronGateway, Broadcaster { // ── TRC10 assets ─────────────────────────────────────────────────────────────── async getAssetById(assetId: string): Promise { return this.#wrap("asset by id", async () => { - // getTokenByID throws a plain Error for an unknown id; absence is a result, not a fault. - try { - return await this.#tw.trx.getTokenByID(assetId) as unknown as TronAsset; - } catch { - return undefined; - } + // an unknown id comes back as an empty object; absence is a result, not a fault. + const raw = parseTronAssetResponse(await this.#post("/wallet/getassetissuebyid", { value: assetId })); + return raw.owner_address === undefined ? undefined : raw; }); } async getAssetsByName(name: string): Promise { - return this.#wrap("assets by name", async () => { - try { - const found = await this.#tw.trx.getTokenListByName(name); - return (Array.isArray(found) ? found : [found]) as unknown as TronAsset[]; - } catch { - return []; - } - }); + return this.#wrap("assets by name", async () => + assetList(await this.#post("/wallet/getassetissuelistbyname", { + value: Buffer.from(name, "utf8").toString("hex"), + })), + ); } async getAssetByIssuer(address: string): Promise { return this.#wrap("asset by issuer", async () => { - const issued = await this.#tw.trx.getTokensIssuedByAddress(address); - // keyed by token name; an account may issue at most one asset, so there is at most one entry. - return Object.values(issued ?? {})[0] as unknown as TronAsset | undefined; + // an account may issue at most one asset, so there is at most one entry. + const issued = assetList(await this.#post("/wallet/getassetissuebyaccount", { + address: this.#tw.address.toHex(address), + })); + return issued[0]; }); } async listAssets(limit: number, offset: number): Promise { return this.#wrap("list assets", async () => - // limit is always > 0 here, so tronweb takes the paginated endpoint, never the full dump. - await this.#tw.trx.listTokens(limit, offset) as unknown as TronAsset[], + assetList(await this.#post("/wallet/getpaginatedassetissuelist", { offset, limit })), ); } @@ -659,17 +669,18 @@ export class TronRpcClient implements TronGateway, Broadcaster { async getExchangeById(exchangeId: number): Promise { return this.#wrap("exchange by id", async () => { - const found = await this.#tw.trx.getExchangeByID(exchangeId) as unknown as Record; + const found = exchangeValue(await this.#post("/wallet/getexchangebyid", { id: exchangeId })); // an unknown id comes back as an empty object rather than an error - if (!found || found.exchange_id === undefined) return undefined; + if (found.exchange_id === undefined) return undefined; return this.#toExchange(found); }); } async listExchanges(limit: number, offset: number): Promise { return this.#wrap("list exchanges", async () => { - const page = await this.#tw.trx.listExchangesPaginated(limit, offset) as unknown as Array>; - return (page ?? []).map((raw) => this.#toExchange(raw)); + const raw = exchangeValue(await this.#post("/wallet/getpaginatedexchangelist", { offset, limit })); + const page = Array.isArray(raw.exchanges) ? raw.exchanges : []; + return page.map((entry) => this.#toExchange(entry as Record)); }); } @@ -1322,6 +1333,76 @@ function normalizeProposal(value: unknown): TronProposal | null { }; } +/** + * Parse TRC10 asset JSON without coercing its int64 quantities through JS number. + * + * Asked with `visible: false`, so `owner_address` keeps the hex form `tronHexToBase58` expects; + * the cost is that the node's text fields arrive hex-encoded and are decoded here — the one piece + * of tronweb's `_parseToken` worth keeping. + */ +export function parseTronAssetResponse(text: string): TronAsset { + return decodeAssetText(normalizeAccountValue(parseLosslessJson(text))) as TronAsset; +} + +const ASSET_TEXT_KEYS = new Set(["name", "abbr", "url", "description"]); + +function decodeAssetText(value: unknown): unknown { + if (Array.isArray(value)) return value.map(decodeAssetText); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + ASSET_TEXT_KEYS.has(key) && typeof entry === "string" ? hexToUtf8(entry) : decodeAssetText(entry), + ]), + ); +} + +/** node text fields are hex; anything else is passed through rather than mangled. */ +function hexToUtf8(value: string): string { + if (value === "" || !/^([0-9a-fA-F]{2})+$/.test(value)) return value; + return Buffer.from(value, "hex").toString("utf8"); +} + +/** + * Amounts the chain reports having actually moved. Kept exact because a high-supply TRC10 puts + * them above 2^53; fee and resource counters are deliberately absent, so `--wait` receipts keep + * reporting those as numbers. + */ +const REALISED_AMOUNT_KEYS = new Set([ + "unfreeze_amount", + "withdraw_amount", + "exchange_received_amount", + "exchange_inject_another_amount", + "exchange_withdraw_another_amount", +]); + +function normalizeTxInfoValue(value: unknown, key?: string): unknown { + if (isLosslessNumber(value)) { + const exact = value.toString(); + if (key && REALISED_AMOUNT_KEYS.has(key)) return exact; + const number = Number(exact); + return Number.isSafeInteger(number) ? number : exact; + } + if (Array.isArray(value)) return value.map((entry) => normalizeTxInfoValue(entry)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([entryKey, entry]) => [entryKey, normalizeTxInfoValue(entry, entryKey)]), + ); + } + return value; +} + +/** `{assetIssue: [...]}` — the shape every list-ish TRC10 endpoint answers with. */ +function assetList(text: string): TronAsset[] { + const raw = parseTronAssetResponse(text) as unknown as { assetIssue?: unknown }; + return Array.isArray(raw.assetIssue) ? raw.assetIssue as TronAsset[] : []; +} + +/** exchange records need no text decoding — #toExchange already decodes their token ids. */ +function exchangeValue(text: string): Record { + return normalizeAccountValue(parseLosslessJson(text)) as Record; +} + /** Parse node account JSON without first coercing 64-bit quantities through JS number. */ export function parseTronAccountResponse(text: string): TronAccount { return normalizeAccountValue(parseLosslessJson(text)) as TronAccount; diff --git a/ts/src/adapters/outbound/chain/tron/tron.tx-info-lossless.test.ts b/ts/src/adapters/outbound/chain/tron/tron.tx-info-lossless.test.ts new file mode 100644 index 000000000..639f517dc --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/tron.tx-info-lossless.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TronRpcClient } from "./tron.js"; + +/** + * `--wait` receipts report what the chain actually did. The realised quantities among them are + * protocol int64 and, on a high-supply TRC10, genuinely large: the amount released by + * `asset unfreeze` or returned by an `exchange trade` can exceed 2^53 even though a fee never + * will. They reached us through tronweb's plain `JSON.parse`, so those were rounded before any + * downstream `String(...)` could preserve them. + * + * Fees and resource counters deliberately stay numbers: 2^53 sun is nine billion TRX, so widening + * them would change the machine contract of every `--wait` receipt to buy nothing. + */ +function nodeReturns(body: string) { + const calls: Array<{ url: string; body: unknown }> = []; + vi.stubGlobal("fetch", vi.fn(async (url: string, init: { body: string }) => { + calls.push({ url, body: JSON.parse(init.body) }); + return { ok: true, status: 200, text: async () => body } as never; + })); + return calls; +} + +afterEach(() => vi.unstubAllGlobals()); + +const client = () => new TronRpcClient("https://node.invalid", 1000); + +describe("transaction-info realised amounts survive as exact decimal strings", () => { + it.each([ + ["unfreeze_amount", "899999999999999999"], + ["withdraw_amount", "9007199254740993"], + ["exchange_received_amount", "900000000000000001"], + ["exchange_inject_another_amount", "900000000000000003"], + ["exchange_withdraw_another_amount", "900000000000000005"], + ])("%s", async (field, exact) => { + const calls = nodeReturns(`{"blockNumber": 1, "${field}": ${exact}}`); + const info = await client().getTransactionInfoById("abc"); + + expect(String((info as Record)[field])).toBe(exact); + expect(calls[0]!.url).toContain("/wallet/gettransactioninfobyid"); + expect(calls[0]!.body).toMatchObject({ value: "abc" }); + }); + + it("leaves fee and resource counters as numbers — the machine contract is unchanged", async () => { + nodeReturns(`{ + "blockNumber": 12, + "fee": 1100000, + "receipt": { "result": "SUCCESS", "energy_usage_total": 31895, "net_usage": 345, "energy_fee": 0, "net_fee": 0 } + }`); + const info = await client().getTransactionInfoById("abc"); + + expect(info.fee).toBe(1_100_000); + expect(info.blockNumber).toBe(12); + expect(info.receipt?.energy_usage_total).toBe(31_895); + expect(info.receipt?.net_usage).toBe(345); + expect(info.receipt?.result).toBe("SUCCESS"); + }); + + it("still reports a pending transaction as having no block", async () => { + nodeReturns("{}"); + expect((await client().getTransactionInfoById("abc")).blockNumber).toBeUndefined(); + }); +}); diff --git a/ts/src/application/ports/chain/tron-gateway.ts b/ts/src/application/ports/chain/tron-gateway.ts index 9b8ec9cd8..39e2eefc7 100644 --- a/ts/src/application/ports/chain/tron-gateway.ts +++ b/ts/src/application/ports/chain/tron-gateway.ts @@ -102,12 +102,26 @@ export interface TronTokenInfo { [key: string]: unknown; } -/** one frozen tranche of a TRC10's supply, fixed at issuance. */ +/** + * One frozen tranche as ISSUANCE declares it. Quantities stay `number` on the way out: java-tron + * rebuilds the contract from `raw_data` json on the non-visible broadcast path, and a numeric + * *string* does not parse into an int64 field there — the node then validates an empty message. + * `asset issue` therefore refuses a supply above 2^53 rather than emit one it cannot broadcast. + */ export interface TronAssetTranche { frozen_amount: number; frozen_days: number; } +/** + * One frozen tranche as the chain REPORTS it. Reading has no such constraint, so the int64 is + * carried exactly, as a decimal string. + */ +export interface TronAssetTrancheView { + frozen_amount: string; + frozen_days: number; +} + /** * A TRC10 asset as the chain stores it. `name`/`abbr`/`description`/`url` arrive decoded to * UTF-8; every quantity is in the asset's minimal units. `trx_num`/`num` are the on-chain ICO @@ -120,7 +134,8 @@ export interface TronAsset { abbr?: string; description?: string; url?: string; - total_supply: number; + /** protocol int64, carried exactly as a decimal string — real assets exceed 2^53. */ + total_supply: string; trx_num: number; num: number; precision?: number; @@ -128,7 +143,7 @@ export interface TronAsset { end_time: number; free_asset_net_limit?: number; public_free_asset_net_limit?: number; - frozen_supply?: TronAssetTranche[]; + frozen_supply?: TronAssetTrancheView[]; } /** the mutable half of a TRC10, i.e. everything `asset update` may change. */ diff --git a/ts/src/application/use-cases/tron/asset-service.test.ts b/ts/src/application/use-cases/tron/asset-service.test.ts index b9f2cf5a1..79d4c0983 100644 --- a/ts/src/application/use-cases/tron/asset-service.test.ts +++ b/ts/src/application/use-cases/tron/asset-service.test.ts @@ -30,7 +30,7 @@ function asset(over: Partial = {}): TronAsset { abbr: "MTK", description: "Demo TRC10", url: "https://mytoken.io", - total_supply: 1_000_000_000_000_000, + total_supply: "1000000000000000", trx_num: 1, num: 100, precision: 6, @@ -208,8 +208,8 @@ describe("ambiguous asset names", () => { it("carries the queried name, the ids, and one comparable row per match", async () => { const svc = service({ getAssetsByName: async () => [ - asset({ id: "1000123", owner_address: OTHER_HEX, total_supply: 1_000_000_000, precision: 6 }), - asset({ id: "1000488", owner_address: OWNER_HEX, total_supply: 50_000_000, precision: 2 }), + asset({ id: "1000123", owner_address: OTHER_HEX, total_supply: "1000000000", precision: 6 }), + asset({ id: "1000488", owner_address: OWNER_HEX, total_supply: "50000000", precision: 2 }), ], }); await expect(svc.info(NET, { assetRef: "MyToken" })).rejects.toMatchObject({ @@ -233,8 +233,8 @@ describe("ambiguous asset names", () => { describe("asset unfreeze", () => { const tranches = [ - { frozen_amount: 100_000_000_000_000, frozen_days: 30 }, - { frozen_amount: 50_000_000_000_000, frozen_days: 90 }, + { frozen_amount: "100000000000000", frozen_days: 30 }, + { frozen_amount: "50000000000000", frozen_days: 90 }, ]; it("releases every matured tranche and reports what stays frozen", async () => { @@ -272,7 +272,7 @@ describe("asset info / list", () => { it("returns one asset with its ICO terms and tranche unlock times", async () => { const start = 1_800_000_000_000; const svc = service({ - getAssetById: async () => asset({ start_time: start, frozen_supply: [{ frozen_amount: 5, frozen_days: 30 }] }), + getAssetById: async () => asset({ start_time: start, frozen_supply: [{ frozen_amount: "5", frozen_days: 30 }] }), }); await expect(svc.info(NET, { assetRef: "1000123" })).resolves.toMatchObject({ assetId: "1000123", diff --git a/ts/src/application/use-cases/tron/exchange-service.test.ts b/ts/src/application/use-cases/tron/exchange-service.test.ts index 37d17ca46..16b90338c 100644 --- a/ts/src/application/use-cases/tron/exchange-service.test.ts +++ b/ts/src/application/use-cases/tron/exchange-service.test.ts @@ -41,7 +41,7 @@ const MYTOKEN: TronAsset = { name: "MyToken", precision: 6, owner_address: "4174472e7d35395a6b5add427eecb7f4b62ad2b071", - total_supply: 1_000_000_000_000_000, + total_supply: "1000000000000000", trx_num: 1, num: 100, start_time: 0, From dbfbe14db8ca64ac6051fd4ff0f2298589b09e05 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Fri, 14 Aug 2026 02:11:09 +0800 Subject: [PATCH 12/15] fix(ts): bind TRC10 and exchange reads to the id asked for, and to protocol range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ask a node for TRC10 1234 and it could answer with asset 9999 — different issuer, different rate, different ICO window — and nothing noticed. The same held for exchange pools: `#requireExchange` checked only that a record came back, then inject/withdraw/trade built against whatever id it carried. Response identity is the one claim about a by-id lookup that can be verified without trusting the node, and it was not being verified. Alongside it, two fields are constrained by the contract rather than by observation: `precision` is 0..6, and the `trx_num`/`num` rate pair is a positive int32. Reading every TRC10 on mainnet (5,192) and Nile (4,000) found zero records violating either — so refusing one is not a compatibility risk, it is a broken or dishonest node. Absence stays legal: 47.67% of mainnet assets omit `precision` entirely, which means 0, and the checks treat it that way. This closes three concrete failures, all previously reachable with a node that answers oddly: - an out-of-range `precision` reached fromBaseUnits, whose padStart then allocated a string proportional to it; - a zero/negative rate pair reached icoPriceLabel, which surfaced as `internal_error` — a code that blames this client for the node's answer; - a by-id lookup could be answered with a different object entirely. It does NOT close the case the identity check is often assumed to: a node reporting a precision that is wrong but in range. Measured, with only the node's answer varying: tx send --asset-id 1000001 --amount 1 node says precision=6 -> amount 1000000 signed node says precision=0 -> amount 1 signed Both values are legal, so no local rule rejects either, and the factor of a million survives this commit untouched. There is no invariant here to check against — unlike an id, a precision cannot be compared to anything we already know. Mitigating it means showing the raw amount before signing, or telling users to pass --raw-amount when it must be certain; neither belongs in this change, and neither should be assumed to be in place because identity now is. `getTrc10Info` is included, which matters more than the rest: `tx send --asset-id` reads its decimals there rather than from getAssetById, so it is the TRC10 signing path most users take and it would otherwise have kept the hole its neighbours just closed. Single-record reads throw; list reads drop the offending row and return the page. A list is a display surface and one poisoned record should not deny the caller the other 199 — but a name lookup filters too, because its single match can still reach a signing path. `invalid_node_response` is documented in machine-interface.md rather than left for the next audit to find, which is how the two documentation defects in this same release got there. --- ts/docs/machine-interface.md | 1 + .../chain/tron/tron.asset-reads.test.ts | 109 ++++++++++++++++++ ts/src/adapters/outbound/chain/tron/tron.ts | 72 +++++++++++- 3 files changed, 176 insertions(+), 6 deletions(-) diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index a0a81ad7e..85abd0bfa 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -145,6 +145,7 @@ Common codes at exit **1** (execution — runtime failure): | Code | Meaning | |---|---| | `rpc_error` | The TRON node rejected or failed the request | +| `invalid_node_response` | The node's answer contradicts the request or the protocol: a TRC10/exchange record whose id is not the one asked for, a `precision` outside 0..6, or a rate pair that is not a positive int32. These fields decide signed amounts, so the command stops rather than acting on them. List reads drop the offending record and keep the page | | `timeout` | Aborted waiting for network or device (`--timeout` exceeded) | | `auth_required` | Master password required but not supplied | | `auth_failed` | Wrong master password (decryption failed) | diff --git a/ts/src/adapters/outbound/chain/tron/tron.asset-reads.test.ts b/ts/src/adapters/outbound/chain/tron/tron.asset-reads.test.ts index 0f8f9bede..fea78677e 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.asset-reads.test.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.asset-reads.test.ts @@ -120,3 +120,112 @@ describe("exchange reads carry int64 reserves exactly", () => { expect(await client().getExchangeById(999)).toBeUndefined(); }); }); + +/** + * A response is only trustworthy to the extent it can be checked. Two things can be: + * + * - identity — we know which id we asked for, so a record answering with a different one is a + * mismatch no matter how self-consistent it looks; + * - protocol range — precision is 0..6 and the rate pair is a positive int32, by definition of + * the contract, not by observation. + * + * Both matter because these fields are not merely displayed: `precision` converts a human `--amount` + * into the minimal units that get SIGNED, so a node that reports 0 where the token has 6 moves the + * decimal point six places on a transaction the user is about to authorise. + * + * Scanning every TRC10 on mainnet (5,192) and Nile (4,000) found zero records violating either + * rule, so rejecting is not a compatibility risk — an out-of-range value is a broken or dishonest + * node, never a historical quirk. Absence stays legal: 47.67% of mainnet assets omit `precision` + * entirely, which means 0. + */ +describe("node responses are checked against what we asked for and what the protocol allows", () => { + const asset = (over: Record = {}) => JSON.stringify({ + id: "1002438", owner_address: OWNER_HEX, name: "4556494c", + total_supply: 100, trx_num: 1, num: 1, precision: 6, + start_time: 1, end_time: 2, ...over, + }); + + it("rejects an asset whose id is not the one requested", async () => { + nodeReturns(asset({ id: "9999" })); + await expect(client().getAssetById("1002438")).rejects.toMatchObject({ code: "invalid_node_response" }); + }); + + it.each([ + ["far out of range", 100_000], + ["negative", -1], + ["fractional", 1.5], + ["one past the ceiling", 7], + ])("rejects a precision that is %s", async (_label, precision) => { + nodeReturns(asset({ precision })); + await expect(client().getAssetById("1002438")).rejects.toMatchObject({ code: "invalid_node_response" }); + }); + + it.each([ + ["trx_num", { trx_num: 0 }], + ["num", { num: 0 }], + ["a negative rate", { trx_num: -1 }], + ])("rejects a rate pair that is not a positive int32: %s", async (_label, over) => { + nodeReturns(asset(over)); + await expect(client().getAssetById("1002438")).rejects.toMatchObject({ code: "invalid_node_response" }); + }); + + it("accepts an asset with no precision field at all — that is 47% of mainnet", async () => { + nodeReturns(asset({ precision: undefined })); + await expect(client().getAssetById("1002438")).resolves.toMatchObject({ id: "1002438" }); + }); + + it("accepts every precision the protocol allows", async () => { + for (const precision of [0, 1, 2, 3, 4, 5, 6]) { + nodeReturns(asset({ precision })); + await expect(client().getAssetById("1002438")).resolves.toMatchObject({ precision }); + } + }); + + it("rejects an exchange whose id is not the one requested", async () => { + nodeReturns('{"exchange_id": 99, "creator_address": "' + OWNER_HEX + '", "first_token_id": "5f", "first_token_balance": 1, "second_token_id": "31", "second_token_balance": 1}'); + await expect(client().getExchangeById(12)).rejects.toMatchObject({ code: "invalid_node_response" }); + }); + + // A list is a display surface: one poisoned row must not deny the other 199. + it("drops an invalid row from a list instead of failing the whole page", async () => { + nodeReturns(`{"assetIssue": [${asset()}, ${asset({ id: "2", precision: 99 })}, ${asset({ id: "3" })}]}`); + const assets = await client().listAssets(10, 0); + + expect(assets.map((a) => a.id)).toEqual(["1002438", "3"]); + }); + + it("drops an invalid match from a name lookup — it can still reach a signing path", async () => { + nodeReturns(`{"assetIssue": [${asset({ precision: 99 })}, ${asset({ id: "3" })}]}`); + expect((await client().getAssetsByName("EVIL")).map((a) => a.id)).toEqual(["3"]); + }); +}); + +/** + * `tx send --asset-id --amount 1` is the mainstream way to move a TRC10, and it reads its + * decimals from here rather than from getAssetById — so this method needs the same guarantees, or + * the most-used signing path keeps the hole the others just closed. + */ +describe("getTrc10Info is held to the same rules as the other TRC10 reads", () => { + const info = (over: Record = {}) => + JSON.stringify({ id: "1000001", name: "4556494c", abbr: "4556", trx_num: 1, num: 1, precision: 6, ...over }); + + it("keeps a valid record and its decoded text", async () => { + nodeReturns(info()); + await expect(client().getTrc10Info("1000001")).resolves.toMatchObject({ precision: 6, name: "EVIL" }); + }); + + it("rejects a record answering with a different id", async () => { + nodeReturns(info({ id: "9999" })); + await expect(client().getTrc10Info("1000001")).rejects.toMatchObject({ code: "invalid_node_response" }); + }); + + it.each([100_000, -1, 1.5, 7])("rejects precision %s, which would rescale a signed amount", async (precision) => { + nodeReturns(info({ precision })); + await expect(client().getTrc10Info("1000001")).rejects.toMatchObject({ code: "invalid_node_response" }); + }); + + it("accepts an absent precision as 0", async () => { + nodeReturns(info({ precision: undefined })); + await expect(client().getTrc10Info("1000001")).resolves.toMatchObject({ id: "1000001" }); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index dcd84f776..33d9e856e 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -509,10 +509,13 @@ export class TronRpcClient implements TronGateway, Broadcaster { return entry?.value ?? "0"; }); } + /** `tx send --asset-id` reads its decimals here, so it gets the same identity and range checks + * as every other TRC10 read — this is the signing path a user is most likely to take. */ async getTrc10Info(assetId: string): Promise { - return this.#wrap("trc10 info", async () => - await this.#tw.trx.getTokenFromID(assetId) as unknown as TronTokenInfo, - ); + return this.#wrap("trc10 info", async () => { + const raw = parseTronAssetResponse(await this.#post("/wallet/getassetissuebyid", { value: assetId })); + return assertUsableAsset(raw, assetId) as unknown as TronTokenInfo; + }); } async buildTrc20Transfer(from: string, to: string, contract: string, amount: string, feeLimit: string): Promise { @@ -541,7 +544,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { return this.#wrap("asset by id", async () => { // an unknown id comes back as an empty object; absence is a result, not a fault. const raw = parseTronAssetResponse(await this.#post("/wallet/getassetissuebyid", { value: assetId })); - return raw.owner_address === undefined ? undefined : raw; + return raw.owner_address === undefined ? undefined : assertUsableAsset(raw, assetId); }); } @@ -672,6 +675,12 @@ export class TronRpcClient implements TronGateway, Broadcaster { const found = exchangeValue(await this.#post("/wallet/getexchangebyid", { id: exchangeId })); // an unknown id comes back as an empty object rather than an error if (found.exchange_id === undefined) return undefined; + if (Number(found.exchange_id) !== exchangeId) { + throw new ChainError( + "invalid_node_response", + `asked the node for exchange ${exchangeId} and it answered with ${JSON.stringify(found.exchange_id)}`, + ); + } return this.#toExchange(found); }); } @@ -1392,10 +1401,61 @@ function normalizeTxInfoValue(value: unknown, key?: string): unknown { return value; } -/** `{assetIssue: [...]}` — the shape every list-ish TRC10 endpoint answers with. */ +/** + * What a TRC10 record must satisfy to be usable, as opposed to merely well-formed json. + * + * `precision` is 0..6 and the rate pair is a positive int32 by definition of the contract — every + * TRC10 on mainnet (5,192) and Nile (4,000) complies, so a violation is a broken or dishonest node + * rather than a historical quirk. It is worth refusing because `precision` converts a human + * `--amount` into the minimal units that get signed: reporting 0 where the token carries 6 moves + * the decimal point six places on a transaction the user is about to authorise. + * + * An ABSENT precision is legal and means 0 — 47% of mainnet assets omit the field. + */ +const TRC10_MAX_PRECISION = 6; +const INT32_MAX = 2_147_483_647; + +function assetViolation(asset: TronAsset): string | undefined { + const precision = asset.precision; + if (precision !== undefined && (!Number.isInteger(precision) || precision < 0 || precision > TRC10_MAX_PRECISION)) { + return `precision ${JSON.stringify(precision)} is outside the protocol range 0..${TRC10_MAX_PRECISION}`; + } + for (const field of ["trx_num", "num"] as const) { + const value = asset[field]; + if (!Number.isInteger(value) || value <= 0 || value > INT32_MAX) { + return `${field} ${JSON.stringify(value)} is not a positive int32`; + } + } + return undefined; +} + +function assertUsableAsset(asset: TronAsset, requestedId?: string): TronAsset { + // We know which id we asked for, so a record answering with another one is a mismatch however + // self-consistent it looks — the one identity claim that can be checked without trusting the node. + if (requestedId !== undefined && String(asset.id) !== requestedId) { + throw new ChainError( + "invalid_node_response", + `asked the node for TRC10 ${requestedId} and it answered with ${JSON.stringify(asset.id)}`, + ); + } + const violation = assetViolation(asset); + if (violation) { + throw new ChainError("invalid_node_response", `TRC10 ${String(asset.id)}: ${violation}`); + } + return asset; +} + +/** + * `{assetIssue: [...]}` — the shape every list-ish TRC10 endpoint answers with. + * + * Unusable rows are dropped rather than thrown on: a list is a display surface, and one poisoned + * record must not deny the caller the other 199. A name lookup filters the same way because its + * single match can still reach a signing path. + */ function assetList(text: string): TronAsset[] { const raw = parseTronAssetResponse(text) as unknown as { assetIssue?: unknown }; - return Array.isArray(raw.assetIssue) ? raw.assetIssue as TronAsset[] : []; + const assets = Array.isArray(raw.assetIssue) ? raw.assetIssue as TronAsset[] : []; + return assets.filter((asset) => assetViolation(asset) === undefined); } /** exchange records need no text decoding — #toExchange already decodes their token ids. */ From fd7bcc647af28b8b62ac177a0207fa62f9cb67f5 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Fri, 14 Aug 2026 10:21:18 +0800 Subject: [PATCH 13/15] fix(ts): close four small paths where a wrong answer looked like a right one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four unrelated defects, each one where the code produced something plausible instead of stopping. **A keystore could authenticate any password.** The Web3 MAC is keccak(dk[16:32] || ciphertext), so it binds to the password only through that slice. A file declaring `dklen: 16` leaves the slice EMPTY and the MAC collapses to keccak(ciphertext) — a constant its author picks. Every password then passes, decrypts to different 32 random bytes, and that is a well-formed private key: import reports success and the user holds an address nobody knows the key to. Reproduced with three different passwords against one file, yielding three different addresses, on both accepted KDFs. Refusing dklen < 32 is not a divergence from the Java implementation this codec claims parity with — it is catching up. Java's pbkdf2 path ignores `dklen` and always derives 32 bytes; its scrypt path would throw copying a 16-byte slice. Both are safe; we were not. The header comment promising "anything it can open, we can open" now records the exception. **The export audit log destroyed itself on schema drift.** An append is a whole-file rewrite, and any legal json that was not the shape we expected read as "no records" — so the next export silently overwrote it. The likely trigger is not tampering but us: a future build writing `version: 2`, opened once by this one, loses the history. Unreadable content is now set aside as `.unreadable-` before anything is written. Export keeps working, because refusing to back up when the log is unreadable helps nobody, but the original bytes survive. Syntactically broken json already failed closed and still does; that asymmetry was the tell. The test that pinned the old behaviour asserted the data loss as a feature ("treats a file with no usable records array as empty"), so it is replaced rather than extended. **A committed secret could go unreported.** Backup writes the file, then records the export. If the second step throws, the first has already happened — and the command reported a plain failure, so the caller learned neither that a secret existed nor where. Without `--out` that is a timestamped file in the process's working directory, which nobody can guess, and retrying writes a second copy. It stays a failure, because the export did not fully succeed, but `audit_append_failed` now carries `details.out` so the file can be found and shredded. **An impossible ICO time became a different, valid one.** The parser took any two digits per component, let `Date.UTC` roll them over, then checked only the resulting DATE — which catches `24:00:00` (it lands on the next day) but not `12:60:00`, silently read as 13:00:00. Issuance burns a fee, an account may only ever issue once, and the window is fixed for the life of the token, so comparing the whole instant is the least this deserves. Documentation, in the same spirit of not implying more certainty than exists: - `--amount` is scaled by decimals the NODE supplies. The range checks added earlier reject a TRC10 precision outside 0..6 and a record answering for another id, but a wrong value INSIDE that range cannot be detected locally — there is nothing to compare it against. `tx send` and the exchange guide now say so, and point at the `--raw-*` flags for when it must be exact. - `contract deploy --build-only` documented `unsigned`/`unsignedHex`; the producer emits `tx`/`hex`, so anything reading the documented names got undefined. - The pagination inventory listed `proposal show`, which returns one proposal and no cursor, and omitted `proposal list`, which does paginate. --- ts/docs/commands/contract/deploy.md | 2 +- ts/docs/commands/exchange/index.md | 5 ++ ts/docs/commands/tx/send.md | 8 +++ ts/docs/machine-interface.md | 2 +- .../persistence/backup-records.test.ts | 44 +++++++++++++- .../outbound/persistence/backup-records.ts | 26 ++++++++- .../use-cases/tron/asset-service.test.ts | 31 ++++++++++ .../use-cases/tron/asset-service.ts | 6 +- .../use-cases/wallet-service.keystore.test.ts | 37 ++++++++++++ .../application/use-cases/wallet-service.ts | 25 +++++++- ts/src/domain/keystore/index.ts | 12 +++- ts/src/domain/keystore/keystore-v3.test.ts | 58 +++++++++++++++++++ 12 files changed, 244 insertions(+), 12 deletions(-) diff --git a/ts/docs/commands/contract/deploy.md b/ts/docs/commands/contract/deploy.md index 12a88e644..0dc692930 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -70,7 +70,7 @@ echo "$PW" | wallet-cli contract deploy --abi "$(cat MyToken.abi.json)" --byteco | `--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` | +| `--build-only` | `kind`, `mode: "build-only"`, unsigned `tx`, `hex`, fee estimate, `contractAddress` | ## Exit status diff --git a/ts/docs/commands/exchange/index.md b/ts/docs/commands/exchange/index.md index 9209a32b9..60a86883f 100644 --- a/ts/docs/commands/exchange/index.md +++ b/ts/docs/commands/exchange/index.md @@ -10,6 +10,11 @@ TRON carries an automatic market maker at **protocol level**: no order book, no - **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. +- **Human amounts are scaled by node-supplied decimals.** Every `--amount` / `--amounts` / + `--min-received` is converted to base units using the TRC10 `precision` the node reports, so that + value decides the quantity you sign. It is checked against the protocol range 0..6 and against the + token id requested, but a wrong value inside that range cannot be caught locally. Use the + `--raw-*` variants when the exact base-unit quantity matters — they are used verbatim. ## Pricing diff --git a/ts/docs/commands/tx/send.md b/ts/docs/commands/tx/send.md index c7338ae41..afe4037e4 100644 --- a/ts/docs/commands/tx/send.md +++ b/ts/docs/commands/tx/send.md @@ -22,6 +22,14 @@ Builds, signs, and submits a transfer from the active account (or `--account`). Amounts: `--amount` is human units (TRX, or token units respecting the token's decimals); `--raw-amount` is the raw integer (SUN or token base units). Exactly one of the two. +Where the decimals come from: TRX is fixed at 6, but a token's are read from the node — from the +contract for TRC20, from the asset record for TRC10. `--amount` is therefore scaled by a number the +node supplies, and a node that misreports it moves the decimal point on the amount you sign. The +value is checked against the protocol range (a TRC10 precision is 0..6, and a record answering for +a different id is refused outright), but a wrong value *inside* that range cannot be detected +locally — there is nothing to compare it against. When the exact base-unit quantity matters, pass +`--raw-amount`, which is used verbatim and never rescaled. + Early exits: `--dry-run` builds and estimates only — no signature, no broadcast, nothing leaves your machine; `--sign-only` signs and prints the signed transaction **hex**; `--build-only` builds but does **not** sign, printing the **unsigned** hex. For multi-sig, `--permission-id` selects the signing group and `--expiration` extends how long the transaction stays valid for co-signers to add their signatures. **By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed, or poll [`tx status`](status.md). diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index 85abd0bfa..d2a2920a6 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -84,7 +84,7 @@ Helpers that assume strings (`.meta.warnings | join("\n")`, `Array.prototype.joi ### 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. +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 list`, 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 } } diff --git a/ts/src/adapters/outbound/persistence/backup-records.test.ts b/ts/src/adapters/outbound/persistence/backup-records.test.ts index fa7d3a950..cb915393c 100644 --- a/ts/src/adapters/outbound/persistence/backup-records.test.ts +++ b/ts/src/adapters/outbound/persistence/backup-records.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { AtomicFileStore } from "./fs/index.js"; @@ -58,10 +58,48 @@ describe("FileBackupRecordStore", () => { expect(onDisk).toEqual({ version: 1, records: [record(1)] }); }); - it("treats a file with no usable records array as empty rather than failing the command", () => { - writeFileSync(join(root, "backup-records.json"), JSON.stringify({ version: 1 })); + /** + * The audit log is the only local record of which secrets left this machine, and an append is a + * whole-file rewrite. A file that is legal json but not a shape we recognise used to read as + * "no records", so the next export overwrote it — reliably and silently destroying the evidence. + * A future `version: 2` written by a newer build and then opened by this one would do exactly + * that, which makes it a forward-compatibility trap as much as a tampering one. + * + * Unrecognised content is therefore set aside under a `.unreadable-` name before anything is + * written. Backup keeps working — refusing to export because the log is unreadable helps nobody — + * but the original bytes survive for whoever has to reconstruct what happened. + */ + const unreadable = [ + ["a newer schema version", { version: 2, entries: [{ account: "T1" }] }], + ["records that are not an array", { version: 1, records: { a: 1 } }], + ["no records key at all", { version: 1 }], + ] as const; + + it.each(unreadable)("sets aside %s instead of reporting an empty log", (_label, content) => { + writeFileSync(join(root, "backup-records.json"), JSON.stringify(content)); + expect(store.list()).toEqual([]); + + const quarantined = readdirSync(root).filter((f) => f.startsWith("backup-records.json.unreadable")); + expect(quarantined).toHaveLength(1); + expect(JSON.parse(readFileSync(join(root, quarantined[0]!), "utf8"))).toEqual(content); + }); + + it("keeps the set-aside copy when the next export rewrites the log", () => { + const original = { version: 2, entries: [{ account: "T1" }] }; + writeFileSync(join(root, "backup-records.json"), JSON.stringify(original)); + store.append(record(1)); + expect(store.list()).toEqual([record(1)]); + const quarantined = readdirSync(root).filter((f) => f.startsWith("backup-records.json.unreadable")); + expect(JSON.parse(readFileSync(join(root, quarantined[0]!), "utf8"))).toEqual(original); + }); + + // Syntactically broken json already failed closed; that behaviour is the reference, not the bug. + it("still refuses to touch a file that is not json at all", () => { + writeFileSync(join(root, "backup-records.json"), '{"version":1,"records":['); + expect(() => store.list()).toThrowError(); + expect(readFileSync(join(root, "backup-records.json"), "utf8")).toBe('{"version":1,"records":['); }); }); diff --git a/ts/src/adapters/outbound/persistence/backup-records.ts b/ts/src/adapters/outbound/persistence/backup-records.ts index 91cdef329..d6bf4960a 100644 --- a/ts/src/adapters/outbound/persistence/backup-records.ts +++ b/ts/src/adapters/outbound/persistence/backup-records.ts @@ -5,6 +5,7 @@ * hard-coded 1000 entries: it is an audit trail, not a database, and making it configurable would * invite setting it to 0 — i.e. silently turning the trail off. */ +import { existsSync, renameSync } from "node:fs"; import { join } from "node:path"; import type { BackupRecord, BackupRecordStore } from "../../../application/ports/backup-records.js"; import { AtomicFileStore } from "./fs/index.js"; @@ -39,8 +40,31 @@ export class FileBackupRecordStore implements BackupRecordStore { return this.#read(); } + /** + * Records currently on file, or none — but never by discarding something we simply did not + * recognise. `append` rewrites the whole file, so treating an unfamiliar shape as "empty" made + * the next export destroy it. A newer build writing `version: 2` and this one opening it would + * hit exactly that, which makes it a forward-compatibility trap as much as a tampering one. + * + * Unreadable content is moved aside instead. Refusing to export because the log is unreadable + * would help nobody, so the export proceeds — but the original bytes survive for whoever has to + * reconstruct what happened. (Syntactically broken json throws out of `readJson` before this, + * and is left untouched.) + */ #read(): BackupRecord[] { const file = this.store.readJson(this.path); - return Array.isArray(file?.records) ? file.records : []; + if (file === null) return []; + if (file.version === 1 && Array.isArray(file.records)) return file.records; + this.#setAside(); + return []; + } + + #setAside(): void { + for (let n = 1; ; n += 1) { + const target = `${this.path}.unreadable-${n}`; + if (existsSync(target)) continue; + renameSync(this.path, target); + return; + } } } diff --git a/ts/src/application/use-cases/tron/asset-service.test.ts b/ts/src/application/use-cases/tron/asset-service.test.ts index 79d4c0983..82684b14f 100644 --- a/ts/src/application/use-cases/tron/asset-service.test.ts +++ b/ts/src/application/use-cases/tron/asset-service.test.ts @@ -303,3 +303,34 @@ describe("asset info / list", () => { expect(listAssets).toHaveBeenCalledWith(10, 20); }); }); + +/** + * `asset issue` burns the issuance fee, an account may only ever issue once, and the ICO window is + * fixed for the life of the token. So a mistyped time must not become a different, valid one. + * + * The parser accepted any two digits per component and let `Date.UTC` roll them over, checking only + * the resulting DATE afterwards — which catches `24:00:00` (it lands on the next day) but not + * `12:60:00`, which stays inside the same day and is silently read as 13:00:00. + */ +describe("asset issue rejects an impossible time instead of rolling it over", () => { + const issueWith = (start: string) => + service({ getAssetByIssuer: async () => undefined, buildAssetIssue: async () => ({}) as never }) + .issue(scope, NET, { ...ISSUE, start, end: "2031-01-01", dryRun: true } as never); + + it.each([ + ["60 minutes", "2030-01-01 12:60:00"], + ["60 seconds", "2030-01-01 12:00:60"], + ["25 hours", "2030-01-01 25:00:00"], + ["24 hours — already rejected, and must stay rejected", "2030-01-01 24:00:00"], + ])("rejects %s", async (_label, start) => { + await expect(issueWith(start)).rejects.toMatchObject({ code: "invalid_value" }); + }); + + it.each([ + ["midnight", "2030-01-01 00:00:00"], + ["the last second of a day", "2030-01-01 23:59:59"], + ["a date with no time at all", "2030-01-01"], + ])("still accepts %s", async (_label, start) => { + await expect(issueWith(start)).resolves.toMatchObject({ kind: "asset-issue" }); + }); +}); diff --git a/ts/src/application/use-cases/tron/asset-service.ts b/ts/src/application/use-cases/tron/asset-service.ts index 12d695646..b1fc206f0 100644 --- a/ts/src/application/use-cases/tron/asset-service.ts +++ b/ts/src/application/use-cases/tron/asset-service.ts @@ -426,9 +426,11 @@ function parseUtcDateTime(value: string, flag: string): number { const [, y, mo, d, h = "00", mi = "00", s = "00"] = match; const stamp = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)); // Date.UTC rolls over out-of-range parts (month 13 → next January); reject rather than reinterpret. + // Comparing the whole instant, not just the date: `12:60:00` rolls to 13:00:00 WITHIN the same + // day, so a date-only check waves it through — and an issuance is irreversible. const iso = new Date(stamp).toISOString(); - if (iso.slice(0, 10) !== `${y}-${mo}-${d}`) { - throw new UsageError("invalid_value", `${flag} is not a real date`); + if (iso.slice(0, 19) !== `${y}-${mo}-${d}T${h}:${mi}:${s}`) { + throw new UsageError("invalid_value", `${flag} is not a real date and time`); } return stamp; } diff --git a/ts/src/application/use-cases/wallet-service.keystore.test.ts b/ts/src/application/use-cases/wallet-service.keystore.test.ts index ab426cdad..07a0586ca 100644 --- a/ts/src/application/use-cases/wallet-service.keystore.test.ts +++ b/ts/src/application/use-cases/wallet-service.keystore.test.ts @@ -277,3 +277,40 @@ describe("WalletService.importKeystore", () => { .toThrowError(/already holds this address/); }); }); + +/** + * A backup is two side effects in order: the secret file is committed, then the export is recorded. + * If the second throws — a lock held, a set-aside rename that fails, any local IO fault — the first + * has already happened. The command reported a plain failure, so the caller learned neither that a + * secret had been written nor where it went. Without `--out` that is a timestamped file in the + * process's working directory, so "somewhere in whichever directory the agent happened to be in". + * Retrying then writes a second copy. + * + * The export genuinely did not fully succeed, so it stays a failure — but the path it produced is + * part of the error, because the caller has to be able to find and shred it. + */ +describe("WalletService reports the file it wrote when the audit append fails", () => { + const auditFails = (h: ReturnType) => new WalletService( + h.keystore, + {} as any, + { write: () => ({ out: "/tmp/exported-secret.json", fileMode: "0600" as const, bytes: 42 }) }, + { append: () => { throw new Error("audit log is unwritable"); }, list: () => [] }, + () => NOW, + ); + + it.each([ + ["native backup", (s: WalletService, id: string) => s.backup(id, undefined)], + ["keystore backup", (s: WalletService, id: string) => s.backupKeystore(id, undefined, PW)], + ])("%s still fails, but names the file it already committed", (_label, run) => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: RAW_KEY, type: "privateKey" }); + + try { + run(auditFails(h), accountId); + throw new Error("expected the export to fail"); + } catch (error) { + expect((error as { code?: string }).code).toBe("audit_append_failed"); + expect((error as { details?: { out?: string } }).details?.out).toBe("/tmp/exported-secret.json"); + } + }); +}); diff --git a/ts/src/application/use-cases/wallet-service.ts b/ts/src/application/use-cases/wallet-service.ts index fcc96301f..939ae9b6f 100644 --- a/ts/src/application/use-cases/wallet-service.ts +++ b/ts/src/application/use-cases/wallet-service.ts @@ -5,7 +5,7 @@ import { KeystoreV3 } from "../../domain/keystore/index.js"; import { derivePrivAddresses } from "../../domain/wallet/index.js"; import { tronHexAddress } from "../../domain/address/index.js"; import type { Bytes } from "../../domain/types/index.js"; -import { UsageError, WalletError } from "../../domain/errors/index.js"; +import { ExecutionError, UsageError, WalletError } from "../../domain/errors/index.js"; import type { BackupWriter } from "../ports/backup-writer.js"; import type { BackupRecord, BackupRecordStore } from "../ports/backup-records.js"; import type { LedgerDevice } from "../ports/ledger-device.js"; @@ -249,9 +249,28 @@ export class WalletService { throw notExportable(source.type); } - /** Appended only after the file exists, so the audit log never claims a failed export. - * Timestamps are UTC at second precision — an audit trail, not a profiler. */ + /** + * Appended only after the file exists, so the audit log never claims a failed export. + * Timestamps are UTC at second precision — an audit trail, not a profiler. + * + * The secret is already on disk by the time this runs, so a failure here is not a clean one: the + * export did not fully succeed, but something sensitive exists that the caller must be able to + * find and shred. Without `--out` that is a timestamped file in the process's working directory, + * which nobody can guess — so the path travels with the error rather than being lost with it. + */ #recordExport(operation: BackupRecord["operation"], descriptor: { accountId: string; label?: string | null; addresses: Partial> }, out: string) { + try { + this.#appendExport(operation, descriptor, out); + } catch (error) { + throw new ExecutionError( + "audit_append_failed", + `the ${operation === "backup" ? "backup" : "keystore"} file was written to ${out}, but recording it in the export log failed: ${(error as Error).message}`, + { out, fileMode: "0600" }, + ); + } + } + + #appendExport(operation: BackupRecord["operation"], descriptor: { accountId: string; label?: string | null; addresses: Partial> }, out: string) { this.backupRecordStore.append({ operation, accountId: descriptor.accountId, diff --git a/ts/src/domain/keystore/index.ts b/ts/src/domain/keystore/index.ts index 40bbb8179..0d6f88448 100644 --- a/ts/src/domain/keystore/index.ts +++ b/ts/src/domain/keystore/index.ts @@ -11,7 +11,9 @@ * 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. + * the Java implementation (`Wallet.java`) so anything it or TronLink can open, we can open — with + * one deliberate exception: a derived key shorter than the private key it must authenticate is + * refused (see `deriveKey`), because accepting it would mean accepting any password. */ import { randomUUID } from "node:crypto"; import { scrypt } from "@noble/hashes/scrypt.js"; @@ -122,6 +124,14 @@ function deriveKey(c: Record, password: string): Bytes { const p = asRecord(c.kdfparams, "missing crypto.kdfparams"); const salt = hexField(p.salt, "crypto.kdfparams.salt"); const dklen = intField(p.dklen, "crypto.kdfparams.dklen"); + // The MAC authenticates the password only through dk[16:32]. Below 32 bytes that slice is empty + // and the MAC collapses to keccak(ciphertext) — a constant the file's author chooses — so every + // password passes and decrypts to a different, unknowable key. Java is not fooled here (its + // pbkdf2 path ignores dklen and derives 32; its scrypt path throws), so this is parity, not + // divergence. + if (dklen < PRIVATE_KEY_BYTES) { + throw invalid(`crypto.kdfparams.dklen must be at least ${PRIVATE_KEY_BYTES}, got ${dklen}: a shorter derived key cannot bind the MAC to the password`); + } if (c.kdf === "scrypt") { return Web3Crypto.scryptKey(password, salt, { n: intField(p.n, "crypto.kdfparams.n"), diff --git a/ts/src/domain/keystore/keystore-v3.test.ts b/ts/src/domain/keystore/keystore-v3.test.ts index 352b03dbb..d68b9d2cb 100644 --- a/ts/src/domain/keystore/keystore-v3.test.ts +++ b/ts/src/domain/keystore/keystore-v3.test.ts @@ -135,3 +135,61 @@ describe("KeystoreV3.decrypt", () => { expect(bytesToHex(KeystoreV3.decrypt(file, PW))).toBe(bytesToHex(KEY)); }); }); + +/** + * The Web3 MAC is keccak(dk[16:32] || ciphertext) — it authenticates the password only because + * dk[16:32] is derived from it. A file declaring `dklen: 16` makes that slice EMPTY, so the MAC + * degenerates to keccak(ciphertext): a value the file's author fixes, independent of any password. + * Every password then passes the check and decrypts the ciphertext to different 32 random bytes, + * which is a well-formed private key. The import reports success and the user holds an address + * nobody knows the key to — funds sent there are burned. + * + * Both KDFs in the accept set are affected, and both are safe in the Java implementation this codec + * claims parity with: its pbkdf2 path ignores `dklen` and always derives 32 bytes, and its scrypt + * path would throw copying a 16-byte slice. Refusing dklen < 32 is therefore not a divergence from + * Java — it is catching up with it. + */ +describe("V3 import rejects a derived key too short to authenticate the password", () => { + const shortDklen = (kdf: "pbkdf2" | "scrypt", password: string) => { + const salt = new Uint8Array(32).fill(11); + const iv = new Uint8Array(16).fill(13); + const kdfparams = kdf === "pbkdf2" + ? { c: 1, prf: "hmac-sha256", dklen: 16, salt: bytesToHex(salt) } + : { n: 1024, r: 8, p: 1, dklen: 16, salt: bytesToHex(salt) }; + const dk = kdf === "pbkdf2" + ? pbkdf2(sha256, utf8ToBytes(password), salt, { c: 1, dkLen: 16 }) + : Web3Crypto.scryptKey(password, salt, { n: 1024, r: 8, p: 1, dklen: 16 }); + const ciphertext = ctr(dk.slice(0, 16), iv).encrypt(KEY); + return { + version: 3, + id: "aa0f2c1e-0000-4000-8000-000000000002", + address: ADDRESS, + crypto: { + cipher: "aes-128-ctr", + ciphertext: bytesToHex(ciphertext), + cipherparams: { iv: bytesToHex(iv) }, + kdf, + kdfparams, + // dk[16:32] is empty, so this is keccak(ciphertext) — no password involved. + mac: bytesToHex(Web3Crypto.mac(dk, ciphertext)), + }, + }; + }; + + it.each(["pbkdf2", "scrypt"] as const)("refuses a %s file declaring dklen 16", (kdf) => { + expect(() => KeystoreV3.decrypt(shortDklen(kdf, PW), PW)) + .toThrowError(/dklen/i); + }); + + // The decisive property: without the guard BOTH of these succeed, each yielding a different key. + it.each(["pbkdf2", "scrypt"] as const)("refuses a %s file under any password, not just the wrong one", (kdf) => { + const file = shortDklen(kdf, PW); + for (const attempt of [PW, "completely-different-password", ""]) { + expect(() => KeystoreV3.decrypt(file, attempt)).toThrowError(/dklen/i); + } + }); + + it("still accepts the standard dklen 32", () => { + expect(KeystoreV3.decrypt(lightV3(), PW)).toEqual(KEY); + }); +}); From 6c298b898aae8ce8cf64f517a4ad66d23bb87321 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 17 Aug 2026 14:39:47 +0800 Subject: [PATCH 14/15] fix(ts): trust our own transaction id, MAC bytes and a proposal snapshot; sync the reference docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects with one shape: a value we could establish ourselves was taken from somewhere less reliable instead. **The transaction id came from the node.** A TRON txID is the sha256 of the transaction body — not an identifier the node assigns, but one derivable from the bytes we signed, and `--sign-only` already derives it. The broadcast path returned the node's echo instead. Verified against a live Nile node that the two agree, so this costs nothing when the node is honest; when it does not, the node chooses which transaction `--wait` polls for and which id the receipt quotes, and a receipt can end up describing someone else's confirmed transaction. The derived id now wins and a disagreement is warned about rather than thrown on, because the transaction has already been broadcast and failing a submitted one repeats the mistake of hiding a side effect that happened. Settled in one helper used by all three broadcast paths — the pipeline, `tx broadcast --file`, and the multi-sig relay — rather than at the one call site that prompted it. **The keystore MAC was compared as text.** `A1B2` and `a1b2` are the same bytes, and it was the one hex field skipping `hexField`, compared against our lowercase rendering — so a valid file written in uppercase came back as `wrong_keystore_password`, refusing the file AND sending the reader to fix a password that was never wrong. Java compares byte arrays and accepts it. The same misreport covered a missing or non-hex `mac`, which is now `invalid_keystore`, as the codec's own contract already said it should be. No timing claim is made: whoever can time this comparison already holds the file and can try passwords offline. **The created proposal's id was a guess.** The chain does not report it, so `--wait` recognises it in the list afterwards — and proposer plus parameter set does not identify it: on mainnet 10 of 106 proposals share both with another, because a rejected proposal gets re-submitted unchanged, and the lookup never filtered by state either. Ties went to the highest id, which is right only while the list is fresh. The case needing no hostile node: the confirmation reads unsolidified fullnode data (~3s), the list lags it, the caller had proposed these parameters before — so the only match is the OLD proposal, and its id is handed back to be passed to `proposal approve` or the irreversible `proposal delete`. A snapshot taken before submitting makes "already there" and "just appeared" distinguishable. Anything other than exactly one new match is now reported as unknown with a warning, since no id beats a wrong id. The snapshot is only taken when a confirmation will actually be awaited, and a failure to read it degrades to omitting the id rather than blocking the write. `proposalId` may therefore be absent where it used to appear; documented on the command page. Docs: the command reference moves from terse prose to per-command option tables, examples and json field tables. Spot-checked against live output — the field tables for `proposal show` and `asset info` match what the commands actually return. Three corrections on top of that rewrite, which was based on a revision predating the fixes earlier in this release: - `--permission-id` rows used an en-dash (`2–9`) where the flag's own help uses a hyphen, so all 28 pages failed the check that keeps the two in step; four pages had also lost the value key or the `--expiration` default. - the notes on where a token's decimals come from — and on the range check being unable to catch a wrong value inside the protocol range — were reinstated, along with the `invalid_node_response` code. - `asset participate` documented `insufficient_asset_supply`, which nothing emits: a supply shortfall is a chain rejection. --- README.md | 10 +- java/docs/commands/index.md | 12 +- ts/.gitignore | 5 +- ts/README.md | 35 +++-- ts/docs/commands/account/activate.md | 2 +- ts/docs/commands/account/set.md | 2 +- ts/docs/commands/asset/index.md | 28 ++-- ts/docs/commands/asset/info.md | 116 +++++++++++++--- ts/docs/commands/asset/issue.md | 110 +++++++++------ ts/docs/commands/asset/list.md | 55 +++++--- ts/docs/commands/asset/participate.md | 81 ++++++----- ts/docs/commands/asset/unfreeze.md | 65 +++++---- ts/docs/commands/asset/update.md | 74 ++++++---- ts/docs/commands/backup.md | 130 +++++++++--------- ts/docs/commands/chain/params.md | 13 +- ts/docs/commands/contract/clear-abi.md | 62 +++++++-- ts/docs/commands/contract/create2.md | 64 +++++++-- ts/docs/commands/contract/deploy.md | 12 +- ts/docs/commands/contract/index.md | 12 +- ts/docs/commands/contract/send.md | 2 +- .../contract/set-origin-energy-limit.md | 70 ++++++++-- .../contract/set-user-resource-percent.md | 72 ++++++++-- ts/docs/commands/exchange/create.md | 77 +++++++---- ts/docs/commands/exchange/index.md | 34 ++--- ts/docs/commands/exchange/inject.md | 84 ++++++----- ts/docs/commands/exchange/list.md | 51 +++++-- ts/docs/commands/exchange/show.md | 53 +++++-- ts/docs/commands/exchange/trade.md | 108 ++++++++------- ts/docs/commands/exchange/withdraw.md | 84 +++++++---- ts/docs/commands/gasfree/transfer.md | 4 +- ts/docs/commands/import/index.md | 2 +- ts/docs/commands/import/keystore.md | 42 ++---- ts/docs/commands/index.md | 63 +++++---- ts/docs/commands/permission/update.md | 4 +- ts/docs/commands/proposal/approve.md | 74 ++++++++-- ts/docs/commands/proposal/create.md | 92 +++++++++++-- ts/docs/commands/proposal/delete.md | 60 ++++++-- ts/docs/commands/proposal/index.md | 25 +++- ts/docs/commands/proposal/list.md | 69 ++++++++-- ts/docs/commands/proposal/show.md | 93 +++++++++++-- ts/docs/commands/tx/multisig.md | 2 +- ts/docs/commands/tx/sign.md | 2 +- ts/docs/commands/vote/list.md | 9 +- ts/docs/commands/vote/status.md | 9 +- ts/docs/commands/witness/create.md | 61 ++++++-- ts/docs/commands/witness/index.md | 18 ++- ts/docs/commands/witness/set-brokerage.md | 66 +++++++-- ts/docs/commands/witness/update.md | 60 ++++++-- ts/docs/concepts/accounts-and-hd.md | 2 +- ts/docs/java-parity-v4.12-governance.md | 37 ----- ts/docs/machine-interface.md | 73 ++++++---- .../services/broadcast-identity.ts | 38 +++++ ts/src/application/services/pipeline/index.ts | 16 +-- .../services/pipeline/pipeline.test.ts | 40 ++++++ .../services/tron-confirmation.test.ts | 52 +++++++ .../application/services/tron-confirmation.ts | 10 +- .../use-cases/tron/multisig-service.ts | 3 +- .../use-cases/tron/proposal-service.test.ts | 72 ++++++++++ .../use-cases/tron/proposal-service.ts | 40 +++++- .../use-cases/tron/transaction-service.ts | 3 +- ts/src/domain/keystore/index.ts | 13 +- ts/src/domain/keystore/keystore-v3.test.ts | 41 ++++++ 62 files changed, 1938 insertions(+), 780 deletions(-) delete mode 100644 ts/docs/java-parity-v4.12-governance.md create mode 100644 ts/src/application/services/broadcast-identity.ts diff --git a/README.md b/README.md index 73c689ffd..e074bb382 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ This repository holds **two independent implementations** that share the same purpose but target different users: -- **[Java](java/README.md)** — the original, full-featured reference CLI. An interactive prompt (REPL) for people who want the complete TRON feature surface. +- **[Java](java/README.md)** — the original, full-featured reference CLI. An interactive prompt (REPL) you drive by hand. - **[TypeScript](ts/README.md)** — an agent-first rewrite for automation. Standard subcommands with a stable JSON envelope, built for scripts, CI, and AI agents. -Both manage the same kind of wallet on the same networks — your address is identical regardless of which you use. They differ in how you install and drive them, and in how much of TRON they cover. Pick one and read its own README for depth; this page gives you the basics of each so you can choose. +Both manage the same kind of wallet on the same networks — your address is identical regardless of which you use. They cover the same TRON feature surface and differ in how you install and drive them. Pick one and read its own README for depth; this page gives you the basics of each so you can choose. ## At a glance @@ -31,7 +31,7 @@ Both manage the same kind of wallet on the same networks — your address is ide | **Output for scripts** | Human-readable text. | Stable JSON via `-o json` ([`wallet-cli.result.v1`](ts/docs/machine-interface.md)) + fixed exit codes (`0`/`1`/`2`). | | **Config / networks** | `config.conf` (net type + full node), or `SwitchNetwork` at runtime. Mainnet · Nile · Shasta · custom. | `--network` flag / `config` command. `tron:mainnet` · `tron:nile` · `tron:shasta`. | | **Signing** | Software keystore · Ledger. | Encrypted local keystore · Ledger. Secrets never via argv/env. | -| **Feature scope** | **The full surface** — everything in the TypeScript column, plus TRC10 token issuance and on-chain DEX & governance/proposals. | **Core wallet ops** — HD wallets, TRX/TRC20/TRC10 transfers, staking & delegation, voting & rewards, contract call/deploy, multi-sig, GasFree transfers, message signing, and on-chain queries. | +| **Feature scope** | **The full surface** — wallets and transfers, staking, voting and rewards, governance, contracts, TRC10, and the on-chain exchange. | **The full surface** — HD wallets, TRX/TRC20/TRC10 transfers, staking & delegation, voting & rewards, governance proposals & super-representative operation, contract call/deploy/governance, TRC10 issuance, the on-chain Bancor exchange, multi-sig, GasFree transfers, message signing, and on-chain queries. | | **Best for** | People at a terminal who want every TRON capability. | Scripting, CI pipelines, and AI agents. | | **Full docs** | [java/README.md](java/README.md) | [ts/README.md](ts/README.md) | @@ -49,7 +49,7 @@ $ java -jar wallet-cli.jar # opens the interactive prompt > GetBalance # TRX balance ``` -Full setup (config.conf, connecting to a node), the complete A–Z command list, and features like GasFree and multi-sig live in **[java/README.md](java/README.md)** — jump to [Setup](java/README.md#setup), [Quickstart](java/README.md#quickstart), [Commands](java/README.md#commands), or [GasFree](java/README.md#gasfree). +Full setup (config.conf, connecting to a node), the complete A–Z command list, and features like GasFree and multi-sig live in **[java/README.md](java/README.md)** — jump to [Setup](java/README.md#setup), [Quickstart](java/README.md#quickstart), [Commands](java/README.md#commands), or [GasFree](java/README.md#contracts-gasfree--chain-data). ## TypeScript — get a taste @@ -72,5 +72,5 @@ Every command has a reference page, and the JSON contract, exit codes, and agent ## Which should I use? - **Scripting, CI, or building an AI agent?** → the [TypeScript version](ts/README.md) — the JSON envelope and deterministic exit codes exist for exactly this. -- **Working interactively and want the complete TRON toolkit** — TRC10 issuance, or on-chain DEX/governance/proposals? → the [Java version](java/README.md). +- **Working interactively** — one long-running session at a `>` prompt, with the wallet unlocked once for the whole session? → the [Java version](java/README.md). - **Just sending TRX/tokens or staking from your own machine?** → either works; the TypeScript CLI is the lighter install (`npm install -g`, no build step). diff --git a/java/docs/commands/index.md b/java/docs/commands/index.md index b13d849d4..66aef7619 100644 --- a/java/docs/commands/index.md +++ b/java/docs/commands/index.md @@ -45,7 +45,7 @@ Type any command in the interactive wallet to see its built-in usage tips. | CreateProposal | [proposals.md#createproposal](proposals.md#createproposal) | | CreateWitness | [vote-reward.md#createwitness](vote-reward.md#createwitness) | | CurrentNetwork | [network.md#currentnetwork](network.md#currentnetwork) | -| DelegateResource | [stake-v2.md#delegateresource-undelegateresource](stake-v2.md#delegateresource-undelegateresource) | +| DelegateResource | [stake-v2.md#delegateresource](stake-v2.md#delegateresource) | | DeleteProposal | [proposals.md#deleteproposal](proposals.md#deleteproposal) | | DeployConstantContract | [contract.md#deployconstantcontract](contract.md#deployconstantcontract) | | DeployContract | [contract.md#deploycontract](contract.md#deploycontract) | @@ -58,7 +58,7 @@ Type any command in the interactive wallet to see its built-in usage tips. | ExportWalletKeystore | [wallet.md#exportwalletkeystore](wallet.md#exportwalletkeystore) | | ExportWalletMnemonic | [wallet.md#exportwalletmnemonic](wallet.md#exportwalletmnemonic) | | FreezeBalance | [stake-v1-legacy.md#how-to-freezeunfreeze-balance](stake-v1-legacy.md#how-to-freezeunfreeze-balance) | -| FreezeBalanceV2 | [stake-v2.md#freezebalancev2-unfreezebalancev2](stake-v2.md#freezebalancev2-unfreezebalancev2) | +| FreezeBalanceV2 | [stake-v2.md#freezebalancev2](stake-v2.md#freezebalancev2) | | GasFreeInfo | [gasfree.md#gasfreeinfo](gasfree.md#gasfreeinfo) | | GasFreeTrace | [gasfree.md#gasfreetrace](gasfree.md#gasfreetrace) | | GasFreeTransfer | [gasfree.md#gasfreetransfer](gasfree.md#gasfreetransfer) | @@ -145,17 +145,17 @@ Type any command in the interactive wallet to see its built-in usage tips. | TriggerConstantContract | [contract.md#triggerconstantcontract](contract.md#triggerconstantcontract) | | TriggerContract | [contract.md#triggercontract](contract.md#triggercontract) | | TronlinkMultiSign | [multisig.md#tronlinkmultisign](multisig.md#tronlinkmultisign) | -| UnDelegateResource | [stake-v2.md#delegateresource-undelegateresource](stake-v2.md#delegateresource-undelegateresource) | +| UnDelegateResource | [stake-v2.md#undelegateresource](stake-v2.md#undelegateresource) | | UnfreezeAsset | [transfer-trc10.md#unfreezeasset](transfer-trc10.md#unfreezeasset) | | UnfreezeBalance | [stake-v1-legacy.md#unfreezebalance-undelegate](stake-v1-legacy.md#unfreezebalance-undelegate) | -| UnfreezeBalanceV2 | [stake-v2.md#freezebalancev2-unfreezebalancev2](stake-v2.md#freezebalancev2-unfreezebalancev2) | +| UnfreezeBalanceV2 | [stake-v2.md#unfreezebalancev2](stake-v2.md#unfreezebalancev2) | | Unlock | [wallet.md#unlock](wallet.md#unlock) | | UpdateAccount | [account.md#updateaccount](account.md#updateaccount) | | UpdateAccountPermission | [multisig.md#updateaccountpermission](multisig.md#updateaccountpermission) | | UpdateAsset | [transfer-trc10.md#updateasset](transfer-trc10.md#updateasset) | | UpdateBrokerage | [vote-reward.md#updatebrokerage](vote-reward.md#updatebrokerage) | -| UpdateEnergyLimit | [contract.md#updateenergylimit-updatesetting](contract.md#updateenergylimit-updatesetting) | -| UpdateSetting | [contract.md#updateenergylimit-updatesetting](contract.md#updateenergylimit-updatesetting) | +| UpdateEnergyLimit | [contract.md#updateenergylimit--updatesetting](contract.md#updateenergylimit--updatesetting) | +| UpdateSetting | [contract.md#updateenergylimit--updatesetting](contract.md#updateenergylimit--updatesetting) | | UpdateWitness | [vote-reward.md#updatewitness](vote-reward.md#updatewitness) | | ViewBackupRecords | [account.md#viewbackuprecords](account.md#viewbackuprecords) | | ViewTransactionHistory | [account.md#viewtransactionhistory](account.md#viewtransactionhistory) | diff --git a/ts/.gitignore b/ts/.gitignore index a0a00aedd..e2563668b 100644 --- a/ts/.gitignore +++ b/ts/.gitignore @@ -4,4 +4,7 @@ dist/ .wallet-cli/ .env .private/ -docs/superpowers \ No newline at end of file +docs/superpowers +docs/qa +docs/adr +CONTEXT.md \ No newline at end of file diff --git a/ts/README.md b/ts/README.md index ac876274a..990e47ea7 100644 --- a/ts/README.md +++ b/ts/README.md @@ -7,7 +7,7 @@ The agent-first implementation of wallet-cli, built for automation: every comman - **Agent-first** — stable JSON output, deterministic exit codes, and discoverable schemas, built for scripts, CI, and AI agents (details in [The contract, in one paragraph](#the-contract-in-one-paragraph)). - **Encrypted local storage** — software keystores are encrypted on disk; secrets are never passed via argv or environment variables. - **Software and Ledger signing** — sign in software, or on a Ledger device (the private key never leaves the device). -- **Covers the main TRON capabilities** — HD wallets, TRX and TRC20/TRC10 transfers, staking / resource delegation, voting / rewards, smart-contract calls and deployment, multi-sig, GasFree transfers, message signing, and on-chain queries. +- **Covers the full TRON feature surface** — HD wallets, TRX and TRC20/TRC10 transfers, staking / resource delegation, voting / rewards, governance proposals and super-representative operation, smart-contract calls, deployment and governance, TRC10 issuance, the on-chain Bancor exchange, multi-sig, GasFree transfers, message signing, and on-chain queries. ## Table of contents @@ -19,6 +19,7 @@ The agent-first implementation of wallet-cli, built for automation: every comman - [Transactions](#transactions) - [On-chain queries](#on-chain-queries) - [Tokens, contracts, staking, signing](#tokens-contracts-staking-signing) + - [Governance, TRC10, and the on-chain exchange](#governance-trc10-and-the-on-chain-exchange) - [Local tools and configuration](#local-tools-and-configuration) - [The contract, in one paragraph](#the-contract-in-one-paragraph) - [Understanding TRON mechanics](#understanding-tron-mechanics) @@ -104,11 +105,11 @@ Create, import, and manage local wallets and accounts. | Command | Description | |---|---| | [`create`](docs/commands/create.md) | Create a new HD wallet (BIP39 seed) | -| `import` | Import a wallet — [mnemonic](docs/commands/import/mnemonic.md) · [private-key](docs/commands/import/private-key.md) · [ledger](docs/commands/import/ledger.md) · [watch](docs/commands/import/watch.md)-only | +| `import` | Import a wallet — [mnemonic](docs/commands/import/mnemonic.md) · [private-key](docs/commands/import/private-key.md) · [keystore](docs/commands/import/keystore.md) · [ledger](docs/commands/import/ledger.md) · [watch](docs/commands/import/watch.md)-only | | [`list`](docs/commands/list.md) | List wallets and accounts | | [`use`](docs/commands/use.md) · [`current`](docs/commands/current.md) | Set / show the active account (`current --qr` for a receive QR) | | [`derive`](docs/commands/derive.md) | Derive the next HD account from a seed wallet | -| [`rename`](docs/commands/rename.md) · [`backup`](docs/commands/backup.md) · [`delete`](docs/commands/delete.md) | Rename, back up, or delete an account (backup writes secret + metadata, mode 0600) | +| [`rename`](docs/commands/rename.md) · [`backup`](docs/commands/backup.md) · [`delete`](docs/commands/delete.md) | Rename, back up, or delete an account (backup writes secret + metadata, mode 0600; `--keystore` for Web3 keystore format, `--records` for the export audit log) | | [`change-password`](docs/commands/change-password.md) | Change the master password (re-encrypt all software keystores) | ### Transactions @@ -138,17 +139,27 @@ Read account, block, and chain state. Token and contract operations, resource staking, voting rewards, message signing, and permissions. +| Command | Description | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [`token`](docs/commands/token/index.md) | Token address book and queries ([balance](docs/commands/token/balance.md) · [info](docs/commands/token/info.md) · [add](docs/commands/token/add.md) · [list](docs/commands/token/list.md) · [remove](docs/commands/token/remove.md)) | +| [`contact`](docs/commands/contact/index.md) | Recipient contact book ([add](docs/commands/contact/add.md) · [list](docs/commands/contact/list.md) · [remove](docs/commands/contact/remove.md)) | +| [`contract`](docs/commands/contract/index.md) | Call, send, deploy, inspect, and govern contracts ([call](docs/commands/contract/call.md) · [send](docs/commands/contract/send.md) · [deploy](docs/commands/contract/deploy.md) · [info](docs/commands/contract/info.md) · [clear-abi](docs/commands/contract/clear-abi.md) · [set-origin-energy-limit](docs/commands/contract/set-origin-energy-limit.md) · [set-user-resource-percent](docs/commands/contract/set-user-resource-percent.md) · [create2](docs/commands/contract/create2.md)) | +| [`stake`](docs/commands/stake/index.md) | Stake / delegate resources ([freeze](docs/commands/stake/freeze.md) · [unfreeze](docs/commands/stake/unfreeze.md) · [delegate](docs/commands/stake/delegate.md) · [info](docs/commands/stake/info.md), …) | +| [`vote`](docs/commands/vote/index.md) · [`reward`](docs/commands/reward/index.md) | Vote for super representatives and claim voting rewards | +| [`message`](docs/commands/message/index.md) · [`typed-data`](docs/commands/typed-data/index.md) | Sign arbitrary messages, or EIP-712/TIP-712 structured data | +| [`permission`](docs/commands/permission/index.md) | View / update account permissions for multi-sig | +| [`gasfree`](docs/commands/gasfree/index.md) | Gas-free token transfers via the GasFree service | + +### Governance, TRC10, and the on-chain exchange + +Chain governance, super-representative operation, and TRON's protocol-level TRC10 and Bancor exchange mechanics. + | Command | Description | |---|---| -| [`token`](docs/commands/token/index.md) | Token address book and queries ([balance](docs/commands/token/balance.md) · [info](docs/commands/token/info.md) · [add](docs/commands/token/add.md) · [list](docs/commands/token/list.md) · [remove](docs/commands/token/remove.md)) | -| [`contact`](docs/commands/contact/index.md) | Recipient contact book ([add](docs/commands/contact/add.md) · [list](docs/commands/contact/list.md) · [remove](docs/commands/contact/remove.md)) | -| [`contract`](docs/commands/contract/index.md) | Call, send, deploy, inspect contracts ([call](docs/commands/contract/call.md) · [send](docs/commands/contract/send.md) · [deploy](docs/commands/contract/deploy.md) · [info](docs/commands/contract/info.md)) | -| [`stake`](docs/commands/stake/index.md) | Stake / delegate resources ([freeze](docs/commands/stake/freeze.md) · [unfreeze](docs/commands/stake/unfreeze.md) · [delegate](docs/commands/stake/delegate.md) · [info](docs/commands/stake/info.md), …) | -| [`vote`](docs/commands/vote/index.md) · [`reward`](docs/commands/reward/index.md) | Vote for super representatives and claim voting rewards | -| [`message`](docs/commands/message/index.md) · [`typed-data`](docs/commands/typed-data/index.md) | Sign arbitrary messages, or EIP-712/TIP-712 structured data | -| [`permission`](docs/commands/permission/index.md) | View / update account permissions for multi-sig | -| [`gasfree`](docs/commands/gasfree/index.md) | Gas-free token transfers via the GasFree service | -| [`typed-data`](docs/commands/typed-data/index.md) | Sign EIP-712 / TIP-712 structured data ([sign](docs/commands/typed-data/sign.md)) | +| [`proposal`](docs/commands/proposal/index.md) | Chain-parameter proposals ([list](docs/commands/proposal/list.md) · [show](docs/commands/proposal/show.md) · [create](docs/commands/proposal/create.md) · [approve](docs/commands/proposal/approve.md) · [delete](docs/commands/proposal/delete.md)) — `list` / `show` are open to anyone, the write commands require a registered witness | +| [`witness`](docs/commands/witness/index.md) | Register and operate a super representative ([create](docs/commands/witness/create.md) · [update](docs/commands/witness/update.md) · [set-brokerage](docs/commands/witness/set-brokerage.md)) | +| [`asset`](docs/commands/asset/index.md) | Issue and manage TRC10 tokens ([issue](docs/commands/asset/issue.md) · [update](docs/commands/asset/update.md) · [participate](docs/commands/asset/participate.md) · [unfreeze](docs/commands/asset/unfreeze.md) · [info](docs/commands/asset/info.md) · [list](docs/commands/asset/list.md)); TRC10 transfers go through [`tx send`](docs/commands/tx/send.md) | +| [`exchange`](docs/commands/exchange/index.md) | The protocol-level Bancor exchange between TRX and TRC10 ([create](docs/commands/exchange/create.md) · [inject](docs/commands/exchange/inject.md) · [withdraw](docs/commands/exchange/withdraw.md) · [trade](docs/commands/exchange/trade.md) · [show](docs/commands/exchange/show.md) · [list](docs/commands/exchange/list.md)) | ### Local tools and configuration diff --git a/ts/docs/commands/account/activate.md b/ts/docs/commands/account/activate.md index 1aec9905a..cf0bb92da 100644 --- a/ts/docs/commands/account/activate.md +++ b/ts/docs/commands/account/activate.md @@ -88,7 +88,7 @@ echo "$PW" | wallet-cli account activate --address TNewAddr9k2fP7cW4bXm1sV8dRj6e ## Exit status -`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`account_already_active`, `watch_only_no_signer`, `wrong_password`, `auth_failed`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value` — malformed address). +`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`account_already_active`, `watch_only_no_signer`, `auth_failed`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value` — malformed address). After a **confirmed** transaction the command reads the account back to verify the change took effect. That follow-up never turns an already-paid transaction into a command failure: a mismatch or an unreadable read is reported as a `meta.warnings` entry (`account_activate_postcheck_mismatch` / `account_activate_postcheck_unavailable`) with `success` still `true` and exit `0`. diff --git a/ts/docs/commands/account/set.md b/ts/docs/commands/account/set.md index 8f0e046b6..22ef0f84f 100644 --- a/ts/docs/commands/account/set.md +++ b/ts/docs/commands/account/set.md @@ -90,7 +90,7 @@ echo "$PW" | wallet-cli account set --id acme-treasury-01 --network tron:nile -- ## Exit status -`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`name_already_set`, `id_already_set`, `id_taken`, `watch_only_no_signer`, `wrong_password`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`, `invalid_option` — malformed or missing name/id). +`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`name_already_set`, `id_already_set`, `id_taken`, `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`, `invalid_option` — malformed or missing name/id). After a **confirmed** transaction the command reads the account back to verify the change took effect. That follow-up never turns an already-paid transaction into a command failure: a mismatch or an unreadable read is reported as a `meta.warnings` entry (`account_set_postcheck_mismatch` / `account_set_postcheck_unavailable`) with `success` still `true` and exit `0`. diff --git a/ts/docs/commands/asset/index.md b/ts/docs/commands/asset/index.md index 974136c44..7d0b3b4c2 100644 --- a/ts/docs/commands/asset/index.md +++ b/ts/docs/commands/asset/index.md @@ -1,15 +1,17 @@ # wallet-cli asset -Issue and operate TRC10 tokens. +Issue and manage 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. +TRC10 is TRON's **chain-native** token standard: issuance, the ICO sale, and frozen supply are protocol features, not contract code. That is what separates this group from [`token`](../token/index.md), which deals in TRC20 contract tokens — and from [`contract`](../contract/index.md), since a TRC10 has no contract at all. -Two things shape everything in this group: +Four facts shape everything here: -- **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. +- **One token per account, for life.** An account that has issued a TRC10 can never issue another. Getting it wrong means starting over with a different account. +- **Issuance is final.** The issuance fee is burned, and only the description, URL, and the two free-bandwidth limits stay editable afterwards ([`asset update`](update.md)). Supply, precision, ICO rate, ICO window, and frozen tranches are fixed at issuance — the chain has no way to change them. +- **Participation is the ICO, not a market.** [`asset participate`](participate.md) buys from the issuance at the fixed rate set when the token was created, inside its funding window. There is no order book here; TRX↔TRC10 trading lives in [`exchange`](../exchange/index.md). +- **Transfers are not in this group.** Send a TRC10 with [`tx send --asset-id `](../tx/send.md), the same as any other token. -**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.) +Amounts on the command line and in text output are in **whole tokens**; json carries the on-chain raw value (whole tokens × 10^precision). ## Synopsis @@ -22,16 +24,12 @@ wallet-cli asset COMMAND | 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 update` | [update.md](update.md) | Change the four mutable fields | +| `asset participate` | [participate.md](participate.md) | Buy into a token's ICO with TRX | | `asset unfreeze` | [unfreeze.md](unfreeze.md) | Release matured frozen supply | -| `asset info` | [info.md](info.md) | 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`. +| `asset info` | [info.md](info.md) | Full detail of one TRC10 | +| `asset list` | [list.md](list.md) | List every TRC10 on chain | ## See also -[`token`](../token/index.md) (TRC20) · [`tx send`](../tx/send.md) (TRC10 transfer) · [`exchange`](../exchange/index.md) (trading TRC10 against TRX) +[`tx send`](../tx/send.md) · [`token info`](../token/info.md) · [`exchange`](../exchange/index.md) diff --git a/ts/docs/commands/asset/info.md b/ts/docs/commands/asset/info.md index 23d5548a0..309c2e016 100644 --- a/ts/docs/commands/asset/info.md +++ b/ts/docs/commands/asset/info.md @@ -5,32 +5,31 @@ Show one TRC10 in full. ## Synopsis ``` -wallet-cli asset info [] [--issuer
] [options] +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. +Reports a token's issuance record: issuer, total supply, precision, ICO rate and window, frozen tranches, description, URL, and the two free-bandwidth limits. Read-only, no account needed. -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. +Look it up three ways — by id (an all-digit argument), by name, or by `--issuer` address. Exactly one of `` and `--issuer` is required; giving neither or both is `invalid_value`. Since an account can only ever issue one TRC10, an issuer lookup has a single answer. -**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. +Names are not guaranteed unique on chain. **A name that matches several tokens is an error, not a listing** — the command exits `1` with `ambiguous_asset_name` and prints the candidates so you can re-run with an id. See [the example below](#a-name-that-is-not-unique). -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. +Empty sections are dropped entirely: a token with no frozen tranches shows no `Frozen` block at all. -**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. +Timestamps here are printed to the second (`2026-08-01 00:00:00 UTC`), not to the minute as elsewhere in the CLI. -## Arguments +This is the TRC10-specific counterpart to [`token info`](../token/info.md), which reports the generic metadata (name, symbol, decimals) shared with TRC20 and selects a TRC10 only by `--asset-id`. -| Argument | Description | -|---|---| -| `` | Token id or name; a numeric value is read as the id. Exactly one of this or `--issuer` | +There is no "amount already sold", "remaining supply", or holder count: a node cannot compute any of them reliably — an issuer's plain transfers are indistinguishable from ICO sales when working backwards — so none is reported. For the issuer's current holding, read its balance with [`account balance`](../account/balance.md). ## Options | Option | Description | |---|---| -| `--issuer ` | Look up the token issued by this address. Exactly one of this or `` | +| `` | Token id or name; an all-digit value is read as the id. One of `` / `--issuer` | +| `--issuer
` | The token issued by this address. One of `` / `--issuer` | Plus the [global options](../index.md#global-options-every-command). @@ -42,32 +41,105 @@ By id: wallet-cli asset info 1000123 --network tron:nile ``` -By name — fails with the candidate ids if the name is not unique: +```console +Asset MyToken (id 1000123) + Issuer TQkXm4vN...5Zt7Uw + Total supply 1,000,000,000 + Precision 6 + Price 1 TRX = 100 MyToken + ICO start time 2026-08-01 00:00:00 UTC + ICO end time 2026-08-31 00:00:00 UTC + Url https://mytoken.io + Description Demo TRC10 + Free net/account 0 + Public free net 0 + Frozen (2) + 100,000,000 until 2026-08-31 00:00:00 UTC + 50,000,000 until 2026-10-30 00:00:00 UTC +``` + +### A name that is not unique ```bash wallet-cli asset info MyToken --network tron:nile ``` -By issuer: +The command fails with exit `1`; the message and the candidate table go to **stderr**: + +```console +error [ambiguous_asset_name]: 2 TRC10 tokens are named MyToken; re-run with the id +| ID | Issuer | Total supply | Precision | +| ------- | ---------------------------------- | ------------- | --------- | +| 1000123 | TQkXm4vN2f8LrQ5tYc7bWmXe3sVd9Zt7Uw | 1,000,000,000 | 6 | +| 1000488 | TZx9kP2mR4nJ6vLc8dHqYe1tWbXs5f7bWq | 50,000,000 | 2 | +``` + +In json the same information is in `error.details` — see [Output](#output). + +By issuer — someone else's token here, and it has no frozen tranches: ```bash -wallet-cli asset info --issuer TQkXm4vN...5Zt7Uw --network tron:nile +wallet-cli asset info --issuer TZx9kP2m...7bWq --network tron:nile ``` -Machine-readable: +```console +Asset MyToken (id 1000488) + Issuer TZx9kP2m...7bWq + Total supply 50,000,000 + Precision 2 + Price 1 TRX = 5 MyToken + ICO start time 2026-07-15 00:00:00 UTC + ICO end time 2026-09-15 00:00:00 UTC + Url https://beta.example + Description Another TRC10 + Free net/account 0 + Public free net 0 +``` ```bash wallet-cli asset info 1000123 --network tron:nile -o json ``` -## Errors +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.info","data":{"kind":"asset-info","assetId":"1000123","name":"MyToken","abbr":"MTK","issuerAddress":"TQkXm4vN...","totalSupply":"1000000000000000","precision":6,"price":"1:100","trxNum":1000000,"num":100000000,"startTime":1785542400000,"endTime":1788134400000,"url":"https://mytoken.io","description":"Demo TRC10","freeAssetNetLimit":0,"publicFreeAssetNetLimit":0,"frozenSupply":[{"amount":"100000000000000","days":30,"expireTime":1788134400000},{"amount":"50000000000000","days":90,"expireTime":1793318400000}]},"meta":{"durationMs":26,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` -| 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 | +The ambiguous-name failure, in json: + +```bash +wallet-cli asset info MyToken --network tron:nile -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":false,"command":"asset.info","error":{"code":"ambiguous_asset_name","message":"2 TRC10 tokens are named MyToken; re-run with the id","details":{"name":"MyToken","assetIds":["1000123","1000488"],"matches":[{"assetId":"1000123","issuerAddress":"TQkXm4vN...","totalSupply":"1000000000000000","precision":6},{"assetId":"1000488","issuerAddress":"TZx9kP2m...","totalSupply":"5000000000","precision":2}]}},"meta":{"durationMs":29,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data.kind` is `asset-info`. + +| Field | Type | Meaning | +|---|---|---| +| `assetId` | string | Token id | +| `name` / `abbr` | string | Name and abbreviation as issued. `abbr` is json-only — text has no row for it | +| `issuerAddress` | string | Issuer, base58 | +| `totalSupply` | string | Total supply, raw (whole tokens × 10^`precision`). A **string**: supplies reach int64 and would lose precision as a JSON number | +| `precision` | number | Decimal places, 0–6 | +| `price` | string | The issued rate as `trx:tokens`, in whole units — what text renders as `1 TRX = 100 MyToken` | +| `trxNum` / `num` | number | The same rate exactly as stored on chain, in sun and minimal units. For a `precision` of 6, `1:100` is stored as `1000000` / `100000000` | +| `startTime` / `endTime` | number | ICO window, ms since epoch | +| `url` / `description` | string | Project page and description | +| `freeAssetNetLimit` / `publicFreeAssetNetLimit` | number | Free bandwidth per holder, and the shared pool | +| `frozenSupply[]` | array | `amount` (raw, a **string**), `days`, `expireTime` (ms since epoch). An empty array when there are none | + +There is no `remainingSupply` field. + +A name matching several tokens fails instead of returning data. `error.details` then carries `name`, `assetIds[]` (the ids to re-run with), and `matches[]` — one flat row per candidate with `assetId`, `issuerAddress`, `totalSupply` (raw, string), and `precision`. Text mode renders `matches[]` as the table shown above, scaling each `totalSupply` by its `precision`. + +## Exit status + +`0` success · `1` execution failure (`asset_not_found` — no such token, `ambiguous_asset_name` — the name matches several tokens, `rpc_error`) · `2` usage error (`invalid_value` — neither `` nor `--issuer` given, or both; or `--issuer` is not a valid base58 TRON address). ## See also -[`asset list`](list.md) · [`token info`](../token/info.md) · [`asset` group](index.md) +[`asset list`](list.md) · [`token info`](../token/info.md) · [`asset participate`](participate.md) diff --git a/ts/docs/commands/asset/issue.md b/ts/docs/commands/asset/issue.md index 7a2bcc917..27e1e960e 100644 --- a/ts/docs/commands/asset/issue.md +++ b/ts/docs/commands/asset/issue.md @@ -10,86 +10,108 @@ wallet-cli asset issue --name --supply --price : [--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] + [--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. +Creates a TRC10 token and, in the same transaction, fixes the terms of its ICO: total supply, precision, the TRX-to-token rate, the funding window, and any frozen tranches. -**This 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. +**This cannot be undone.** The issuance fee is burned — the chain parameter `getAssetIssueFee`, currently around 1,024 TRX, readable with [`chain params`](../chain/params.md) — and an account may issue **only one TRC10 in its lifetime**. Afterwards only the description, the URL, and the two free-bandwidth limits can be changed ([`asset update`](update.md)); everything else is permanent. The receipt therefore echoes the complete definition, because that is the final one. -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`.** The chain stores the rate as an integer pair `trxNum` / `num` satisfying `num ÷ trxNum = tokens × 10^precision ÷ (trx × 10^6)`, reduced to lowest terms. So `--price 1:100` is stored as `trxNum=1, num=100` at `--precision 6`, but as `trxNum=10000, num=1` at `--precision 0` — the same flag, a different on-chain rate. Both values must land in the positive int32 range after reduction; otherwise the command fails with `invalid_value` and nothing is broadcast. -**`--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. +Amounts (`--supply`, `--freeze`) are in **whole tokens** — `--supply 1000000000 --precision 6` becomes an on-chain `total_supply` of `1000000000000000`. -`--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. +Dates are read as **UTC**, as `YYYY-MM-DD` or `YYYY-MM-DD HH:mm:ss`; a bare date means `00:00:00`. `--start` must be later than the chain's current time, so a bare date is at the earliest tomorrow — to start a sale the same day, give the time as well. -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. +Constraints are checked locally before broadcast: `--name` and `--abbr` are 1–32 visible ASCII characters (`0x21`–`0x7E`, so no spaces and no non-ASCII); `--url` is required and at most 256 bytes; `--description` at most 200 bytes; `--precision` 0–6; `--end` after `--start`; each `--freeze` tranche's days within `getMinFrozenSupplyTime`…`getMaxFrozenSupplyTime`, the number of tranches within `getMaxFrozenSupplyNumber`, and their sum within the total supply; both free-bandwidth limits below `getOneDayNetLimit`. -**By default the command returns at submission**; `--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`. +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `--name ` | **Required.** Token name, 1–32 visible ASCII characters — 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` | +| `--name ` | **Required.** Token name, 1–32 visible ASCII characters | +| `--supply ` | **Required.** Total supply in whole tokens, > 0 | +| `--price :` | **Required.** ICO rate, whole TRX to whole tokens (e.g. `1:100`); both sides > 0, converted using `--precision` | +| `--start ` | **Required.** ICO start, UTC; must be in the future | +| `--end ` | **Required.** ICO end, UTC; must be after `--start` | +| `--url ` | **Required.** Project page, non-empty, ≤ 256 bytes | +| `--abbr ` | Token abbreviation; same character rules as `--name` (default: empty) | | `--precision <0-6>` | Decimal places (default `0`) | -| `--description ` | Short description, 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` | +| `--description ` | Short description, ≤ 200 bytes (default: empty) | +| `--free-net-per-account ` | Free bandwidth each holder may use (default `0`) | +| `--public-free-net ` | Shared free-bandwidth pool for holders (default `0`) | +| `--freeze :` | **Repeatable.** Frozen tranche; amount in whole tokens, e.g. `100000000:30` | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | -| `--password-stdin` | Master password from stdin | +| `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). ## Examples -In the examples, `$PW` is your master password, fed on stdin via `--password-stdin`. - -Preview before spending anything — always do this first: +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli asset issue --name MyToken --abbr MTK --supply 1000000000 \ - --price 1:100 --precision 6 --start 2026-08-01 --end 2026-08-31 \ - --url https://mytoken.io --dry-run --password-stdin --network tron:nile +echo "$PW" | wallet-cli asset issue --name MyToken --abbr MTK --supply 1000000000 --price 1:100 --precision 6 \ + --start 2026-08-01 --end 2026-08-31 --url https://mytoken.io --description "Demo TRC10" \ + --freeze 100000000:30 --freeze 50000000:90 --network tron:nile --wait --password-stdin ``` -Issue with two frozen tranches, waiting for the id: +```console +✅ Asset issued + Asset MyToken (id 1000123) + Issuer TQkXm4vN...5Zt7Uw (main) + Total supply 1,000,000,000 + Precision 6 + Price 1 TRX = 100 MyToken + ICO start time 2026-08-01 00:00 UTC + ICO end time 2026-08-31 00:00 UTC + Url https://mytoken.io + Description Demo TRC10 + Free net/account 0 + Public free net 0 + Frozen (2) + 100,000,000 for 30 days + 50,000,000 for 90 days + TxID 7d1... + Block 57,883,010 + Fee 1,024 TRX (312 bandwidth) + Status success +``` ```bash -echo "$PW" | wallet-cli asset issue --name MyToken --abbr MTK --supply 1000000000 \ - --price 1:100 --precision 6 --start 2026-08-01 --end 2026-08-31 \ - --url https://mytoken.io --description "Demo TRC10" \ - --freeze 100000000:30 --freeze 50000000:90 \ - --wait --password-stdin --network tron:nile +echo "$PW" | wallet-cli asset issue --name MyToken --abbr MTK --supply 1000000000 --price 1:100 --precision 6 \ + --start 2026-08-01 --end 2026-08-31 --url https://mytoken.io --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.issue","data":{"kind":"asset-issue","stage":"confirmed","txId":"7d1...","confirmed":true,"blockNumber":57883010,"failed":false,"assetId":"1000123","name":"MyToken","abbr":"MTK","totalSupply":1000000000000000,"precision":6,"price":"1:100","trxNum":1,"num":100,"startTime":1785542400000,"endTime":1788134400000,"url":"https://mytoken.io","description":"Demo TRC10","freeAssetNetLimit":0,"publicFreeAssetNetLimit":0,"frozenSupply":[{"amount":100000000000000,"days":30},{"amount":50000000000000,"days":90}],"feeSun":1024000000,"resource":{"netUsage":312,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6720,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` -## Errors +## Output -| Code | Meaning | +`data` varies by stage: + +| Stage | Fields | |---|---| -| `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 | +| default (submit) | `kind: "asset-issue"`, `stage: "submitted"`, `txId`, and the token definition below except `assetId` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed`, and `assetId` — assigned by the chain, so known only once confirmed | + +Definition fields: `name`, `abbr`, `totalSupply` (raw), `precision`, `price` (the `trx:tokens` string as given) with the stored `trxNum` / `num` pair, `startTime` / `endTime` (ms since epoch), `url`, `description`, `freeAssetNetLimit`, `publicFreeAssetNetLimit`, and `frozenSupply[]` (`amount` raw, `days`). + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`already_issued_asset` — this account already issued one, `insufficient_balance` — below the issuance fee, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — a required flag is absent; `invalid_asset_name` — name or abbreviation outside 1–32 visible ASCII; `invalid_value` — rate, precision, dates, bandwidth limits, or frozen tranches out of range, or the rate exceeding int32 after conversion). ## See also -[`asset update`](update.md) · [`asset info`](info.md) · [`asset` group](index.md) +[`asset update`](update.md) · [`asset info`](info.md) · [`asset participate`](participate.md) · [`chain params`](../chain/params.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/asset/list.md b/ts/docs/commands/asset/list.md index 9abdbd286..7fa20b16a 100644 --- a/ts/docs/commands/asset/list.md +++ b/ts/docs/commands/asset/list.md @@ -1,6 +1,6 @@ # wallet-cli asset list -List TRC10 tokens, one page at a time. +List every TRC10 on chain. ## Synopsis @@ -10,49 +10,60 @@ 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. +Lists TRC10 tokens with id, name, total supply, precision, and issuer. Read-only, no account needed. For one token's full issuance record — ICO rate and window, frozen tranches — use [`asset info`](info.md). -**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. +Paging happens on the node, and **there is no total**: the chain exposes no count of TRC10 tokens, and fetching them all to count them is expensive (thousands of tokens, megabytes of response). So the title reports the window it asked for — `Assets (limit 3, offset 0)` — not `showing 3 of N`, and `meta.pagination.total` is always `null`. To get everything, pass a `--limit` large enough to cover it. ## Options | Option | Description | |---|---| -| `--limit ` | Max tokens to return, 1–1000 (default `10`) | +| `--limit ` | Max tokens to return (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 +wallet-cli asset list --limit 3 --network tron:nile ``` -Walk further in: +```console +Assets (limit 3, offset 0) +| ID | Name | Total supply | Precision | Issuer | +| ------- | --------- | ------------- | --------- | ----------------- | +| 1000125 | AlphaCoin | 500,000,000 | 2 | TAlpha7k...3nQw | +| 1000124 | BetaToken | 2,000,000,000 | 6 | TBeta9mR...8pLx | +| 1000123 | MyToken | 1,000,000,000 | 6 | TQkXm4vN...5Zt7Uw | +``` ```bash -wallet-cli asset list --limit 50 --offset 50 --network tron:nile +wallet-cli asset list --limit 3 --network tron:nile -o json ``` -Machine-readable: - -```bash -wallet-cli asset list --limit 50 --network tron:nile -o json +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.list","data":{"kind":"asset-list","assets":[{"assetId":"1000125","name":"AlphaCoin","issuerAddress":"TAlpha7k...","totalSupply":"50000000000","precision":2},{"assetId":"1000124","name":"BetaToken","issuerAddress":"TBeta9mR...","totalSupply":"2000000000000000","precision":6},{"assetId":"1000123","name":"MyToken","issuerAddress":"TQkXm4vN...","totalSupply":"1000000000000000","precision":6}]},"meta":{"durationMs":48,"warnings":[],"pagination":{"offset":0,"limit":3,"total":null}},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` -## Errors +## Output -| Code | Meaning | -|---|---| -| `invalid_value` | `--limit` outside 1–1000, or a negative `--offset` | +`data.kind` is `asset-list`. `data.assets[]` — one entry per token: + +| Field | Type | Meaning | +|---|---|---| +| `assetId` | string | Token id | +| `name` | string | Token name | +| `issuerAddress` | string | Issuer, base58 | +| `totalSupply` | string | Total supply, raw (whole tokens × 10^`precision`). A **string**: supplies reach int64 and would lose precision as a JSON number | +| `precision` | number | Decimal places, 0–6 | + +`meta.pagination` carries `offset`, `limit`, and `total` — `total` is always `null` here, meaning "no count exists", not "zero". + +## Exit status + +`0` success · `1` execution failure (`rpc_error`) · `2` usage error (`invalid_value` — bad limit or offset). ## See also -[`asset info`](info.md) · [`asset` group](index.md) +[`asset info`](info.md) · [`token list`](../token/list.md) diff --git a/ts/docs/commands/asset/participate.md b/ts/docs/commands/asset/participate.md index a9457b51e..3041d4e60 100644 --- a/ts/docs/commands/asset/participate.md +++ b/ts/docs/commands/asset/participate.md @@ -1,78 +1,87 @@ # wallet-cli asset participate -Buy into a TRC10's ICO at its fixed rate. +Buy into a TRC10's ICO with TRX. ## Synopsis ``` wallet-cli asset participate --pay - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] + [--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). +Buys from a token's issuance inside its funding window, at the fixed rate set when it was issued. This is participation in the ICO, not a market trade — the tokens come out of the issuer's remaining supply, and the price is not negotiable. The issuer's address is resolved from the token, so there is nothing to pass for it. -**`--pay` is the TRX you spend, not the tokens you receive.** 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. +**`--pay` is the TRX you spend, not the tokens you receive.** You get `floor(pay × tokens ÷ trx)` where `trx:tokens` is the token's issued rate — the amount paid times the unit price, rounded down, since the chain multiplies before dividing on integers. The TRX is transferred in full, so any truncated remainder is not refunded; the loss is under 1 sun and cannot occur at all when the rate's `trxNum` is 1. If `--pay` is too small to buy even one unit, the command fails locally rather than broadcasting. -The issuer's address is resolved from the token automatically; you never pass it. +The acting account cannot be the token's own issuer. -`` 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 | +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `--pay ` | **Required.** TRX to spend — not the number of tokens | -| `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `` | **Required.** Token id or name; an all-digit value is read as the id | +| `--pay ` | **Required.** TRX to spend (not a token count), > 0 | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | -| `--password-stdin` | Master password from stdin | +| `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). ## Examples -Spend 100 TRX on token 1000124: +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +Spend 100 TRX on a token issued at `1:100`: ```bash -echo "$PW" | wallet-cli asset participate 1000124 --pay 100 \ - --wait --password-stdin --network tron:nile +echo "$PW" | wallet-cli asset participate 1000124 --pay 100 --network tron:nile --wait --password-stdin ``` -Check what you would get before committing: +```console +✅ Participated in ICO + Asset BetaToken (id 1000124) + Issuer TBeta9mR...8pLx + Participant TQkXm4vN...5Zt7Uw (main) + Paid 100 TRX + Received 10,000 BetaToken + TxID 4c8... + Block 57,883,402 + Fee 0 TRX (301 bandwidth) + Status success +``` ```bash -echo "$PW" | wallet-cli asset participate 1000124 --pay 100 \ - --dry-run --password-stdin --network tron:nile +echo "$PW" | wallet-cli asset participate 1000124 --pay 100 --network tron:nile --wait --password-stdin -o json ``` -## Errors +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.participate","data":{"kind":"asset-participate","stage":"confirmed","txId":"4c8...","confirmed":true,"blockNumber":57883402,"failed":false,"assetId":"1000124","name":"BetaToken","issuerAddress":"TBeta9mR...","participantAddress":"TQkXm4vN...","paidSun":100000000,"receivedAmount":10000000000,"feeSun":0,"resource":{"netUsage":301,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6450,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: -| Code | Meaning | +| Stage | Fields | |---|---| -| `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 | +| default (submit) | `kind: "asset-participate"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress`, `participantAddress`, `paidSun`, `receivedAmount` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +`paidSun` is the TRX spent in sun; `receivedAmount` is the token amount in its smallest unit (text shows both in human units). + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`asset_not_found` — no such token, `not_in_ico_window` — outside the funding window, `self_participation` — you issued this token, `insufficient_balance`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--pay`; `invalid_amount` — `--pay` is not a decimal number, or has more than 6 decimal places; `invalid_value` — `--pay` ≤ 0, or too small to buy one unit). ## See also -[`asset info`](info.md) · [`exchange trade`](../exchange/trade.md) · [`asset` group](index.md) +[`asset info`](info.md) · [`tx send`](../tx/send.md) · [`exchange trade`](../exchange/trade.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/asset/unfreeze.md b/ts/docs/commands/asset/unfreeze.md index 585b122b2..b318945e6 100644 --- a/ts/docs/commands/asset/unfreeze.md +++ b/ts/docs/commands/asset/unfreeze.md @@ -6,62 +6,81 @@ Release matured frozen supply of the TRC10 you issued. ``` wallet-cli asset unfreeze - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] + [--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. +Returns the part of the issued supply that was frozen at issuance and whose lock period is over, back to the issuer'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 is no argument of any kind: the command always targets the token issued by the signing account, and the chain accepts neither "which tranche" nor "how much" — **every matured tranche is released in one transaction**. Tranches that have not matured are untouched; run the command again once they are. -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 matures at its issuance `--start` plus its `days`, not at the moment the token was actually issued: the chain writes each tranche's `expire_time` as `start_time + days × 86400000` when the token is created. The resulting dates are visible in the `Frozen` section of [`asset info`](info.md). -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. +This is unrelated to [`stake unfreeze`](../stake/unfreeze.md), which releases staked TRX; the only thing they share is the word. -**By default the command returns at submission**; `--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`. +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options +This command has no options of its own. + | Option | Description | |---|---| -| `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | -| `--password-stdin` | Master password from stdin | +| `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). ## Examples -Check what has matured before spending bandwidth: +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -wallet-cli asset info --issuer TQkXm4vN...5Zt7Uw --network tron:nile +echo "$PW" | wallet-cli asset unfreeze --network tron:nile --wait --password-stdin ``` -Release everything that has matured: +```console +✅ Frozen supply released + Asset MyToken (id 1000123) + Issuer TQkXm4vN...5Zt7Uw (main) + Released 100,000,000 MyToken + Still frozen 50,000,000 MyToken + TxID 6a5... + Block 57,883,560 + Fee 0 TRX (288 bandwidth) + Status success +``` ```bash -echo "$PW" | wallet-cli asset unfreeze --wait --password-stdin --network tron:nile +echo "$PW" | wallet-cli asset unfreeze --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.unfreeze","data":{"kind":"asset-unfreeze","stage":"confirmed","txId":"6a5...","confirmed":true,"blockNumber":57883560,"failed":false,"assetId":"1000123","name":"MyToken","issuerAddress":"TQkXm4vN...","releasedAmount":100000000000000,"stillFrozenAmount":50000000000000,"feeSun":0,"resource":{"netUsage":288,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6410,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` -## Errors +## Output -| Code | Meaning | +`data` varies by stage: + +| Stage | Fields | |---|---| -| `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 | +| default (submit) | `kind: "asset-unfreeze"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed`, `releasedAmount`, `stillFrozenAmount` | + +`releasedAmount` and `stillFrozenAmount` are raw amounts (smallest unit) and reflect what the confirmed transaction actually did. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_an_issuer` — this account has not issued a TRC10, `no_frozen_supply`, `not_yet_unfreezable` — nothing has matured yet, `watch_only_no_signer`, `auth_failed`) · `2` usage error. ## See also -[`asset info`](info.md) · [`asset issue`](issue.md) · [`stake unfreeze`](../stake/unfreeze.md) (a different thing) · [`asset` group](index.md) +[`asset info`](info.md) · [`asset issue`](issue.md) · [`stake unfreeze`](../stake/unfreeze.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/asset/update.md b/ts/docs/commands/asset/update.md index a50e43ad4..243bc60c6 100644 --- a/ts/docs/commands/asset/update.md +++ b/ts/docs/commands/asset/update.md @@ -1,71 +1,89 @@ # wallet-cli asset update -Update the mutable fields of the TRC10 you issued. +Change the mutable fields of the TRC10 you issued. ## Synopsis ``` wallet-cli asset update [--description ] [--url ] [--free-net-per-account ] [--public-free-net ] - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] + [--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 TRC10 issued by the signing account. An account that has not issued one fails with `not_an_issuer`. -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. +**Only four fields can ever change** — description, URL, free bandwidth per holder, and the shared free-bandwidth pool. Supply, precision, ICO rate, ICO window, and frozen tranches were fixed at issuance and the chain offers no way to alter them. -**Pass only the fields you 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. +Pass only the fields you are changing. The others are read from chain and written back unchanged, so nothing is silently cleared; at least one field is required. The receipt shows all four as they now stand. -**By default the command returns at submission**; `--wait` blocks until confirmed. - -**Ledger accounts are refused** (`ledger_unsupported`): the Ledger TRON app cannot decode `UpdateAssetContract`. +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `--description ` | New description, 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` | +| `--description ` | New description, ≤ 200 bytes (unchanged if omitted) | +| `--url ` | New project page, non-empty, ≤ 256 bytes (unchanged if omitted) | +| `--free-net-per-account ` | Free bandwidth each holder may use (unchanged if omitted) | +| `--public-free-net ` | Shared free-bandwidth pool for holders (unchanged if omitted) | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | -| `--password-stdin` | Master password from stdin | +| `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). ## Examples -Change only the URL; the other three keep their current values: +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 \ - --wait --password-stdin --network tron:nile +echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 --network tron:nile --wait --password-stdin ``` -Raise both bandwidth allowances at once: +```console +✅ Asset updated + Asset MyToken (id 1000123) + Issuer TQkXm4vN...5Zt7Uw (main) + Url https://mytoken.io/v2 + Description Demo TRC10 + Free net/account 0 + Public free net 0 + TxID 9e3... + Block 57,883,190 + Fee 0 TRX (295 bandwidth) + Status success +``` ```bash -echo "$PW" | wallet-cli asset update --free-net-per-account 1000 --public-free-net 10000 \ - --wait --password-stdin --network tron:nile +echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.update","data":{"kind":"asset-update","stage":"confirmed","txId":"9e3...","confirmed":true,"blockNumber":57883190,"failed":false,"assetId":"1000123","name":"MyToken","issuerAddress":"TQkXm4vN...","url":"https://mytoken.io/v2","description":"Demo TRC10","freeAssetNetLimit":0,"publicFreeAssetNetLimit":0,"feeSun":0,"resource":{"netUsage":295,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6480,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` -## Errors +## Output -| Code | Meaning | +`data` varies by stage: + +| Stage | Fields | |---|---| -| `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 | +| default (submit) | `kind: "asset-update"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress`, and the four fields as submitted | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +The four fields are `url`, `description`, `freeAssetNetLimit`, and `publicFreeAssetNetLimit` — always all four, including the ones read back unchanged. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_an_issuer` — this account has not issued a TRC10, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no field given; `invalid_value` — URL or description too long, bandwidth limits out of range). ## See also -[`asset issue`](issue.md) · [`asset info`](info.md) · [`asset` group](index.md) +[`asset issue`](issue.md) · [`asset info`](info.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/backup.md b/ts/docs/commands/backup.md index 8e2065347..148d8aaf3 100644 --- a/ts/docs/commands/backup.md +++ b/ts/docs/commands/backup.md @@ -1,60 +1,66 @@ # wallet-cli backup -Export an account's secret to a 0600 file — natively, or as a standard Web3 keystore. With `--records`, list past exports instead. +Export an account's secret to a 0600 file, or review past exports. ## Synopsis ``` -wallet-cli backup [--keystore] [--out ] [options] -wallet-cli backup --records [options] +wallet-cli backup [--keystore] [--out ] [--password-stdin] [options] +wallet-cli backup --records [] [--from ] [--to ] [--limit ] [--offset ] [--account ] [options] ``` -## Arguments +## Description -- `account` — account or wallet to export, by accountId, label, or address. Required unless `--records` is given; with `--records` it selects **whose** exports to list. +With an account, `backup` writes that account's secret material and metadata to a file created with mode **0600**, never overwriting an existing one. The secret goes only into the file — never to stdout. Watch-only and Ledger accounts have no secret to export and fail with `not_exportable` — checked before any password is demanded, so an account that cannot be exported never costs you a prompt. + +Two formats: + +- **Native** (default) — the wallet's own backup JSON. A seed account exports its recovery phrase, so the whole seed moves with it. +- **`--keystore`** — a standard Web3 keystore JSON, importable by TronLink and others, encrypted with **your master password**. A keystore holds a **single private key**: an HD account exports only its current derived key, and that key arrives elsewhere as a standalone account with nothing derivable from it. Use the native format to move a seed. + +**Files land in the current working directory** by default — `./-.json`, or `./-.keystore.json` with `--keystore`. `--out` overrides the path. + +> A file holding a private key or recovery phrase is now sitting in your working directory. Do not run this in a shared directory or inside a git repository: the CLI guarantees mode 0600 and refuses to overwrite, but it does not check whether the directory is safe or version-controlled. Move the file to secure storage and treat it as the key itself — see [Security](../concepts/security.md). + +With `--records` and no account, nothing is exported: the command lists the **local audit log of past exports** instead. One row per `backup` and `backup --keystore`, newest first, recording which account's secret left, when, and **which file it went to**. Imports are not logged — the log's purpose is a trail of secrets leaving. It keeps the most recent 1000 entries and drops the oldest beyond that. `Exported account` is the account whose secret was exported, and `--account` filters on it. + +**The two forms do not mix, and the CLI enforces that in both directions:** + +- `--keystore` and `--out` describe an export, so combining either with `--records` fails rather than being silently ignored. +- `--from` / `--to` / `--limit` / `--offset` filter the log, so any of them **without** `--records` fails too. + +Both are `invalid_value` at exit `2`, and the message names the offending flag — for example `invalid --offset: --offset filters the export log; it needs --records`. + +The positional account is the exception: it means different things in the two forms rather than conflicting with `--records`. `backup main` exports `main`'s secret; `backup main --records` lists `main`'s past exports, exactly as `--account main` would. ## Options | Option | Description | |---|---| -| `--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) | +| `` | Account to export, by accountId, label, or address. Required unless `--records`; **with** `--records` it filters the log instead, like `--account` | +| `--keystore` | Export as a standard Web3 keystore instead of the native format | +| `--out ` | Output file path; mode 0600, never overwritten (default: the current directory, see above) | +| `--password-stdin` | Master password from stdin (fd 0) | -Records options (with `--records`, instead of exporting): +With `--records`, instead of an account: | 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 | +| `--records` | List past exports instead of exporting | +| `--from ` | Only records at or after this time, `YYYY-MM-DD[ HH:mm:ss]`, UTC | +| `--to ` | Only records at or before this time, same format | +| `--limit ` | Max records to return (default: all) | | `--offset ` | Pagination offset (default `0`) | | `--account ` | Only exports of this account, by accountId / label / address | -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. +Plus the [global options](index.md#global-options-every-command). ## Examples In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. +Native export of a seed account — the recovery phrase: + ```bash printf '%s' "$PW" | wallet-cli backup main --password-stdin ``` @@ -69,56 +75,58 @@ printf '%s' "$PW" | wallet-cli backup main --password-stdin ⚠️ Secret material was written only to the backup file, never to stdout. ``` +As a keystore instead — a single private key: + ```bash printf '%s' "$PW" | wallet-cli backup main --keystore --password-stdin ``` ```console -⚠️ Keystore written ./wlt_d1qbj2fb.0-1783751611076.keystore.json +⚠️ Keystore written ./wlt_d1qbj2fb.0-1785930000.keystore.json Account ID wlt_d1qbj2fb.0 Secret private key File mode 0600 - Bytes 608 + Bytes 491 ⚠️ Secret material was written only to the keystore file, never to stdout. ``` ```bash -printf '%s' "$PW" | wallet-cli backup main --out ./main-backup.json --password-stdin -o json +printf '%s' "$PW" | wallet-cli backup main --keystore --out ./main.keystore.json --password-stdin -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"backup","data":{"accountId":"wlt_d1qbj2fb.0","label":"main","type":"seed","index":0,"active":true,"addresses":{"tron":"TJToBi4Ngr6JT3HqZHfCkKvuQTvqm73HHp"},"seedId":"wlt_d1qbj2fb","secretType":"mnemonic","format":"native","out":"./main-backup.json","fileMode":"0600","bytes":277},"meta":{"durationMs":1387,"warnings":[]}} +{"schema":"wallet-cli.result.v1","success":true,"command":"backup","data":{"accountId":"wlt_d1qbj2fb.0","label":"main","type":"seed","index":0,"active":true,"addresses":{"tron":"TQkXm4vN...5Zt7Uw"},"seedId":"wlt_d1qbj2fb","secretType":"privateKey","format":"keystore","out":"./main.keystore.json","fileMode":"0600","bytes":491},"meta":{"durationMs":1420,"warnings":[]}} ``` +The audit log: + ```bash wallet-cli backup --records --limit 3 ``` ```console Backup records (showing 3 of 12) -| Time (UTC) | Exported account | Operation | File | -| ---------------- | ---------------------------- | ----------------- | --------------------------------------------- | -| 2026-08-05 11:40 | 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 | +| Time (UTC) | Exported account | Operation | File | +| ---------------- | ------------------------ | ----------------- | ----------------------------------------- | +| 2026-08-05 11:40 | TQkXm4vN...5Zt7Uw (main) | backup --keystore | ./wlt_d1qbj2fb.0-1785930000.keystore.json | +| 2026-08-04 09:12 | TQkXm4vN...5Zt7Uw (main) | backup | ./wlt_d1qbj2fb.0-1785834720.json | +| 2026-07-30 22:03 | TBeta9mR...8pLx | backup | ./tbeta-seed.json | ``` ```bash -wallet-cli backup --records --account main --from 2026-08-01 -o json +wallet-cli backup --records --limit 3 -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"backup.records","data":{"records":[{"operation":"backup --keystore","accountId":"wlt_d1qbj2fb.0","account":"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}}} +{"schema":"wallet-cli.result.v1","success":true,"command":"backup.records","data":{"records":[{"operation":"backup --keystore","accountId":"wlt_d1qbj2fb.0","account":"TQkXm4vN...5Zt7Uw","label":"main","out":"./wlt_d1qbj2fb.0-1785930000.keystore.json","timestamp":"2026-08-05T11:40:00Z"},{"operation":"backup","accountId":"wlt_d1qbj2fb.0","account":"TQkXm4vN...5Zt7Uw","label":"main","out":"./wlt_d1qbj2fb.0-1785834720.json","timestamp":"2026-08-04T09:12:00Z"},{"operation":"backup","accountId":"wlt_9x3k2m7p.0","account":"TBeta9mR...8pLx","label":null,"out":"./tbeta-seed.json","timestamp":"2026-07-30T22:03:00Z"}]},"meta":{"durationMs":8,"warnings":[],"pagination":{"offset":0,"limit":3,"total":12}}} ``` ## Output -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]`) +Both forms are local commands — no `chain` block — and they carry different `command` ids: `backup` for an export, `backup.records` for the log. -`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. +`data` for an export is the account plus the file's details: | Field | Type | Meaning | |---|---|---| @@ -129,35 +137,29 @@ The two modes return **different shapes** and therefore different `command` ids: | `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: `mnemonic` or `privateKey` (always `privateKey` with `--keystore`) | -| `format` | string | `"native"` or `"keystore"` | -| `out` | string | Written file path | +| `secretType` | string | Kind of exported secret — `mnemonic`, or `privateKey` with `--keystore` | +| `format` | string | `keystore` when `--keystore` was used | +| `out` | string | Path written | | `fileMode` | string | File permissions, always `0600` | | `bytes` | number | File size in bytes | -### 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). +`data.records[]` for `--records`: | 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. +| `operation` | string | `backup` or `backup --keystore` | +| `accountId` / `account` / `label` | string \| null | The account whose secret was exported; `label` is `null` when unset | +| `out` | string | File the secret went to | +| `timestamp` | string | Export time, UTC | -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. +`meta.pagination` carries `offset`, `limit` (`null` = unlimited), and `total`. ## Exit status -`0` success · `1` execution failure · `2` usage error. See [machine-interface](../machine-interface.md). +`0` success · `1` execution failure (`not_exportable` — watch-only or Ledger, `invalid_value` — no such account, `auth_failed`, `io_error` — path not writable) · `2` usage error (`output_exists` — the target file already exists and is never overwritten; `invalid_value` — a record filter without `--records`, `--keystore` / `--out` with `--records`, or a bad time / limit / offset). -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`). +`invalid_value` appears under both exit codes here: an unresolvable account reference is exit `1`, a malformed call is exit `2`. Branch on the exit code first. ## See also -[Security model](../concepts/security.md) · [`import keystore`](import/keystore.md) · [`import mnemonic`](import/mnemonic.md) · [`delete`](delete.md) +[Security model](../concepts/security.md) · [`import keystore`](import/keystore.md) · [`delete`](delete.md) diff --git a/ts/docs/commands/chain/params.md b/ts/docs/commands/chain/params.md index 40eb3d823..9628ca6ba 100644 --- a/ts/docs/commands/chain/params.md +++ b/ts/docs/commands/chain/params.md @@ -52,12 +52,13 @@ wallet-cli chain params --network tron:nile ``` ```console -Key Value -getEnergyFee 210 SUN -getTransactionFee 1,000 SUN -getCreateAccountFee 100,000 SUN -getWitnessPayPerBlock 16,000,000 SUN -getMaintenanceTimeInterval 21,600,000 ms +| Key | Value | +| -------------------------- | -------------- | +| getEnergyFee | 210 SUN | +| getTransactionFee | 1,000 SUN | +| getCreateAccountFee | 100,000 SUN | +| getWitnessPayPerBlock | 16,000,000 SUN | +| getMaintenanceTimeInterval | 21,600,000 ms | ``` ```bash diff --git a/ts/docs/commands/contract/clear-abi.md b/ts/docs/commands/contract/clear-abi.md index 2ae192e32..891620dc8 100644 --- a/ts/docs/commands/contract/clear-abi.md +++ b/ts/docs/commands/contract/clear-abi.md @@ -1,35 +1,79 @@ # wallet-cli contract clear-abi -Irreversibly remove a contract's on-chain ABI metadata. +Clear the ABI a contract stores on chain. ## Synopsis ``` -wallet-cli contract clear-abi
[--dry-run | --sign-only | --build-only] [options] +wallet-cli contract clear-abi
+ [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [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. +Removes the ABI held on chain for a contract. **This cannot be undone** — the ABI is gone from the chain, and anything that decoded calls by reading it (explorers, SDKs, [`contract call`](call.md)) must supply its own from then on. + +What it does **not** touch: the bytecode and the contract's state are unaffected, and the contract stays callable exactly as before. The ABI is auxiliary metadata, not part of execution. + +Only the contract's deployer can do this — the address the chain records as the contract's origin, visible in [`contract info`](info.md). Other accounts fail with `not_contract_deployer`. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options -`
` 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`. +| Option | Description | +|---|---| +| `
` | **Required.** Contract whose ABI to clear | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). -## Example +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV... --network tron:nile --wait --password-stdin +echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV...4wRe --network tron:nile --wait --password-stdin +``` + +```console +✅ ABI cleared + Contract TQ5nJ8mV...4wRe + Deployer TQkXm4vN...5Zt7Uw (main) + TxID 3f7... + Block 57,882,140 + Fee 0 TRX (287 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV...4wRe --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.clear-abi","data":{"kind":"contract-clear-abi","stage":"confirmed","txId":"3f7...","confirmed":true,"blockNumber":57882140,"failed":false,"contractAddress":"TQ5nJ8mV...","deployerAddress":"TQkXm4vN...","feeSun":0,"resource":{"netUsage":287,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6510,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output -Returns `kind: "contract-clear-abi"`, contract/deployer addresses, transaction stage/id, and confirmed resource usage. +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "contract-clear-abi"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | ## Exit status -`0` built/signed/submitted · `1` `contract_not_found`, `not_contract_deployer`, signer/auth, RPC, or chain failure · `2` invalid input. +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address). ## See also -[`contract info`](info.md) · [`contract set-origin-energy-limit`](set-origin-energy-limit.md) +[`contract info`](info.md) · [`contract deploy`](deploy.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/contract/create2.md b/ts/docs/commands/contract/create2.md index cac613dd2..67176d4ff 100644 --- a/ts/docs/commands/contract/create2.md +++ b/ts/docs/commands/contract/create2.md @@ -1,48 +1,82 @@ # wallet-cli contract create2 -Compute a TVM CREATE2 contract address locally. +Compute the address a CREATE2 deployment would land on. ## Synopsis ``` -wallet-cli contract create2 --deployer
(--code | --code-file ) --salt +wallet-cli contract create2 --deployer
(--code | --code-file ) --salt [options] ``` ## 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: +Pure local arithmetic: no node is contacted, nothing is broadcast, and no account or password is involved. The result is the same on every TRON network, so `--network` does not affect it. + +**TRON's derivation is not Ethereum's** — do not compute it with an EVM calculator. The address is ``` -keccak256(deployer_21_bytes || salt_32_bytes || keccak256(creation_code)) +sha3omit12( deployer (21 bytes, 0x41-prefixed) ‖ salt (32 bytes) ‖ keccak256(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. +where `sha3omit12` takes bytes `[11:32]` of the keccak256 digest, overwrites the first byte with `0x41`, and Base58Check-encodes the result. There is no `0xff` prefix: the 21-byte `0x41`-prefixed deployer already separates the domain. The same deployer, salt, and code therefore yield different addresses on TRON and Ethereum. + +**The code must be the creation bytecode with constructor arguments already appended** — not the runtime bytecode. One byte of difference in the constructor arguments gives an entirely different address. Creation bytecode usually runs to tens of thousands of characters, which is why `--code-file` exists; a `0x` prefix and any whitespace are stripped from either form. + +`--salt` is a decimal integer (64-bit signed). It is placed in the low bytes of a 32-byte salt with the rest zero-filled; hex salts are not accepted. + +Deploying with CREATE2 itself requires the chain to have TVM Constantinople enabled, but this command is arithmetic only and is not subject to that. ## Options | Option | Description | |---|---| -| `--deployer
` | Required 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 | +| `--deployer
` | **Required.** Address performing the CREATE2 — a factory contract or a plain account | +| `--code ` | Creation bytecode, constructor arguments included. One of `--code` / `--code-file` | +| `--code-file ` | Read the creation bytecode from a file — preferred, since it is usually very long. One of `--code` / `--code-file` | +| `--salt ` | **Required.** Salt as a decimal integer, zero-padded to 32 bytes | -## Example +Plus the [global options](../index.md#global-options-every-command). + +## Examples ```bash -wallet-cli contract create2 --deployer TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t --code 60006000 --salt 1 -o json +wallet-cli contract create2 --deployer TQkXm4vN...5Zt7Uw --code-file ./MyToken.creation.hex --salt 1 +``` + +```console +Contract address (CREATE2) + Deployer TQkXm4vN...5Zt7Uw + Salt 1 (0x000000…0001) + Code hash c8f4a1...b91b + Address TXm5RQ7d...9kPa ``` -The example resolves to `TFVMEWMJCq5fCmADjNzuhKnUFHJkJBBFAW`. +Short bytecode can go inline instead: + +```bash +wallet-cli contract create2 --deployer TQkXm4vN...5Zt7Uw --code 6080604052... --salt 255 -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.create2","data":{"deployerAddress":"TQkXm4vN...","salt":255,"saltHex":"0x00000000000000000000000000000000000000000000000000000000000000ff","codeHash":"c8f4a1...b91b","address":"TWq8dK3n...2mHb"},"meta":{"durationMs":3,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` ## Output -Returns `deployerAddress`, decimal `salt`, zero-padded `saltHex`, `codeHash`, and Base58Check `address`. +| Field | Type | Meaning | +|---|---|---| +| `deployerAddress` | string | The deployer as given, base58 | +| `salt` | number | The salt as given, decimal | +| `saltHex` | string | The zero-padded 32 bytes that actually enter the hash | +| `codeHash` | string | `keccak256` of the creation bytecode | +| `address` | string | The resulting contract address, base58 | + +This is a local command, so the envelope carries no `chain` block. ## Exit status -`0` success · `2` `invalid_address`, `invalid_value`, or `file_not_found`. +`0` success · `1` execution failure (`io_error` — `--code-file` cannot be read) · `2` usage error (`missing_option` — no `--deployer` / `--salt`, or neither code source; `invalid_option` — both `--code` and `--code-file`; `invalid_value` — malformed deployer address, non-hex code, or a salt outside the 64-bit signed range). ## See also -[`contract deploy`](deploy.md) · [`contract info`](info.md) +[`contract deploy`](deploy.md) · [`contract info`](info.md) · [`encoding convert`](../encoding/convert.md) diff --git a/ts/docs/commands/contract/deploy.md b/ts/docs/commands/contract/deploy.md index 0dc692930..fc98a9b7b 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -14,7 +14,12 @@ 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), 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. +Two shapes are checked before anything is built, both reported as `invalid_value` at exit `2`: + +- **`--params` takes raw positional values here**, e.g. `[100, "T..."]`. The `{"type","value"}` entries that [`contract call`](call.md) and [`contract send`](send.md) take are rejected — deploy reads the types from the ABI's constructor instead. +- **The ABI's `constructor` entry needs a string `stateMutability`** (`"nonpayable"` or `"payable"`). `solc` emits it; an ABI that was hand-trimmed, or produced by `solc` older than 0.5, may not have it. + +Same execution model as other broadcast commands: `--dry-run` previews, `--sign-only` outputs a signed transaction for [`tx broadcast`](../tx/broadcast.md), default returns at submission, `--wait` blocks until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. @@ -68,13 +73,10 @@ 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 `tx`, `hex`, fee estimate, `contractAddress` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value` — bad ABI/bytecode/params, missing `--fee-limit`). +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`missing_option` — no `--fee-limit`; `invalid_value` — bad ABI or bytecode, `--params` in `{"type","value"}` form, or an ABI constructor without a string `stateMutability`). ## See also diff --git a/ts/docs/commands/contract/index.md b/ts/docs/commands/contract/index.md index 8fcb0e66d..380e512e0 100644 --- a/ts/docs/commands/contract/index.md +++ b/ts/docs/commands/contract/index.md @@ -1,6 +1,8 @@ # wallet-cli contract -Call, deploy, inspect, and govern smart contracts. +Call, send, deploy, inspect, and govern smart contracts. + +The governing part is the deployer's: who pays a call's energy, and whether the contract keeps an ABI on chain. Those settings belong to the account that deployed the contract and take effect as soon as the transaction confirms. `create2` is unrelated to any of that — it is local arithmetic over an address that does not exist yet. ## Synopsis @@ -16,10 +18,10 @@ wallet-cli contract COMMAND | `contract send` | [send.md](send.md) | State-changing call (triggerSmartContract) | | `contract deploy` | [deploy.md](deploy.md) | Deploy a smart contract | | `contract info` | [info.md](info.md) | Show contract ABI + metadata | -| `contract clear-abi` | [clear-abi.md](clear-abi.md) | 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 | +| `contract clear-abi` | [clear-abi.md](clear-abi.md) | Clear the on-chain ABI (irreversible) | +| `contract set-origin-energy-limit` | [set-origin-energy-limit.md](set-origin-energy-limit.md) | Energy the deployer covers per call | +| `contract set-user-resource-percent` | [set-user-resource-percent.md](set-user-resource-percent.md) | Share of a call's energy paid by the caller | +| `contract create2` | [create2.md](create2.md) | Compute a CREATE2 address locally | ## See also diff --git a/ts/docs/commands/contract/send.md b/ts/docs/commands/contract/send.md index c76ed27f3..287c24240 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. -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. +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). **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. diff --git a/ts/docs/commands/contract/set-origin-energy-limit.md b/ts/docs/commands/contract/set-origin-energy-limit.md index b1eac02d0..a71aa6af5 100644 --- a/ts/docs/commands/contract/set-origin-energy-limit.md +++ b/ts/docs/commands/contract/set-origin-energy-limit.md @@ -1,41 +1,85 @@ # wallet-cli contract set-origin-energy-limit -Set the deployer's per-call energy contribution cap. +Set the energy the deployer will cover per call. ## Synopsis ``` wallet-cli contract set-origin-energy-limit
- [--dry-run | --sign-only | --build-only] [options] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [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. +Sets `origin_energy_limit` — the ceiling on how much energy the **deployer** is willing to pay for a single call to this contract. -## Arguments +It is not a cap on the contract, and not a cap on the caller. What the deployer actually covers is bounded by three things at once: this limit, the deployer's own staked energy, and the caller/deployer split from [`contract set-user-resource-percent`](set-user-resource-percent.md). Whatever the deployer's side cannot cover falls back to the caller. Two ways this ends up doing nothing: the deployer has no staked energy (the subsidy is zero regardless of this limit), or the user share is 100 % (the deployer's portion is zero, so this limit never comes into play). -| Argument | Description | +`` must be an integer **greater than zero** — the chain rejects zero, and it is refused locally rather than broadcast. + +Only the contract's deployer can do this; the current value is in [`contract info`](info.md). Settings take effect as soon as the transaction confirms. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | |---|---| -| `address` | Contract governed by the selected deployer account | -| `energy` | Positive signed-int64 energy cap | +| `
` | **Required.** Contract to configure; you must be its deployer | +| `` | **Required.** Per-call energy the deployer will cover, integer > 0 | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | -Transaction controls are `--dry-run`, `--sign-only`, `--build-only`, `--expiration` (build/sign-only, max 24 h), `--permission-id`, `--account`, `--wait`, and `--password-stdin`. +Plus the [global options](../index.md#global-options-every-command). -## Example +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV... 50000000 --network tron:nile --wait --password-stdin +echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV...4wRe 50000000 --network tron:nile --wait --password-stdin +``` + +```console +✅ Origin energy limit set + Contract TQ5nJ8mV...4wRe + Deployer TQkXm4vN...5Zt7Uw (main) + Energy limit 50,000,000 + TxID 3a9... + Block 57,882,265 + Fee 0 TRX (290 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV...4wRe 50000000 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.set-origin-energy-limit","data":{"kind":"contract-set-origin-energy-limit","stage":"confirmed","txId":"3a9...","confirmed":true,"blockNumber":57882265,"failed":false,"contractAddress":"TQ5nJ8mV...","deployerAddress":"TQkXm4vN...","originEnergyLimit":50000000,"feeSun":0,"resource":{"netUsage":290,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6530,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output -Returns contract/deployer addresses, `originEnergyLimit`, transaction stage/id, and confirmed resource usage. +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "contract-set-origin-energy-limit"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress`, `originEnergyLimit` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +`originEnergyLimit` is the value now in effect. ## Exit status -`0` 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. +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address, or energy not an integer > 0). ## See also -[`contract set-user-resource-percent`](set-user-resource-percent.md) · [Energy & bandwidth](../../concepts/energy-bandwidth.md) +[`contract set-user-resource-percent`](set-user-resource-percent.md) · [`contract info`](info.md) · [Energy & bandwidth](../../concepts/energy-bandwidth.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/contract/set-user-resource-percent.md b/ts/docs/commands/contract/set-user-resource-percent.md index ee0b61827..0877f2df4 100644 --- a/ts/docs/commands/contract/set-user-resource-percent.md +++ b/ts/docs/commands/contract/set-user-resource-percent.md @@ -1,41 +1,87 @@ # wallet-cli contract set-user-resource-percent -Set the percentage of call energy paid by the caller. +Set the share of a call's energy paid by the caller. ## Synopsis ``` wallet-cli contract set-user-resource-percent
- [--dry-run | --sign-only | --build-only] [options] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [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. +Sets `consume_user_resource_percent`: the percentage of a call's energy the **caller** pays. The remainder is covered by the deployer, itself capped by [`contract set-origin-energy-limit`](set-origin-energy-limit.md) and by the deployer's staked energy. -## Arguments +`100` means callers pay everything and the deployer subsidises nothing — which also makes the origin energy limit irrelevant. `0` means the deployer pays everything within those caps. The value is an integer 0–100, validated locally. -| Argument | Description | +The number is the **caller's** share, matching the chain field's own direction; it is not inverted by this CLI. + +Only the contract's deployer can do this; the current value is in [`contract info`](info.md). Settings take effect as soon as the transaction confirms. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | |---|---| -| `address` | Contract governed by the selected deployer account | -| `percent` | Integer 0–100 paid by the caller | +| `
` | **Required.** Contract to configure; you must be its deployer | +| `` | **Required.** Share of energy paid by the caller, integer 0–100 | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +Callers pay the full energy cost: -Transaction controls are `--dry-run`, `--sign-only`, `--build-only`, `--expiration` (build/sign-only, max 24 h), `--permission-id`, `--account`, `--wait`, and `--password-stdin`. +```bash +echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV...4wRe 100 --network tron:nile --wait --password-stdin +``` -## Example +```console +✅ User pay ratio set + Contract TQ5nJ8mV...4wRe + Deployer TQkXm4vN...5Zt7Uw (main) + User pays 100% + TxID 8b2... + Block 57,882,388 + Fee 0 TRX (289 bandwidth) + Status success +``` ```bash -echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV... 100 --network tron:nile --wait --password-stdin +echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV...4wRe 100 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.set-user-resource-percent","data":{"kind":"contract-set-user-resource-percent","stage":"confirmed","txId":"8b2...","confirmed":true,"blockNumber":57882388,"failed":false,"contractAddress":"TQ5nJ8mV...","deployerAddress":"TQkXm4vN...","consumeUserResourcePercent":100,"feeSun":0,"resource":{"netUsage":289,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6470,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output -Returns contract/deployer addresses, `consumeUserResourcePercent`, transaction stage/id, and confirmed resource usage. +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "contract-set-user-resource-percent"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress`, `consumeUserResourcePercent` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +`consumeUserResourcePercent` is the value now in effect — the caller's share. ## Exit status -`0` built/signed/submitted · `1` `contract_not_found`, `not_contract_deployer`, signer/auth, RPC, or chain failure · `2` percentage or mode error. +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address, or percent outside 0–100). ## See also -[`contract set-origin-energy-limit`](set-origin-energy-limit.md) · [Energy & bandwidth](../../concepts/energy-bandwidth.md) +[`contract set-origin-energy-limit`](set-origin-energy-limit.md) · [`contract info`](info.md) · [Energy & bandwidth](../../concepts/energy-bandwidth.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/exchange/create.md b/ts/docs/commands/exchange/create.md index 811f6d095..3615a8a90 100644 --- a/ts/docs/commands/exchange/create.md +++ b/ts/docs/commands/exchange/create.md @@ -1,70 +1,91 @@ # wallet-cli exchange create -Create a Bancor pair and seed both sides. +Create a Bancor exchange pair and seed both sides. ## Synopsis ``` -wallet-cli exchange create --pair : (--amounts : | --raw-amounts :) - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] +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. +Creates a pair and puts the initial liquidity into it in the same transaction. Either side may be TRX or a TRC10 asset id, and the two must differ. Any account can create a pair. -**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. +**The creator binding is permanent.** From this point on, only the creating account can [inject](inject.md) or [withdraw](withdraw.md) this pair's liquidity, and the chain offers no way to move that right to another account. Creating from the wrong account leaves the liquidity under that account for good. -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 creation fee is **burned** — the chain parameter `getExchangeCreateFee`, currently around 1,024 TRX, readable with [`chain params`](../chain/params.md) — and both initial amounts leave your account on top of it. -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. +`--pair` and `--amounts` are positional to each other: `--pair TRX:1000123 --amounts 10000:500000` puts 10,000 on the TRX side and 500,000 on asset 1000123's side. That ratio is the pair's starting quote — here roughly 1 TRX to 50 tokens — and every trade thereafter moves it. -**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`. +**Tokens are named by id only** — `TRX` (or its on-chain id `_`) and a numeric TRC10 id. A TRC10 name may itself contain `:`, which would make `--pair` ambiguous; find an id with [`asset info `](../asset/info.md). + +`--amounts` is in whole tokens and is converted using each side's precision; `--raw-amounts` gives the same two numbers in minimal units. Exactly one of them is required. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `--pair :` | **Required.** The two sides — `TRX` or a 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` | +| `--pair :` | **Required.** The two sides — `TRX` or a TRC10 asset id; they must differ | +| `--amounts :` | Amount for each side in whole tokens, in `--pair` order; both > 0. Debited from your account and become the pair's reserves. One of `--amounts` / `--raw-amounts` | +| `--raw-amounts :` | The same two amounts in minimal units. One of `--amounts` / `--raw-amounts` | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | -| `--password-stdin` | Master password from stdin | +| `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). ## Examples -Preview first — this one burns a fee: +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 \ - --dry-run --password-stdin --network tron:nile +echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 --network tron:nile --wait --password-stdin ``` -Create and wait for the id: +```console +✅ Exchange created + Exchange id 12 + Creator TQkXm4vN...5Zt7Uw + Reserves 10,000 TRX / 500,000 MyToken + TxID 2b7... + Block #57,884,020 + Fee 1,024 TRX + Status success +``` ```bash -echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 \ - --wait --password-stdin --network tron:nile +echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.create","data":{"kind":"exchange-create","stage":"confirmed","txId":"2b7...","confirmed":true,"blockNumber":57884020,"failed":false,"exchangeId":12,"pair":"TRX:1000123","creatorAddress":"TQkXm4vN...","firstTokenId":"_","firstTokenQuant":"10000000000","firstTokenLabel":"TRX","firstTokenDecimals":6,"secondTokenId":"1000123","secondTokenQuant":"500000000000","secondTokenLabel":"MyToken","secondTokenDecimals":6,"feeSun":1024000000},"meta":{"durationMs":6680,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` -## Errors +## Output -| Code | Meaning | +`data` varies by stage: + +| Stage | Fields | |---|---| -| `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 | +| default (submit) | `kind: "exchange-create"`, `stage: "submitted"`, `txId`, `pair`, `creatorAddress`, and both sides' `…TokenId` / `…TokenQuant` / `…TokenLabel` / `…TokenDecimals` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `failed`, and `exchangeId` — assigned by the chain, so known only once confirmed | + +`firstTokenId` / `secondTokenId` are on-chain ids, so TRX appears as `"_"`. The quantities are **strings** in each token's minimal unit; `…TokenLabel` and `…TokenDecimals` are what text uses to print them as whole tokens. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`same_token` — both sides identical, `asset_not_found` — no TRC10 with that id, `transaction_rejected` — the node refused it, for example for lack of balance or a reserve above `getExchangeBalanceLimit`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--pair`; `invalid_option` — both or neither of `--amounts` / `--raw-amounts`; `invalid_amount` — a side is not a decimal number, or has more decimal places than that token allows; `invalid_value` — a malformed `:`, or a side ≤ 0). ## See also -[`exchange inject`](inject.md) · [`exchange show`](show.md) · [`exchange` group](index.md) +[`exchange inject`](inject.md) · [`exchange trade`](trade.md) · [`exchange show`](show.md) · [`chain params`](../chain/params.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/exchange/index.md b/ts/docs/commands/exchange/index.md index 60a86883f..db13a016b 100644 --- a/ts/docs/commands/exchange/index.md +++ b/ts/docs/commands/exchange/index.md @@ -1,26 +1,22 @@ # wallet-cli exchange -Trade TRX and TRC10 on TRON's built-in Bancor market maker. +TRON's protocol-level Bancor exchange. -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. +Pairs trade **TRX against TRC10** — never TRC20 — and settle instantly against a bonding curve: no order book, no counterparty, no matching. Four properties differ from the AMMs most people are used to, and all four matter before you touch this group: -## 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. +- **A pair is private to its creator.** Only the account that created a pair can inject or withdraw its liquidity, and that binding cannot be transferred. There are no LP tokens and no outside liquidity providers. +- **Anyone can trade**, though — trading is open even though liquidity is not. +- **The protocol charges no fee.** `trade`, `inject`, and `withdraw` cost bandwidth only; the one charge is `create`, which burns `getExchangeCreateFee`. - **Human amounts are scaled by node-supplied decimals.** Every `--amount` / `--amounts` / `--min-received` is converted to base units using the TRC10 `precision` the node reports, so that value decides the quantity you sign. It is checked against the protocol range 0..6 and against the token id requested, but a wrong value inside that range cannot be caught locally. Use the `--raw-*` variants when the exact base-unit quantity matters — they are used verbatim. +- **TRX's token id on chain is the underscore `_`.** Write `TRX` (any case) or an asset id; `_` is accepted too. json shows what actually went on chain, so TRX appears there as `"_"`. -## Pricing - -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. +**Pricing follows the curve, not the ratio.** The ratio of the two reserves is a marginal quote — true only for a trade of size zero. A real trade moves along the curve, and the larger it is relative to the reserves, the worse the price it gets. That gap is the slippage, which is why [`exchange trade`](trade.md) always requires a floor (`--min-received` or `--slippage`), and why no command here prints a "price". To price a specific amount, run `exchange trade --dry-run` against the current reserves. Reserves are also capped by the chain parameter `getExchangeBalanceLimit`. -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. +**Tokens are named by id only in this group** — `TRX` or a numeric asset id, never a token name. Pairs are written with a colon (`--pair TRX:1000123`, `--amounts 10000:500000`), and TRC10 names may legally contain colons, so allowing names would make `--pair` ambiguous. Resolve a name to its id with [`asset info `](../asset/info.md). ## Synopsis @@ -33,16 +29,12 @@ wallet-cli exchange COMMAND | 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 inject` | [inject.md](inject.md) | Add liquidity in proportion to reserves | +| `exchange withdraw` | [withdraw.md](withdraw.md) | Take liquidity out in proportion to reserves | | `exchange trade` | [trade.md](trade.md) | Swap one side for the other | -| `exchange show` | [show.md](show.md) | 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). +| `exchange show` | [show.md](show.md) | One pair's creator, creation time, and reserves | +| `exchange list` | [list.md](list.md) | List every pair on chain | ## See also -[`asset`](../asset/index.md) (TRC10 issuance) · [`tx send`](../tx/send.md) +[`asset`](../asset/index.md) · [`tx send`](../tx/send.md) · [`chain params`](../chain/params.md) diff --git a/ts/docs/commands/exchange/inject.md b/ts/docs/commands/exchange/inject.md index d881ccbab..580878ba2 100644 --- a/ts/docs/commands/exchange/inject.md +++ b/ts/docs/commands/exchange/inject.md @@ -1,76 +1,92 @@ # wallet-cli exchange inject -Add liquidity to a pair you created. +Add liquidity to a pair, in proportion to its reserves. ## Synopsis ``` -wallet-cli exchange inject --token (--amount | --raw-amount ) - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] +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 reserve ratio and debits that as well. `--token TRX --amount 1000` on a pair holding 10,000 TRX and 500,000 tokens therefore also takes 50,000 tokens — you need enough of **both**, not just the one you named. -**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 pair's creator can inject; any other account fails with `not_exchange_creator`. -**Only the account that created the pair may do this**, and the binding cannot be moved. +If the amount is so small that the computed other side rounds to zero, the chain rejects the transaction. That case is caught locally against the current reserves rather than broadcast. -Adding liquidity proportionally does not move the price; it deepens the pair, which reduces the price impact of later trades. +**Tokens are named by id only** — `TRX` (or its on-chain id `_`) and a numeric TRC10 id; a TRC10 name may contain `:`. `--amount` is in whole tokens of the side you named and is converted using its precision; `--raw-amount` gives the same figure in minimal units. Exactly one of them is required. -**By default the command returns at submission**; `--wait` blocks until confirmed. - -## Arguments - -| Argument | Description | -|---|---| -| `` | **Required.** Exchange pair id | +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| +| `` | **Required.** Exchange pair id | | `--token ` | **Required.** The side you are specifying | -| `--amount ` | Amount for that side, in whole tokens. 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` | +| `--amount ` | Amount for that side in whole tokens; the other side follows the reserve ratio. One of `--amount` / `--raw-amount` | +| `--raw-amount ` | The same amount in minimal units. One of `--amount` / `--raw-amount` | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | -| `--password-stdin` | Master password from stdin | +| `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). ## Examples -Add 1,000 TRX and whatever the ratio requires of the other side: +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 \ - --wait --password-stdin --network tron:nile +echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 --network tron:nile --wait --password-stdin ``` -See what the other side would cost, without sending anything: +```console +✅ Liquidity injected + Exchange id 12 + Creator TQkXm4vN...5Zt7Uw + Injected 1,000 TRX / 50,000 MyToken + Reserves 11,000 TRX / 550,000 MyToken + TxID 5c3... + Block #57,884,180 + Fee 0 TRX + Status success +``` ```bash -echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 \ - --dry-run --password-stdin --network tron:nile +echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 --network tron:nile --wait --password-stdin -o json ``` -## Errors +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.inject","data":{"kind":"exchange-inject","stage":"confirmed","txId":"5c3...","confirmed":true,"blockNumber":57884180,"failed":false,"exchangeId":12,"pair":"TRX:1000123","creatorAddress":"TQkXm4vN...","tokenId":"_","tokenQuant":"1000000000","tokenLabel":"TRX","tokenDecimals":6,"otherTokenId":"1000123","otherTokenQuant":"50000000000","otherTokenLabel":"MyToken","otherTokenDecimals":6,"reserveAfter":"11000000000","otherReserveAfter":"550000000000","feeSun":0},"meta":{"durationMs":6440,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` -| 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 | +## Output + +`data` is flat — the side you named, the side that followed, and both reserves afterwards: + +| Field | Type | Meaning | +|---|---|---| +| `exchangeId` / `pair` / `creatorAddress` | number / string / string | The pair and its creator | +| `tokenId` / `tokenQuant` | string | The side you named and the amount debited from it, in minimal units | +| `tokenLabel` / `tokenDecimals` | string / number | How text renders that side in whole tokens | +| `otherTokenId` / `otherTokenQuant` / `otherTokenLabel` / `otherTokenDecimals` | — | The same four for the side computed from the ratio | +| `reserveAfter` / `otherReserveAfter` | string | The pair's balances after this injection, same order | + +TRX is identified as `"_"`; every quantity is a **string** in minimal units. Before confirmation the other side and both reserves are this command's own exact arithmetic; once confirmed the receipt's figure replaces it. `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `not_exchange_creator`, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `transaction_rejected` — the node refused it, for example for lack of balance, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--token`; `invalid_option` — both or neither of `--amount` / `--raw-amount`; `invalid_amount` — the amount is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or so small that the computed other side works out to zero). ## See also -[`exchange withdraw`](withdraw.md) · [`exchange show`](show.md) · [`exchange` group](index.md) +[`exchange withdraw`](withdraw.md) · [`exchange show`](show.md) · [`exchange create`](create.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/exchange/list.md b/ts/docs/commands/exchange/list.md index e42ed08b0..a67586bd4 100644 --- a/ts/docs/commands/exchange/list.md +++ b/ts/docs/commands/exchange/list.md @@ -1,6 +1,6 @@ # wallet-cli exchange list -List exchange pairs, one page at a time. +List every exchange pair on chain. ## Synopsis @@ -10,19 +10,19 @@ wallet-cli exchange list [--limit ] [--offset ] [options] ## Description -Lists exchange pairs with their two token ids, reserves and creator. +Lists pairs with their id, both tokens, reserves, and creator. Read-only, no account needed. For one pair on its own, use [`exchange show`](show.md). -**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. +**`Pair` is in the order the chain stored it**, which is the order the creator supplied — TRX is not normalized to either side, so `1000124:TRX` and `TRX:1000123` both occur. The two numbers in `Reserves` follow that same order. -Use [`exchange show`](show.md) for one pair with names and whole tokens. +**Reserves here are in minimal units, not whole tokens.** This command makes a single RPC and so has no token precisions to divide by; `exchange show` fetches them and prints whole tokens instead. The same pair therefore reads `6,672` here and `66.72` there. -**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. +Paging happens on the node, and **there is no total**: the chain exposes no count of exchange pairs. The title reports the window it asked for — `Exchanges (limit 3, offset 0)` — not `showing 3 of N`, and `meta.pagination.total` is always `null`. To get everything, pass a `--limit` large enough to cover it. ## Options | Option | Description | |---|---| -| `--limit ` | Max pairs to return, 1-1000 (default `10`) | +| `--limit ` | Max pairs to return (default `10`) | | `--offset ` | Pagination offset (default `0`) | Plus the [global options](../index.md#global-options-every-command). @@ -30,19 +30,44 @@ Plus the [global options](../index.md#global-options-every-command). ## Examples ```bash -wallet-cli exchange list --network tron:nile +wallet-cli exchange list --limit 3 --network tron:nile +``` + +```console +Exchanges (limit 3, offset 0) +| ID | Pair | Reserves (minimal units) | Creator | +| -- | ----------- | ---------------------------------- | ----------------- | +| 14 | 1000124:TRX | 2,500,000,000,000 / 50,000,000,000 | TBeta9mR...8pLx | +| 13 | 1000125:TRX | 16,000,000 / 8,000,000,000 | TAlpha7k...3nQw | +| 12 | TRX:1000123 | 10,000,000,000 / 500,000,000,000 | TQkXm4vN...5Zt7Uw | ``` ```bash -wallet-cli exchange list --limit 50 --offset 50 --network tron:nile +wallet-cli exchange list --limit 3 --network tron:nile -o json ``` -## Errors +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.list","data":{"kind":"exchange-list","exchanges":[{"exchangeId":14,"pair":"1000124:TRX","creatorAddress":"TBeta9mR...","firstTokenId":"1000124","firstTokenBalance":"2500000000000","secondTokenId":"_","secondTokenBalance":"50000000000"},{"exchangeId":13,"pair":"1000125:TRX","creatorAddress":"TAlpha7k...","firstTokenId":"1000125","firstTokenBalance":"16000000","secondTokenId":"_","secondTokenBalance":"8000000000"},{"exchangeId":12,"pair":"TRX:1000123","creatorAddress":"TQkXm4vN...","firstTokenId":"_","firstTokenBalance":"10000000000","secondTokenId":"1000123","secondTokenBalance":"500000000000"}]},"meta":{"durationMs":52,"warnings":[],"pagination":{"offset":0,"limit":3,"total":null}},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` -| Code | Meaning | -|---|---| -| `invalid_value` | `--limit` outside 1-1000, or a negative `--offset` | +## Output + +`data.kind` is `exchange-list`. `data.exchanges[]` — one entry per pair: + +| Field | Type | Meaning | +|---|---|---| +| `exchangeId` | number | Pair id | +| `pair` | string | The two sides as `tokenA:tokenB`, in stored order; TRX is spelled `TRX` here | +| `creatorAddress` | string | Creator, base58 | +| `firstTokenId` / `secondTokenId` | string | On-chain token ids, in stored order; TRX is `"_"` — it can be either side | +| `firstTokenBalance` / `secondTokenBalance` | string | Reserves, raw amounts in each token's smallest unit. **Strings**: reserves reach int64 and would lose precision as JSON numbers | + +`meta.pagination` carries `offset`, `limit`, and `total` — `total` is always `null` here, meaning "no count exists", not "zero". + +## Exit status + +`0` success · `1` execution failure (`rpc_error`) · `2` usage error (`invalid_value` — bad limit or offset). ## See also -[`exchange show`](show.md) · [`exchange` group](index.md) +[`exchange show`](show.md) · [`exchange trade`](trade.md) · [`asset list`](../asset/list.md) diff --git a/ts/docs/commands/exchange/show.md b/ts/docs/commands/exchange/show.md index 8e893a80f..5e8dc043b 100644 --- a/ts/docs/commands/exchange/show.md +++ b/ts/docs/commands/exchange/show.md @@ -10,21 +10,21 @@ 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. +Reports a pair's creator, creation time, and both tokens with their reserves. Read-only, no account needed. -**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). +**No price is shown.** The ratio of the reserves is a quoted rate that holds only in the limit of a zero-size trade; anything with volume settles further along the curve and returns less. Printing it would invite reading it as an executable price. To price a specific amount against the current reserves, run [`exchange trade --dry-run`](trade.md). -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. +Unlike [`exchange list`](list.md), this command resolves each side's name and precision, so **reserves print as whole tokens here** and the json carries `firstTokenLabel` / `firstTokenDecimals` and their `second*` counterparts alongside the raw balances. The same pair therefore reads `66.72` here and `6,672` in the list. -## Arguments +`pair` and the `first*` / `second*` fields follow the order the chain stored them, which is the order the creator supplied — TRX is not normalized to either side. Text folds the two sides into a `Reserves` block in that same order. -| Argument | Description | +## Options + +| Option | Description | |---|---| | `` | **Required.** Exchange pair id | -## Options - -Only the [global options](../index.md#global-options-every-command). +Plus the [global options](../index.md#global-options-every-command). ## Examples @@ -32,17 +32,42 @@ Only the [global options](../index.md#global-options-every-command). wallet-cli exchange show 12 --network tron:nile ``` +```console +Exchange id 12 + Creator TQkXm4vN...5Zt7Uw + Created time 2026-08-02 09:15:00 UTC + Reserves + TRX 10,000 + MyToken (id 1000123) 500,000 +``` + ```bash wallet-cli exchange show 12 --network tron:nile -o json ``` -## Errors +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.show","data":{"kind":"exchange-show","exchangeId":12,"pair":"TRX:1000123","creatorAddress":"TQkXm4vN...","createTime":1785662100000,"firstTokenId":"_","firstTokenBalance":"10000000000","firstTokenLabel":"TRX","firstTokenDecimals":6,"secondTokenId":"1000123","secondTokenBalance":"500000000000","secondTokenLabel":"MyToken","secondTokenDecimals":6},"meta":{"durationMs":24,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` -| Code | Meaning | -|---|---| -| `exchange_not_found` | No pair has that id | -| `asset_not_found` | A TRC10 side references an id that no longer resolves | +## Output + +`data.kind` is `exchange-show`. + +| Field | Type | Meaning | +|---|---|---| +| `exchangeId` | number | Pair id | +| `pair` | string | The two sides as `tokenA:tokenB`, in stored order; TRX is spelled `TRX` here | +| `creatorAddress` | string | Creator, base58 — the only account that may inject or withdraw | +| `createTime` | number | Creation time, ms since epoch | +| `firstTokenId` / `secondTokenId` | string | On-chain token ids, in stored order; TRX is `"_"` — it can be either side | +| `firstTokenBalance` / `secondTokenBalance` | string | Reserves, raw amounts in each token's smallest unit. **Strings**: reserves reach int64 and would lose precision as JSON numbers | +| `firstTokenLabel` / `secondTokenLabel` | string | Token name, or `TRX` | +| `firstTokenDecimals` / `secondTokenDecimals` | number | Decimal places used to render the whole-token figures in text | + +## Exit status + +`0` success · `1` execution failure (`exchange_not_found` — no such pair, `rpc_error`) · `2` usage error (`invalid_value` — id not a number). ## See also -[`exchange list`](list.md) · [`exchange trade`](trade.md) · [`exchange` group](index.md) +[`exchange list`](list.md) · [`exchange trade`](trade.md) · [`exchange inject`](inject.md) diff --git a/ts/docs/commands/exchange/trade.md b/ts/docs/commands/exchange/trade.md index 86b28fa67..d3ba4121a 100644 --- a/ts/docs/commands/exchange/trade.md +++ b/ts/docs/commands/exchange/trade.md @@ -5,91 +5,105 @@ Swap one side of a pair for the other. ## Synopsis ``` -wallet-cli exchange trade --sell (--amount | --raw-amount ) +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] + [--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. +Sells one side of the pair for the other along the Bancor curve. Settlement is immediate and needs no counterparty, and **anyone can trade** — unlike liquidity, trading is not restricted to the pair's creator. -### Slippage protection +The floor is optional, and **omitting it means no slippage protection at all**: -`--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. +- `--min-received` is an absolute floor, **not an estimate**. If the trade would return less, the whole thing is rejected on chain as `slippage_exceeded` and only bandwidth is spent. It is the only defence against the price moving between signing and execution. `--raw-min-received` is the same figure in minimal units. +- `--slippage` is the convenience form: the CLI reads the current reserves, computes what the trade would return, subtracts that percentage, and sends the result as the floor. What reaches the chain is always an absolute number. +- **With none of the three**, the floor sent on chain is `1` — the lowest value the protocol accepts — so the trade takes any non-zero return at any price. The response carries a warning in `meta.warnings` saying so. -`--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. +At most one of the three may be given; combining them is a usage error. -**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. +Slippage grows with trade size relative to the reserves — that is the curve, not a fee; the protocol takes no cut. Check depth with [`exchange show`](show.md), and price a specific amount with `exchange trade --dry-run`. -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. +**Tokens are named by id only** — `TRX` (or its on-chain id `_`) and a numeric TRC10 id; a TRC10 name may contain `:`. `--amount` is in whole tokens of the side being sold, `--raw-amount` in minimal units; exactly one of them is required. -### Pricing is an estimate +> **Trading may be closed on the network you are on.** java-tron refuses `ExchangeTransactionContract` outright until the TIP-836 hardening proposal (`getAllowHardenExchangeCalculation`) is activated — it is unset on both mainnet and Nile, and the command then fails with `exchange_trading_disabled`. [`exchange create`](create.md), [`inject`](inject.md) and [`withdraw`](withdraw.md) are unaffected. -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 | +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `--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` | +| `` | **Required.** Exchange pair id | +| `--sell ` | **Required.** The side you are selling; the other side is what you receive | +| `--amount ` | How much to sell, in whole tokens, > 0. One of `--amount` / `--raw-amount` | +| `--raw-amount ` | The same amount in minimal units. One of `--amount` / `--raw-amount` | +| `--min-received ` | Lowest acceptable return, in whole tokens; below it the trade reverts. At most one of the three floor flags | +| `--raw-min-received ` | The same floor in minimal units | +| `--slippage ` | Derive the floor from current reserves, minus this percentage; > 0 and < 100 | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | -| `--password-stdin` | Master password from stdin | +| `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). ## Examples -Price it first: +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +With an explicit floor: ```bash -echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --slippage 1 \ - --dry-run --password-stdin --network tron:nile +echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --min-received 4900 --network tron:nile --wait --password-stdin ``` -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 +```console +✅ Trade completed + Exchange id 12 + Trader TQkXm4vN...5Zt7Uw + Sold 100 TRX + Received 4,950 MyToken + Min accepted 4,900 MyToken + TxID d9a... + Block #57,884,455 + Fee 0 TRX + Status success ``` -Trade with an absolute floor you chose: +The same trade via `--slippage 1`: the CLI computes 4,950 from the current reserves, takes 1 % off, and sends 4,900 as the floor. ```bash -echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --min-received 4900 \ - --wait --password-stdin --network tron:nile +echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --slippage 1 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.trade","data":{"kind":"exchange-trade","stage":"confirmed","txId":"d9a...","confirmed":true,"blockNumber":57884455,"failed":false,"exchangeId":12,"pair":"TRX:1000123","traderAddress":"TQkXm4vN...","soldTokenId":"_","soldQuant":"100000000","soldLabel":"TRX","soldDecimals":6,"receivedTokenId":"1000123","receivedLabel":"MyToken","receivedDecimals":6,"receivedQuant":"4950000000","estimatedReceivedQuant":"4950000000","minReceivedQuant":"4900000000","feeSun":0},"meta":{"durationMs":6490,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` -## Errors +## Output -| 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 | +| Field | Type | Meaning | +|---|---|---| +| `exchangeId` / `pair` / `traderAddress` | number / string / string | The pair and the account that traded | +| `soldTokenId` / `soldQuant` | string | The side sold and how much, in minimal units | +| `soldLabel` / `soldDecimals` | string / number | How text renders that side in whole tokens | +| `receivedTokenId` / `receivedLabel` / `receivedDecimals` | — | The same three for the side received | +| `estimatedReceivedQuant` | string | What the Bancor curve predicted at build time — advisory, always present | +| `receivedQuant` | string | What the trade actually returned; **only once confirmed**, since it exists only in the receipt | +| `minReceivedQuant` | string | The floor that went on chain — yours, the one `--slippage` derived, or `"1"` when no floor was given | + +TRX is identified as `"_"`; every quantity is a **string** in minimal units. Before confirmation text shows `Estimated return` in place of `Received`. `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `exchange_trading_disabled` — the network is not accepting Bancor trades, `slippage_exceeded` — the return fell below the floor, `transaction_rejected` — the node refused it, for example for lack of balance, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--sell`; `invalid_option` — both or neither of `--amount` / `--raw-amount`, or more than one floor flag; `invalid_amount` — the amount or `--min-received` is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or a `--slippage` outside 0–100). ## See also -[`exchange show`](show.md) · [`exchange` group](index.md) +[`exchange show`](show.md) · [`exchange list`](list.md) · [`asset info`](../asset/info.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/exchange/withdraw.md b/ts/docs/commands/exchange/withdraw.md index c98ffc523..edb359982 100644 --- a/ts/docs/commands/exchange/withdraw.md +++ b/ts/docs/commands/exchange/withdraw.md @@ -1,66 +1,90 @@ # wallet-cli exchange withdraw -Take liquidity out of a pair you created. +Take liquidity out of a pair, in proportion to its reserves. ## Synopsis ``` -wallet-cli exchange withdraw --token (--amount | --raw-amount ) - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] +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. +The mirror of [`exchange inject`](inject.md): you name one side and its amount, the other side follows the current reserve ratio, and both come back to your account. Only the pair's creator can withdraw. -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.** +**Amounts that do not divide cleanly by the reserve ratio are refused.** Converting one side to the other has a precision requirement on chain — the quotient must be exact to within 0.01% — and an amount that fails it is rejected outright rather than rounded, as `precision_loss`. Round the amount to something the ratio divides and try again. -**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. +**Tokens are named by id only** — `TRX` (or its on-chain id `_`) and a numeric TRC10 id; a TRC10 name may contain `:`. `--amount` is in whole tokens of the side you named; `--raw-amount` gives the same figure in minimal units. Exactly one of them is required. -**By default the command returns at submission**; `--wait` blocks until confirmed. - -## Arguments - -| Argument | Description | -|---|---| -| `` | **Required.** Exchange pair id | +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| +| `` | **Required.** Exchange pair id | | `--token ` | **Required.** The side you are specifying | -| `--amount ` | Amount for that side, in whole tokens. 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` | +| `--amount ` | Amount for that side in whole tokens; the other side follows the reserve ratio. One of `--amount` / `--raw-amount` | +| `--raw-amount ` | The same amount in minimal units. One of `--amount` / `--raw-amount` | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | -| `--password-stdin` | Master password from stdin | +| `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). ## Examples +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + ```bash -echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 \ - --wait --password-stdin --network tron:nile +echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 --network tron:nile --wait --password-stdin ``` -## Errors +```console +✅ Liquidity withdrawn + Exchange id 12 + Creator TQkXm4vN...5Zt7Uw + Withdrawn 1,000 TRX / 50,000 MyToken + Reserves 10,000 TRX / 500,000 MyToken + TxID 8f6... + Block #57,884,310 + Fee 0 TRX + Status success +``` -| 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 | +```bash +echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.withdraw","data":{"kind":"exchange-withdraw","stage":"confirmed","txId":"8f6...","confirmed":true,"blockNumber":57884310,"failed":false,"exchangeId":12,"pair":"TRX:1000123","creatorAddress":"TQkXm4vN...","tokenId":"_","tokenQuant":"1000000000","tokenLabel":"TRX","tokenDecimals":6,"otherTokenId":"1000123","otherTokenQuant":"50000000000","otherTokenLabel":"MyToken","otherTokenDecimals":6,"reserveAfter":"10000000000","otherReserveAfter":"500000000000","feeSun":0},"meta":{"durationMs":6460,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` is flat, and identical in shape to [`exchange inject`](inject.md#output): + +| Field | Type | Meaning | +|---|---|---| +| `exchangeId` / `pair` / `creatorAddress` | number / string / string | The pair and its creator | +| `tokenId` / `tokenQuant` | string | The side you named and the amount returned from it, in minimal units | +| `tokenLabel` / `tokenDecimals` | string / number | How text renders that side in whole tokens | +| `otherTokenId` / `otherTokenQuant` / `otherTokenLabel` / `otherTokenDecimals` | — | The same four for the side computed from the ratio | +| `reserveAfter` / `otherReserveAfter` | string | The pair's balances after this withdrawal, same order | + +TRX is identified as `"_"`; every quantity is a **string** in minimal units. `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `not_exchange_creator`, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `insufficient_reserve` — more than that side holds, `precision_loss` — the amount does not convert cleanly, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--token`; `invalid_option` — both or neither of `--amount` / `--raw-amount`; `invalid_amount` — the amount is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or so small that the computed other side works out to zero). ## See also -[`exchange inject`](inject.md) · [`exchange show`](show.md) · [`exchange` group](index.md) +[`exchange inject`](inject.md) · [`exchange show`](show.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/gasfree/transfer.md b/ts/docs/commands/gasfree/transfer.md index 27cf9ba10..11cc9e9b6 100644 --- a/ts/docs/commands/gasfree/transfer.md +++ b/ts/docs/commands/gasfree/transfer.md @@ -79,7 +79,7 @@ wallet-cli gasfree transfer --to TBy6mQ7Y3nJ8sD2fWpXk4LhVc9Ra1Zt5Ub --amount 25 ``` ```console -📋 Dry run — GasFree transfer 25 USDT (not submitted) +⏳ Dry run — GasFree transfer 25 USDT (not submitted) From TVjsyZ7fYF3qCcNaMxN5PMWmSgYcCyqZfw (GasFree address, not activated) To TBy6mQ7Y3nJ8sD2fWpXk4LhVc9Ra1Zt5Ub Fee 1.5 USDT (0.5 service + 1.0 activation) @@ -105,7 +105,7 @@ A provider-side failure still leaves the envelope at `success: true` and exit `0 ## Exit status -`0` submitted (or dry-run) · `1` execution failure (`gasfree_credentials_missing`, `insufficient_token_balance` — token balance < amount + service fee [+ activation fee], `unsupported_token`, `gasfree_rejected` — the provider declined the authorization, `gasfree_integrity` — the provider's fee metadata disagreed with itself, `watch_only_no_signer`, `wrong_password`, `auth_failed`, `signing_rejected`, `provider_error`) · `2` usage error (`invalid_value`, `invalid_amount`). +`0` submitted (or dry-run) · `1` execution failure (`gasfree_credentials_missing`, `insufficient_token_balance` — token balance < amount + service fee [+ activation fee], `unsupported_token`, `gasfree_rejected` — the provider declined the authorization, `gasfree_integrity` — the provider's fee metadata disagreed with itself, `watch_only_no_signer`, `auth_failed`, `signing_rejected`, `provider_error`) · `2` usage error (`invalid_value`, `invalid_amount`). ## See also diff --git a/ts/docs/commands/import/index.md b/ts/docs/commands/import/index.md index cff6fbbe8..8718cf50f 100644 --- a/ts/docs/commands/import/index.md +++ b/ts/docs/commands/import/index.md @@ -14,7 +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 keystore`](keystore.md) | Import an account from a Web3 keystore file | | `import ledger` | Register a Ledger account (watch-only locally; signs on device) — `wallet-cli import ledger --help` | | `import watch` | Register a watch-only address (no secret) — `wallet-cli import watch --help` | diff --git a/ts/docs/commands/import/keystore.md b/ts/docs/commands/import/keystore.md index 1dbf1e3d0..fac1dcf6f 100644 --- a/ts/docs/commands/import/keystore.md +++ b/ts/docs/commands/import/keystore.md @@ -1,8 +1,8 @@ # wallet-cli import keystore -Import a single account from a standard Web3 keystore JSON. **Interactive-only.** +Import an account from a Web3 keystore file. **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. +> **Note**: there are no stdin flags here. Both the master password and the keystore file's own password are entered **only** via hidden TTY prompts — the file password is secret material like any other. ## Synopsis @@ -12,24 +12,21 @@ 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. +Imports a single account from a standard Web3 keystore JSON — the format TronLink exports, and what [`backup --keystore`](../backup.md) writes — and stores it encrypted under your master password. The imported wallet becomes active. -A keystore 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). +A keystore holds **one private key**, so the resulting account has no seed and nothing can be derived from it, exactly like [`import private-key`](private-key.md). Its `type` is recorded as `privateKey`. -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`. +Two passwords are involved and they are unrelated: your master password encrypts the account into local storage, the keystore's own password decrypts the file. They are prompted in that order, and only **after** the file has been read and structurally checked — so a mistyped path costs no password typing. -**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` at exit `2`, and that check runs **first**, ahead of the file. In a non-interactive environment every call fails the same way whether the path is good or not; the file-before-password ordering above only applies once you have a terminal. -Without a TTY the command fails with `tty_required` — there is no non-interactive path. - -## Arguments - -- `path` — path to the keystore JSON file +If an account with the same address already exists locally, the import is **refused** rather than overwriting it: replacing an address silently could destroy the seed backup an existing account depends on. Delete the existing account first if replacement is what you want. ## Options | Option | Description | |---|---| +| `` | **Required.** Path to the keystore JSON file | | `--label ` | Human-friendly unique account label, 1–64 chars; omit to auto-generate | Plus the [global options](../index.md#global-options-every-command). @@ -46,7 +43,7 @@ wallet-cli import keystore ./tronlink-export.json --label imported ✅ Imported wallet "imported" Account ID wlt_7h2k9m1a Type private key - TRON address TZx9kP2mQ7hV3nD8sL5cR1tY6bWqA4eJfU + TRON address TZx9kP2m...7bWq Active yes ⚠️ The keystore password was read from hidden input and was not printed. @@ -59,7 +56,7 @@ 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":[]}} +{"schema":"wallet-cli.result.v1","success":true,"command":"import.keystore","data":{"status":"created","accountId":"wlt_7h2k9m1a","label":"imported","type":"privateKey","index":null,"active":true,"addresses":{"tron":"TZx9kP2m...7bWq"}},"meta":{"durationMs":44,"warnings":[]}} ``` ## Output @@ -69,28 +66,17 @@ wallet-cli import keystore ./tronlink-export.json --label imported -o json | Field | Type | Meaning | |---|---|---| | `status` | string | `"created"` | -| `accountId` | string | Stable account id (newly minted on this machine — ids never transfer) | +| `accountId` | string | Stable account id | | `label` | string | Account label | | `type` | string | `"privateKey"` (standalone, no seed) | | `index` | number \| null | Non-HD account, always `null` | | `active` | boolean | Became the active account | -| `addresses.tron` | string | Base58 TRON address, 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 | +| `addresses.tron` | string | Base58 TRON address | ## 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). +`0` imported · `1` execution failure (`keystore_not_found` — no such file; `invalid_keystore` — not a valid keystore JSON; `wrong_keystore_password`; `account_exists` — this address is already in the wallet; `auth_failed`; `io_error`) · `2` usage error (`tty_required` — no TTY for interactive input, checked before anything else; duplicate label). ## See also -[`backup --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) +[`backup`](../backup.md) · [`import private-key`](private-key.md) · [`delete`](../delete.md) · [machine-interface → Secret handling](../../machine-interface.md#secret-handling) diff --git a/ts/docs/commands/index.md b/ts/docs/commands/index.md index 73b0222e4..cb15139aa 100644 --- a/ts/docs/commands/index.md +++ b/ts/docs/commands/index.md @@ -7,6 +7,7 @@ Every command — including every subcommand — has its own page, following a f | Command | Page | |---|---| | `create` | [create.md](create.md) | +| `import` (group) | [import/index.md](import/index.md) | | `import mnemonic` | [import/mnemonic.md](import/mnemonic.md) *(interactive-only)* | | `import private-key` | [import/private-key.md](import/private-key.md) *(interactive-only)* | | `import keystore` | [import/keystore.md](import/keystore.md) *(interactive-only)* | @@ -69,20 +70,6 @@ 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) | @@ -105,16 +92,6 @@ 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) | @@ -132,6 +109,40 @@ Every command — including every subcommand — has its own page, following a f | `reward balance` | [reward/balance.md](reward/balance.md) | | `reward withdraw` | [reward/withdraw.md](reward/withdraw.md) | +## Governance + +| Command | Page | +|---|---| +| `proposal` (group) | [proposal/index.md](proposal/index.md) | +| `proposal list` | [proposal/list.md](proposal/list.md) | +| `proposal show` | [proposal/show.md](proposal/show.md) | +| `proposal create` | [proposal/create.md](proposal/create.md) | +| `proposal approve` | [proposal/approve.md](proposal/approve.md) | +| `proposal delete` | [proposal/delete.md](proposal/delete.md) | +| `witness` (group) | [witness/index.md](witness/index.md) | +| `witness create` | [witness/create.md](witness/create.md) | +| `witness update` | [witness/update.md](witness/update.md) | +| `witness set-brokerage` | [witness/set-brokerage.md](witness/set-brokerage.md) | + +## TRC10 assets and the on-chain exchange + +| Command | Page | +|---|---| +| `asset` (group) | [asset/index.md](asset/index.md) | +| `asset issue` | [asset/issue.md](asset/issue.md) | +| `asset update` | [asset/update.md](asset/update.md) | +| `asset participate` | [asset/participate.md](asset/participate.md) | +| `asset unfreeze` | [asset/unfreeze.md](asset/unfreeze.md) | +| `asset info` | [asset/info.md](asset/info.md) | +| `asset list` | [asset/list.md](asset/list.md) | +| `exchange` (group) | [exchange/index.md](exchange/index.md) | +| `exchange create` | [exchange/create.md](exchange/create.md) | +| `exchange inject` | [exchange/inject.md](exchange/inject.md) | +| `exchange withdraw` | [exchange/withdraw.md](exchange/withdraw.md) | +| `exchange trade` | [exchange/trade.md](exchange/trade.md) | +| `exchange show` | [exchange/show.md](exchange/show.md) | +| `exchange list` | [exchange/list.md](exchange/list.md) | + ## Signing | Command | Page | @@ -145,7 +156,9 @@ Every command — including every subcommand — has its own page, following a f | Command | Page | |---|---| +| `encoding` (group) | [encoding/index.md](encoding/index.md) | | `encoding convert` | [encoding/convert.md](encoding/convert.md) | +| `address` (group) | [address/index.md](address/index.md) | | `address generate` | [address/generate.md](address/generate.md) | | `config` | [config.md](config.md) | | `networks` | [networks.md](networks.md) | @@ -162,3 +175,5 @@ Every command — including every subcommand — has its own page, following a f ``` Broadcast (✍️) commands additionally take `--wait` / `--wait-timeout ` (cap default: config `waitTimeoutMs`, built-in 60000), the early-exit modes `--dry-run` / `--sign-only` / `--build-only`, and the multi-sig options `--permission-id ` / `--expiration `. + +The three early-exit modes are mutually exclusive, and `--expiration` is accepted only alongside `--sign-only` or `--build-only`. Breaking either rule is a usage error at exit `2`. The code depends on where the check runs: on the governance and asset/exchange writes it is `invalid_value`, and the message names the field as `--input` rather than the flags you passed — for example `invalid --input: choose at most one of --dry-run, --sign-only, --build-only`. Elsewhere the same conflict reports `invalid_option`. Branch on the exit code, not on the code string; see [machine interface](../machine-interface.md#error-codes). diff --git a/ts/docs/commands/permission/update.md b/ts/docs/commands/permission/update.md index 2e0a09041..39285de00 100644 --- a/ts/docs/commands/permission/update.md +++ b/ts/docs/commands/permission/update.md @@ -44,7 +44,7 @@ Changing only `keys`, `threshold` or `name` needs no such deletion. | `--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`; 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` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active) — changing permissions is owner-level, so normally `0` (default `0`) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | @@ -124,7 +124,7 @@ Local warnings (`owner_lockout`, `owner_lockout_partial`, `active_can_update_per ## Exit status -`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`invalid_permission`, `not_authorized`, `watch_only_no_signer`, `wrong_password`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`). +`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`invalid_permission`, `not_authorized`, `watch_only_no_signer`, `auth_failed`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`). On a multi-sig account, a submission whose accumulated signature weight is below the permission threshold is refused **after signing and before broadcasting** with `not_authorized` (`signature threshold is not reached; missing N weight`) — nothing is sent and no fee is burned. Collect the remaining signatures through `--sign-only` + [`tx sign`](../tx/sign.md) and submit with [`tx broadcast`](../tx/broadcast.md) instead. `--sign-only` and `--build-only` still return a partial signature, which is how a co-signing flow starts. diff --git a/ts/docs/commands/proposal/approve.md b/ts/docs/commands/proposal/approve.md index 0af54cb43..4acbb58ab 100644 --- a/ts/docs/commands/proposal/approve.md +++ b/ts/docs/commands/proposal/approve.md @@ -1,42 +1,96 @@ # wallet-cli proposal approve -Add or remove the selected witness's approval. +Approve a proposal, or cancel your approval. ## Synopsis ``` wallet-cli proposal approve [--cancel] - [--dry-run | --sign-only | --build-only] [options] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [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. +Adds your approval to a proposal; `--cancel` withdraws an approval you already cast. TRON governance has these two states only — there is no "against" vote, and abstaining means doing nothing. + +Only a registered witness can approve; other accounts fail with `not_a_witness`. The chain checks nothing beyond that, so a non-elected candidate's approval is accepted and lands on chain — but at tally only the approvals of the **top-27 active SRs** count toward the threshold, so it does not move the proposal any closer to passing. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `` | Positive proposal id | -| `--cancel` | Remove this witness's existing approval | -| `--dry-run`, `--sign-only`, `--build-only` | Mutually exclusive transaction modes | +| `` | **Required.** Proposal id | +| `--cancel` | Withdraw an approval you cast earlier instead of adding one | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli proposal approve 47 --network tron:nile --wait --password-stdin +``` + +```console +✅ Proposal approved + Proposal #47 + Voter TSRmq8kP...9dEf (main) + Approvals 13 / 18 + TxID b1e... + Block 57,880,240 + Fee 0 TRX (267 bandwidth) + Status success +``` -## Example +`--cancel` takes your own approval back off the proposal: ```bash echo "$PW" | wallet-cli proposal approve 47 --cancel --network tron:nile --wait --password-stdin ``` +```console +✅ Approval canceled + Proposal #47 + Voter TSRmq8kP...9dEf (main) + Approvals 12 / 18 + TxID b2f... + Block 57,880,255 + Fee 0 TRX (267 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli proposal approve 47 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"proposal.approve","data":{"kind":"proposal-approve","stage":"confirmed","txId":"b1e...","confirmed":true,"blockNumber":57880240,"failed":false,"proposalId":47,"addApproval":true,"feeSun":0,"resource":{"netUsage":267,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6410,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + ## Output -The receipt returns `addApproval`, the projected approval count, threshold, witness address, and transaction/resource fields. +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "proposal-approve"`, `stage: "submitted"`, `txId`, `proposalId`, `addApproval` (`false` with `--cancel`) | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | ## Exit status -`0` built/signed/submitted · `1` `not_a_witness`, `proposal_not_found`, `proposal_expired`, `already_approved`, `not_approved`, signer/auth, or chain failure · `2` invalid input. +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `proposal_not_found` — no such proposal, `already_approved` — you already approved it, `not_approved` — `--cancel` with no approval to withdraw, `proposal_expired`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — id not a number). ## See also -[`proposal show`](show.md) · [`proposal delete`](delete.md) +[`proposal show`](show.md) · [`proposal list`](list.md) · [`proposal delete`](delete.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/proposal/create.md b/ts/docs/commands/proposal/create.md index 70bb4908b..c1cf2b7dd 100644 --- a/ts/docs/commands/proposal/create.md +++ b/ts/docs/commands/proposal/create.md @@ -1,45 +1,111 @@ # wallet-cli proposal create -Create a proposal containing one or more chain-parameter changes. +Create a governance proposal that changes chain parameters. ## Synopsis ``` wallet-cli proposal create --set = [--set ...] - [--dry-run | --sign-only | --build-only] [options] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [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. +Submits a proposal carrying one or more chain-parameter changes for super representatives to vote on. Only a registered witness can create one; other accounts fail with `not_a_witness`. + +`--set` takes a parameter **name** — the `getXxx` vocabulary of [`chain params`](../chain/params.md) — and resolves it to the on-chain parameter id; a raw numeric id also works. Unknown names and out-of-range values are rejected locally, before anything is broadcast. + +Pass `--set` once per parameter. The receipt and `data.changes[]` order changes by parameter id, not by the order you typed them, so the same proposal always renders the same way. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `--set =` | Required, repeatable parameter assignment | -| `--dry-run` | Build and estimate without signing | -| `--sign-only` | Sign without broadcasting | -| `--build-only` | Return the unsigned transaction without accessing a signer | +| `--set =` | **Required, repeatable.** One parameter change, e.g. `--set getTransactionFee=15`; `name` is a `chain params` key, a raw parameter id is also accepted | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +One parameter, waiting for confirmation: + +```bash +echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network tron:nile --wait --password-stdin +``` + +```console +✅ Proposal created + Proposal #48 + Proposer TSRmq8kP...9dEf (main) + Parameter changes (1) + getTransactionFee 10 → 15 sun/byte + TxID 9c4... + Block 57,880,102 + Fee 0 TRX (268 bandwidth) + Status success +``` + +Several parameters in one proposal — the receipt lists them by parameter id: -Plus `--account`, `--password-stdin`, and the [global options](../index.md#global-options-every-command). +```bash +echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --set getCreateAccountFee=200000 --network tron:nile --wait --password-stdin +``` -## Example +```console +✅ Proposal created + Proposal #49 + Proposer TSRmq8kP...9dEf (main) + Parameter changes (2) + getCreateAccountFee 100000 → 200000 sun + getTransactionFee 10 → 15 sun/byte + TxID a1b... + Block 57,880,140 + Fee 0 TRX (292 bandwidth) + Status success +``` ```bash -echo "$PW" | wallet-cli proposal create --set getCreateAccountFee=200000 --set getTransactionFee=15 --network tron:nile --wait --password-stdin +echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"proposal.create","data":{"kind":"proposal-create","stage":"confirmed","txId":"9c4...","confirmed":true,"blockNumber":57880102,"feeSun":0,"resource":{"netUsage":268,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0},"failed":false,"proposalId":48,"changes":[{"id":3,"name":"getTransactionFee","currentValue":10,"proposedValue":15,"unit":"sun/byte"}]},"meta":{"durationMs":6480,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output -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. +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "proposal-create"`, `stage: "submitted"`, `txId`, `changes[]` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed`, and `proposalId` — the new proposal's id, known only once it is on chain | + +`proposalId` is **omitted** when the id cannot be established beyond doubt. The chain does not +report it, so it is recognised by comparing the proposal list against a snapshot taken before +submitting; if the node's list has not caught up yet, or more than one new proposal matches these +parameters, a warning says so and the field is absent. Treat it as optional and fall back to +[`proposal list`](list.md) — a guessed id would be passed on to `proposal approve` or the +irreversible `proposal delete`. The transaction itself has succeeded either way. + +`changes[]` entries carry `id`, `name`, `currentValue`, `proposedValue`, and `unit`, ordered by `id`. ## Exit status -`0` built/signed/submitted · `1` `not_a_witness`, signer/auth, RPC, or chain rejection · `2` invalid parameter or mode. +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--set` given; `unknown_parameter` — no such name or id; `invalid_value` — value out of range or not a number). ## See also -[`proposal approve`](approve.md) · [`chain params`](../chain/params.md) +[`proposal approve`](approve.md) · [`proposal delete`](delete.md) · [`proposal show`](show.md) · [`chain params`](../chain/params.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/proposal/delete.md b/ts/docs/commands/proposal/delete.md index 8aeffd1d3..2cbb069a2 100644 --- a/ts/docs/commands/proposal/delete.md +++ b/ts/docs/commands/proposal/delete.md @@ -1,35 +1,79 @@ # wallet-cli proposal delete -Cancel a proposal created by the selected account during its voting window. +Delete a proposal you created. ## Synopsis ``` -wallet-cli proposal delete [--dry-run | --sign-only | --build-only] [options] +wallet-cli proposal delete + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [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. +Withdraws the proposal itself. Only its creator can do this, and only while it is still in its voting window; afterwards the proposal has been tallied and is final. + +This is a different action from [`proposal approve --cancel`](approve.md), which withdraws a single approval. The receipts say so: `Proposal deleted` here, `Approval canceled` there. + +The chain records the result under its own name — after a successful delete, [`proposal show`](show.md) reports `State canceled`. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options -`` 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`. +| Option | Description | +|---|---| +| `` | **Required.** Proposal id | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). -## Example +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash echo "$PW" | wallet-cli proposal delete 48 --network tron:nile --wait --password-stdin ``` +```console +✅ Proposal deleted + Proposal #48 + Proposer TSRmq8kP...9dEf (main) + TxID c7d... + Block 57,880,355 + Fee 0 TRX (265 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli proposal delete 48 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"proposal.delete","data":{"kind":"proposal-delete","stage":"confirmed","txId":"c7d...","confirmed":true,"blockNumber":57880355,"failed":false,"proposalId":48,"feeSun":0,"resource":{"netUsage":265,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6390,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + ## Output -Returns `kind: "proposal-delete"`, proposal/proposer identity, transaction stage/id, and confirmed resource usage. +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "proposal-delete"`, `stage: "submitted"`, `txId`, `proposalId` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | ## Exit status -`0` built/signed/submitted · `1` `proposal_not_found`, `not_proposal_owner`, `proposal_expired`, `already_canceled`, signer/auth, RPC, or chain failure · `2` invalid input. +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`proposal_not_found` — no such proposal, `not_proposal_owner` — you are not its creator, `proposal_expired`, `already_canceled`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — id not a number). ## See also -[`proposal approve`](approve.md) · [`proposal show`](show.md) +[`proposal create`](create.md) · [`proposal approve`](approve.md) · [`proposal show`](show.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/proposal/index.md b/ts/docs/commands/proposal/index.md index 60c30d482..41afa25d8 100644 --- a/ts/docs/commands/proposal/index.md +++ b/ts/docs/commands/proposal/index.md @@ -1,6 +1,17 @@ # wallet-cli proposal -Query and operate TRON chain-parameter proposals. Read commands are public; create, approve, and delete require a registered witness account. +Create and vote on governance proposals. + +A proposal is a set of **chain-parameter changes** — the same parameters [`chain params`](../chain/params.md) reports — that super representatives vote on. Reading proposals is open to anyone; creating, approving, and deleting them requires a registered witness ([`witness create`](../witness/create.md)). + +The mechanics that shape every subcommand: + +- **Approve or un-approve only.** There is no "against" vote — an SR either adds its approval or withdraws it. +- **Nothing settles early.** A proposal stays in its voting window until `expiration_time`, even once it has enough approvals; it is tallied at the maintenance cycle that follows. +- **Only the top-27 active SRs count.** Any registered witness can approve and the transaction succeeds, but the tally filters to active SRs and needs ≥ 70 % of them. +- **Approved changes apply immediately** at that tally — the parameter is live from then on. + +States: `voting` (in the window) · `approved` (met the threshold, applied, final) · `disapproved` (expired below the threshold, final) · `canceled` (withdrawn by its creator before expiry, final). ## Synopsis @@ -12,12 +23,12 @@ wallet-cli proposal COMMAND | 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 | +| `proposal list` | [list.md](list.md) | List proposals with approval progress | +| `proposal show` | [show.md](show.md) | Full detail of one proposal | +| `proposal create` | [create.md](create.md) | Create a proposal to change chain parameters | +| `proposal approve` | [approve.md](approve.md) | Approve a proposal, or cancel your approval | +| `proposal delete` | [delete.md](delete.md) | Delete a proposal you created | ## See also -[`chain params`](../chain/params.md) · [`witness`](../witness/index.md) · [`vote`](../vote/index.md) +[`witness`](../witness/index.md) · [`chain params`](../chain/params.md) · [`vote list`](../vote/list.md) diff --git a/ts/docs/commands/proposal/list.md b/ts/docs/commands/proposal/list.md index be815763a..6f5485281 100644 --- a/ts/docs/commands/proposal/list.md +++ b/ts/docs/commands/proposal/list.md @@ -1,43 +1,88 @@ # wallet-cli proposal list -List chain-parameter proposals, newest first. +List on-chain governance proposals. ## Synopsis ``` -wallet-cli proposal list [--state active|all] [--offset ] [--limit ] [options] +wallet-cli proposal list [--state ] [--limit ] [--offset ] [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`. +Lists proposals with their approval progress, expiry, and the chain parameters each one would set. Parameters are shown by name, using the same vocabulary as [`chain params`](../chain/params.md). Read-only, no account needed. -The `Value` column is what the proposal would set, not the value in effect now — a proposal does not record what the parameter was before it. See [`chain params`](../chain/params.md) for current values. +**`Value` is what the proposal would set, not the value in effect now.** A proposal records only its target values — the chain keeps no record of what a parameter was when the proposal was created. For a settled proposal the current value is unrelated to that baseline, and for an approved one it *is* the value that proposal installed. Use [`chain params`](../chain/params.md) for the values in effect now. + +A proposal can set several parameters at once. The list never truncates them: the first parameter sits on the proposal's row, the rest continue on their own rows with the left-hand columns blank. Parameters are ordered by parameter id, so the same proposal always prints in the same order; `data.proposals[].parameters[]` uses that order too. + +Filtering is client-side and happens before pagination: `--state` narrows the set, then `--offset` / `--limit` cut a window out of it. The title carries the count — `Proposals (4)` for the whole set, `Proposals (showing 2 of 4)` once a window is in play. Exact numbers are in `meta.pagination`. When nothing matches, the title is followed by `(none)`. ## Options | Option | Description | |---|---| -| `--state ` | State filter; default `active` | -| `--offset ` | Zero-based offset; default 0 | -| `--limit ` | Positive page size; omitted means all remaining rows | +| `--state ` | `active` = still inside the voting window (default); `all` = also approved, disapproved, and canceled ones | +| `--limit ` | Max proposals to return (default: all) | +| `--offset ` | Pagination offset (default `0`) | Plus the [global options](../index.md#global-options-every-command). -## Example +## Examples + +```bash +wallet-cli proposal list --state all --network tron:nile +``` + +```console +Proposals (4) + ID State Approvals Expiry (UTC) Parameter Value + 47 voting 12 / 18 2026-07-22 08:00 getTransactionFee 15 + 46 voting 5 / 18 2026-07-22 08:00 getCreateAccountFee 200000 + 45 approved 18 / 18 2026-07-21 08:00 getEnergyFee 140 + 44 disapproved 8 / 18 2026-07-20 08:00 getMaintenanceTimeInterval 10800000 + getMaxCpuTimeOfOneTx 80 +``` + +Second page — skip the first two, two per page: ```bash -wallet-cli proposal list --state all --offset 20 --limit 20 --network tron:nile -o json +wallet-cli proposal list --state all --offset 2 --limit 2 --network tron:nile +``` + +```console +Proposals (showing 2 of 4) + ID State Approvals Expiry (UTC) Parameter Value + 45 approved 18 / 18 2026-07-21 08:00 getEnergyFee 140 + 44 disapproved 8 / 18 2026-07-20 08:00 getMaintenanceTimeInterval 10800000 + getMaxCpuTimeOfOneTx 80 +``` + +```bash +wallet-cli proposal list --state all --network tron:nile -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"proposal.list","data":{"approvalThreshold":18,"proposals":[{"id":47,"proposerAddress":"TSRmq8kP...","state":"voting","approvals":12,"expirationTime":1784707200000,"parameters":[{"id":3,"name":"getTransactionFee","value":15,"unit":"sun/byte"}]},{"id":44,"proposerAddress":"TSRee5...","state":"disapproved","approvals":8,"expirationTime":1784534400000,"parameters":[{"id":0,"name":"getMaintenanceTimeInterval","value":10800000,"unit":"ms"},{"id":13,"name":"getMaxCpuTimeOfOneTx","value":80,"unit":"ms"}]}]},"meta":{"durationMs":31,"warnings":[],"pagination":{"offset":0,"limit":null,"total":4}},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output -`data.approvalThreshold` is 18 for the normal 27-member active SR set. `data.proposals[]` contains `id`, `proposerAddress`, normalized `state`, approval count, expiry, and sorted `parameters[]` — each entry `{ id, name, value, unit }`. `meta.pagination` contains `offset`, `limit`, and the filtered total. Text output prints `(none)` when nothing matches the filter. +| Field | Type | Meaning | +|---|---|---| +| `approvalThreshold` | number | Approvals needed to pass = 70 % of the active SRs | +| `proposals[].id` | number | Proposal id | +| `proposals[].proposerAddress` | string | Creator, base58 | +| `proposals[].state` | string | `voting` / `approved` / `disapproved` / `canceled` | +| `proposals[].approvals` | number | Approvals cast so far | +| `proposals[].expirationTime` | number | End of the voting window, ms since epoch | +| `proposals[].parameters[]` | array | `id`, `name`, `value` (what the proposal sets), `unit`; ordered by `id` | +| `meta.pagination` | object | `offset`, `limit` (`null` = unlimited), `total` after `--state` filtering | ## Exit status -`0` success · `1` RPC failure · `2` invalid state or pagination value. +`0` success · `1` execution failure (`rpc_error`) · `2` usage error (`invalid_value` — bad state, limit, or offset). ## See also -[`proposal show`](show.md) · [`chain params`](../chain/params.md) +[`proposal show`](show.md) · [`proposal create`](create.md) · [`chain params`](../chain/params.md) diff --git a/ts/docs/commands/proposal/show.md b/ts/docs/commands/proposal/show.md index 4e504097c..bce992099 100644 --- a/ts/docs/commands/proposal/show.md +++ b/ts/docs/commands/proposal/show.md @@ -1,6 +1,6 @@ # wallet-cli proposal show -Show one proposal, the parameters it sets, and approval progress. +Show one proposal, the parameters it sets, and its approval progress. ## Synopsis @@ -10,30 +10,103 @@ 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. +Reports a single proposal: every parameter it sets as `name value` with its unit, the approval count against the threshold, and the creation and expiry times. Read-only, no account needed. -Each parameter's value is the one the proposal would set, not the value in effect now — the chain does not record what the parameter was when the proposal was created. For a settled proposal the current value is unrelated to that baseline, and for an approved one it *is* the value the proposal installed. See [`chain params`](../chain/params.md) for the values in effect now. +**The value shown is the one the proposal would set, not the value in effect now.** The chain does not record what a parameter was when the proposal was created. For a settled proposal the current value is unrelated to that baseline, and for an approved one it *is* the value the proposal installed. Use [`chain params`](../chain/params.md) for the values in effect now. -## Arguments +Text shows the approval count only. The addresses behind it are in the json as `approvedBy[]`, at full length. -| Argument | Description | +`State` is the chain's own value, so a proposal deleted by its creator reads `canceled` here. + +## Options + +| Option | Description | |---|---| -| `id` | Positive proposal id | +| `` | **Required.** Proposal id | + +Plus the [global options](../index.md#global-options-every-command). -## Example +## Examples + +A proposal inside its voting window: ```bash wallet-cli proposal show 47 --network tron:nile ``` +```console +Proposal #47 + State voting + Proposer TSRmq8kP...9dEf + Created time 2026-07-21 08:00 UTC + Expiry time 2026-07-22 08:00 UTC + Approvals 12 / 18 + Parameters (1) + getTransactionFee 15 sun/byte +``` + +One that reached the threshold at expiry — the value is live from that tally on: + +```bash +wallet-cli proposal show 45 --network tron:nile +``` + +```console +Proposal #45 + State approved + Proposer TSRwd3nL...8vC + Created time 2026-07-20 08:00 UTC + Expiry time 2026-07-21 08:00 UTC + Approvals 18 / 18 + Parameters (1) + getEnergyFee 140 sun +``` + +One that expired below the threshold, carrying two parameters: + +```bash +wallet-cli proposal show 44 --network tron:nile +``` + +```console +Proposal #44 + State disapproved + Proposer TSRee5...2xB + Created time 2026-07-19 08:00 UTC + Expiry time 2026-07-20 08:00 UTC + Approvals 8 / 18 + Parameters (2) + getMaintenanceTimeInterval 10800000 ms + getMaxCpuTimeOfOneTx 80 ms +``` + +```bash +wallet-cli proposal show 47 --network tron:nile -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"proposal.show","data":{"id":47,"proposerAddress":"TSRmq8kP...","state":"voting","createTime":1784620800000,"expirationTime":1784707200000,"approvals":12,"approvalThreshold":18,"reachedThreshold":false,"parameters":[{"id":3,"name":"getTransactionFee","value":15,"unit":"sun/byte"}],"approvedBy":["TSRaa1...","TSRbb2..."]},"meta":{"durationMs":22,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + ## Output -Returns the proposer, create/expiry timestamps, threshold status, approving addresses, and `parameters[]` sorted by id — each entry `{ id, name, value, unit }`. +| Field | Type | Meaning | +|---|---|---| +| `id` | number | Proposal id | +| `proposerAddress` | string | Creator, base58 | +| `state` | string | `voting` / `approved` / `disapproved` / `canceled` | +| `createTime` / `expirationTime` | number | Creation and end of the voting window, ms since epoch | +| `approvals` / `approvalThreshold` | number | Approvals cast, and the count needed to pass | +| `reachedThreshold` | boolean | Whether `approvals` already meets `approvalThreshold` | +| `parameters[]` | array | `id`, `name`, `value` (what the proposal sets), `unit`; ordered by `id` | +| `approvedBy[]` | array | Addresses that have approved, base58 (json only) | + +There is no cancellation timestamp: the chain's proposal record holds only the fields above, so `canceled` carries no time of its own. ## Exit status -`0` success · `1` `proposal_not_found` or RPC failure · `2` invalid id. +`0` success · `1` execution failure (`proposal_not_found` — no such proposal, `rpc_error`) · `2` usage error (`invalid_value` — id not a number). ## See also -[`proposal list`](list.md) · [`proposal approve`](approve.md) +[`proposal list`](list.md) · [`proposal approve`](approve.md) · [`chain params`](../chain/params.md) diff --git a/ts/docs/commands/tx/multisig.md b/ts/docs/commands/tx/multisig.md index 18db4c9cd..e897f135d 100644 --- a/ts/docs/commands/tx/multisig.md +++ b/ts/docs/commands/tx/multisig.md @@ -174,7 +174,7 @@ A record the client cannot reconcile with the chain stays visible and is labelle ## Exit status -`0` success · `1` execution failure (`tronlink_credentials_missing`, `not_found` — txId not on the service, `not_authorized`, `already_signed`, `tx_expired`, `wrong_password`, `provider_error` — service error / rate limit) · `2` usage error (`invalid_value` — including an already-signed transaction passed to `--create`, conflicting modes). +`0` success · `1` execution failure (`tronlink_credentials_missing`, `not_found` — txId not on the service, `not_authorized`, `already_signed`, `tx_expired`, `auth_failed`, `provider_error` — service error / rate limit) · `2` usage error (`invalid_value` — including an already-signed transaction passed to `--create`, conflicting modes). ## See also diff --git a/ts/docs/commands/tx/sign.md b/ts/docs/commands/tx/sign.md index 3718554be..6179bdf2e 100644 --- a/ts/docs/commands/tx/sign.md +++ b/ts/docs/commands/tx/sign.md @@ -143,7 +143,7 @@ No `fee` is reported for `--transaction`: nothing was estimated, because the tra ## Exit status -`0` success · `1` execution failure (`tx_integrity` — the three payload representations disagree, `invalid_transaction`, `tx_expired`, `not_authorized` — this account isn't in the group's key list, `already_signed`, `watch_only_no_signer`, `wrong_password`, `signing_rejected`, `rpc_error`) · `2` usage error (`invalid_value`, `missing_option`). +`0` success · `1` execution failure (`tx_integrity` — the three payload representations disagree, `invalid_transaction`, `tx_expired`, `not_authorized` — this account isn't in the group's key list, `already_signed`, `watch_only_no_signer`, `auth_failed`, `signing_rejected`, `rpc_error`) · `2` usage error (`invalid_value`, `missing_option`). ## See also diff --git a/ts/docs/commands/vote/list.md b/ts/docs/commands/vote/list.md index 6340fd625..37254d550 100644 --- a/ts/docs/commands/vote/list.md +++ b/ts/docs/commands/vote/list.md @@ -34,10 +34,11 @@ wallet-cli vote list --limit 3 --network tron:nile ``` ```console -Rank Name Votes APR Reward ratio Address - 1 TRONSCAN 1,203,456,789 4.8% 80% TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g - 2 Binance Staking 998,765,432 0% 0% TT5W8MPbYJih9R586kTszb4LoybzUvCYm2 - 3 JustLend 876,543,210 4.9% 80% TWxkzUeAiKcFvzXvJEcaTQCQqCuMednAtN +| Rank | Name | Votes | APR | Reward ratio | Address | +| ---- | --------------- | ------------- | ---- | ------------ | ---------------------------------- | +| 1 | TRONSCAN | 1,203,456,789 | 4.8% | 80% | TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g | +| 2 | Binance Staking | 998,765,432 | 0% | 0% | TT5W8MPbYJih9R586kTszb4LoybzUvCYm2 | +| 3 | JustLend | 876,543,210 | 4.9% | 80% | TWxkzUeAiKcFvzXvJEcaTQCQqCuMednAtN | ``` ```bash diff --git a/ts/docs/commands/vote/status.md b/ts/docs/commands/vote/status.md index f283619b8..b1e0b1350 100644 --- a/ts/docs/commands/vote/status.md +++ b/ts/docs/commands/vote/status.md @@ -33,10 +33,11 @@ Voting power 1,500 TP (used 1,000 / available 500) Claimable 12.345678 TRX Current votes (2) - Name Votes APR Reward ratio Address - TRONSCAN 600 4.8% 80% TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g - Binance Staking 400 0% 0% TT5W8MPbYJih9R586kTszb4LoybzUvCYm2 -! 400 votes are on an SR with 0% reward ratio — they earn you nothing +| Name | Votes | APR | Reward ratio | Address | +| --------------- | ----- | ---- | ------------ | ---------------------------------- | +| TRONSCAN | 600 | 4.8% | 80% | TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g | +| Binance Staking | 400 | 0% | 0% | TT5W8MPbYJih9R586kTszb4LoybzUvCYm2 | +! 400 votes on Binance Staking earn nothing — 0% reward ratio ``` ```bash diff --git a/ts/docs/commands/witness/create.md b/ts/docs/commands/witness/create.md index 026dffa58..b9ddb39fa 100644 --- a/ts/docs/commands/witness/create.md +++ b/ts/docs/commands/witness/create.md @@ -1,42 +1,81 @@ # wallet-cli witness create -Register an activated account as an SR candidate. +Register the account as a super representative candidate. ## Synopsis ``` -wallet-cli witness create --url [--dry-run | --sign-only | --build-only] [options] +wallet-cli witness create --url + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [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`. +Registers the acting account as an SR candidate, making it votable and eligible to produce blocks once its votes reach the top 27. It also makes the account a witness for governance purposes — [`proposal create`](../proposal/create.md) and [`proposal approve`](../proposal/approve.md) require it. + +**Registration burns a fee — currently about 9,999 TRX — and it is not refundable.** The exact amount is the chain parameter `getAccountUpgradeCost` ([`chain params`](../chain/params.md)), so read it there rather than assuming; the receipt's `Fee` line reports what was actually burned. There is no way to unregister. + +The account must already be activated and hold at least the registration fee. `--url` is the candidate info page — the website explorers show next to the SR — and is the only business field the chain stores for a candidate; change it later with [`witness update`](update.md). + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `--url ` | Required candidate information URL, at most 256 UTF-8 bytes | -| `--dry-run`, `--sign-only`, `--build-only` | Mutually exclusive transaction modes | +| `--url ` | **Required.** Candidate info page | +| `--dry-run` | Build and estimate only, no signature/broadcast; reports the registration fee; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). -Plus `--account`, `--wait`, `--password-stdin`, and the [global options](../index.md#global-options-every-command). +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli witness create --url https://sr.acme.io --network tron:nile --wait --password-stdin +``` -## Example +```console +✅ Witness registered + Witness TSRmq8kP...9dEf (main) + Url https://sr.acme.io + TxID d3a... + Block 57,881,020 + Fee 9,999 TRX (285 bandwidth) + Status success +``` ```bash -echo "$PW" | wallet-cli witness create --url https://sr.example --network tron:nile --wait --password-stdin +echo "$PW" | wallet-cli witness create --url https://sr.acme.io --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"witness.create","data":{"kind":"witness-create","stage":"confirmed","txId":"d3a...","confirmed":true,"blockNumber":57881020,"failed":false,"witnessAddress":"TSRmq8kP...","url":"https://sr.acme.io","feeSun":9999000000,"resource":{"netUsage":285,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0},"registrationFeeSun":9999000000},"meta":{"durationMs":6620,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output -Returns the witness address, URL, irreversible registration fee, transaction stage/id, and confirmed bandwidth/resource usage. +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "witness-create"`, `stage: "submitted"`, `txId`, `witnessAddress`, `url` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed`, `registrationFeeSun` | + +`registrationFeeSun` is the burned registration fee on its own; `feeSun` is the transaction's total cost, which includes it. ## Exit status -`0` built/signed/submitted · `1` `already_witness`, `account_not_active`, `insufficient_balance`, missing chain fee, signer/auth, RPC, or chain failure · `2` invalid input. +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`already_witness`, `account_not_active`, `insufficient_balance` — below the registration fee, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--url`). ## See also -[`witness update`](update.md) · [`chain params`](../chain/params.md) +[`witness update`](update.md) · [`witness set-brokerage`](set-brokerage.md) · [`proposal create`](../proposal/create.md) · [`chain params`](../chain/params.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/witness/index.md b/ts/docs/commands/witness/index.md index 5e90d863b..60e921f4c 100644 --- a/ts/docs/commands/witness/index.md +++ b/ts/docs/commands/witness/index.md @@ -1,6 +1,14 @@ # wallet-cli witness -Register and operate a TRON super representative candidacy. +Register and operate a super representative (SR) candidacy. + +Registering turns an ordinary account into an **SR candidate** — it can be voted for ([`vote cast`](../vote/cast.md)), and if its votes put it in the top 27 it produces blocks. Candidacy is also what unlocks governance: only a registered witness can create or approve [proposals](../proposal/index.md). + +The chain stores very little about a candidate: the owner address and a single **url** — the info page shown next to the SR in explorers — which is the only field this group can change. Everything else about an SR (rank, votes, block production) is a consequence of votes, not a setting. + +The one economic knob is **brokerage**: the share of block rewards the SR keeps, with the remainder distributed to its voters. It defaults to 20 %. + +Registration burns a fee (currently ≈ 9,999 TRX) and cannot be undone. ## Synopsis @@ -13,9 +21,11 @@ wallet-cli witness COMMAND | 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 | +| `witness update` | [update.md](update.md) | Change the candidate info page URL | +| `witness set-brokerage` | [set-brokerage.md](set-brokerage.md) | Set the share of block rewards the SR keeps | + +Candidates, their votes, and their brokerage are read with [`vote list`](../vote/list.md) — which shows the 27 elected SRs by default, so a candidate outside that set needs `vote list --candidates`. ## See also -[`proposal`](../proposal/index.md) · [`vote`](../vote/index.md) · [`reward`](../reward/index.md) +[`proposal`](../proposal/index.md) · [`vote list`](../vote/list.md) · [`reward balance`](../reward/balance.md) diff --git a/ts/docs/commands/witness/set-brokerage.md b/ts/docs/commands/witness/set-brokerage.md index 7c6b087b6..2b0c05dbe 100644 --- a/ts/docs/commands/witness/set-brokerage.md +++ b/ts/docs/commands/witness/set-brokerage.md @@ -1,39 +1,83 @@ # wallet-cli witness set-brokerage -Set the percentage of rewards retained by the SR. +Set the share of block rewards the SR keeps. ## Synopsis ``` -wallet-cli witness set-brokerage [--dry-run | --sign-only | --build-only] [options] +wallet-cli witness set-brokerage + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [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. +`` is the **brokerage** — the percentage of block rewards the SR keeps for itself; the remaining `100 − percent` is distributed to its voters in proportion to their votes. It defaults to 20, and is validated locally as an integer 0–100 before anything is broadcast. -## Arguments +This is the same number [`vote list`](../vote/list.md) reports as `brokeragePct`; that page's `Reward ratio` column is its complement — the voters' share. Setting `100` means voters earn nothing from your blocks. -| Argument | Description | +Any registered witness can set it, elected or not. The acting account must be a candidate; otherwise the command fails with `not_a_witness`. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | |---|---| -| `percent` | Integer 0–100 retained by the SR | +| `` | **Required.** Share the SR keeps, integer 0–100 | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). -Transaction controls are `--dry-run`, `--sign-only`, `--build-only`, `--expiration` (build/sign-only, max 24 h), `--permission-id`, `--account`, `--wait`, and `--password-stdin`. +## Examples -## Example +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +Keep 20 %, pass 80 % to voters: ```bash echo "$PW" | wallet-cli witness set-brokerage 20 --network tron:nile --wait --password-stdin ``` +```console +✅ Brokerage set + Witness TSRmq8kP...9dEf (main) + Brokerage 20% + TxID f8c... + Block 57,881,402 + Fee 0 TRX (269 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli witness set-brokerage 20 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"witness.set-brokerage","data":{"kind":"witness-set-brokerage","stage":"confirmed","txId":"f8c...","confirmed":true,"blockNumber":57881402,"failed":false,"witnessAddress":"TSRmq8kP...","brokerage":20,"feeSun":0,"resource":{"netUsage":269,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6470,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + ## Output -Returns witness address, the unchanged brokerage value, transaction stage/id, and confirmed resource usage. +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "witness-set-brokerage"`, `stage: "submitted"`, `txId`, `witnessAddress`, `brokerage` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +`brokerage` is the value now in effect, as a number. ## Exit status -`0` built/signed/submitted · `1` `not_a_witness`, signer/auth, RPC, or chain failure · `2` percentage or mode error. +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — percent missing, not an integer, or outside 0–100). ## See also -[`vote list`](../vote/list.md) · [`reward`](../reward/index.md) +[`witness create`](create.md) · [`vote list`](../vote/list.md) · [`reward balance`](../reward/balance.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/witness/update.md b/ts/docs/commands/witness/update.md index 33532e614..688856ec6 100644 --- a/ts/docs/commands/witness/update.md +++ b/ts/docs/commands/witness/update.md @@ -1,35 +1,77 @@ # wallet-cli witness update -Update an SR candidate's information URL. +Change the candidate info page URL. ## Synopsis ``` -wallet-cli witness update --url [--dry-run | --sign-only | --build-only] [options] +wallet-cli witness update --url + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] ``` ## Description -The selected account must already be a registered witness. This operation has no registration burn and can be repeated. +Replaces the info page URL of an existing SR candidacy. The url is the only field the chain keeps for a candidate, so this is the whole of "editing" an SR. It can be changed as often as needed and costs only bandwidth. + +The acting account must already be a candidate; otherwise the command fails with `not_a_witness`. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options -`--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`. +| Option | Description | +|---|---| +| `--url ` | **Required.** New candidate info page | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli witness update --url https://sr.acme.io/v2 --network tron:nile --wait --password-stdin +``` -## Example +```console +✅ Witness updated + Witness TSRmq8kP...9dEf (main) + Url https://sr.acme.io/v2 + TxID e5b... + Block 57,881,190 + Fee 0 TRX (270 bandwidth) + Status success +``` ```bash -echo "$PW" | wallet-cli witness update --url https://sr.example/v2 --network tron:nile --wait --password-stdin +echo "$PW" | wallet-cli witness update --url https://sr.acme.io/v2 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"witness.update","data":{"kind":"witness-update","stage":"confirmed","txId":"e5b...","confirmed":true,"blockNumber":57881190,"failed":false,"witnessAddress":"TSRmq8kP...","url":"https://sr.acme.io/v2","feeSun":0,"resource":{"netUsage":270,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6440,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output -Returns `kind: "witness-update"`, witness address, URL, transaction stage/id, and confirmed resource usage. +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "witness-update"`, `stage: "submitted"`, `txId`, `witnessAddress`, `url` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | ## Exit status -`0` built/signed/submitted · `1` `not_a_witness`, signer/auth, RPC, or chain failure · `2` invalid input. +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--url`). ## See also -[`witness create`](create.md) · [`witness set-brokerage`](set-brokerage.md) +[`witness create`](create.md) · [`witness set-brokerage`](set-brokerage.md) · [`vote list`](../vote/list.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/concepts/accounts-and-hd.md b/ts/docs/concepts/accounts-and-hd.md index a27bb4a60..186d9548b 100644 --- a/ts/docs/concepts/accounts-and-hd.md +++ b/ts/docs/concepts/accounts-and-hd.md @@ -34,7 +34,7 @@ Labels are unique, 1–64 chars, renameable (`rename`) — the stable handle is ## Lifecycle -- `backup ` exports secret + metadata to a file created with mode **0600** and never overwritten (default under `/backups/`). Treat the file as the secret it contains. +- `backup ` exports secret + metadata to a file created with mode **0600** and never overwritten (in the current working directory by default). Treat the file as the secret it contains — and mind where you run it, since the CLI does not check whether that directory is shared or version-controlled. - `delete` removes accounts; **deleting an HD wallet cascades from the seed root** — all derived accounts of that seed go with it. The on-chain assets are untouched: re-import the mnemonic to regain access. - Losing the master password is unrecoverable locally; the escape hatch is always the mnemonic → `import mnemonic`. diff --git a/ts/docs/java-parity-v4.12-governance.md b/ts/docs/java-parity-v4.12-governance.md deleted file mode 100644 index d7ffd0dee..000000000 --- a/ts/docs/java-parity-v4.12-governance.md +++ /dev/null @@ -1,37 +0,0 @@ -# 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 d2a2920a6..2d9058cf4 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -56,14 +56,14 @@ Schema id: `wallet-cli.result.v1`. | ----------------- | ------------------------ | ------------------- | -------------------------------------------------------------------------------- | | `schema` | `"wallet-cli.result.v1"` | always | Version gate; dispatch on this | | `success` | boolean | always | Mirrors the exit code (`true` ⇔ 0) | -| `command` | string | always | Canonical command id, e.g. `tx.send`, `list` | +| `command` | string | always | Canonical command id, e.g. `tx.send`, `list`. It names the **operation**, not the words typed: `backup --records` reports `backup.records`, `import keystore` reports `import.keystore` | | `data` | object/array | success only | Command-specific payload; see each command's reference page | | `error.code` | string | error only | Machine-readable; see [error codes](#error-codes) | | `error.message` | string | error only | Human-readable; **not** stable — never parse it | | `error.details` | object | optional | Structured extras when available | | `meta.durationMs` | number | always | Wall time | | `meta.warnings` | `(string \| {code, message})[]` | always | Non-fatal notices; **elements are not uniformly typed** — see below | -| `meta.pagination` | `{offset, limit, total}` | paginated reads only | The window this response returned; `limit`/`total` are nullable — see below | +| `meta.pagination` | object | paginated commands only | `offset` / `limit` / `total`; present where `--limit` / `--offset` apply — see [pagination](#pagination) | | `chain` | object | chain commands only | `family` / `network` / `chainId`; neutral commands (`list`, `config`, …) omit it | Encoding rules: `bigint` values are serialized as decimal **strings** (e.g. `"balance": "1976489000"`), binary as hex. Treat every on-chain amount as a string. @@ -82,26 +82,21 @@ 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` +### 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 list`, and any list command added later. Its absence means the command is not paginated. +Every command that takes `--limit` / `--offset` reports the window it returned in `meta.pagination`, never inside `data`: -```json -"meta": { "durationMs": 8, "warnings": [], "pagination": { "offset": 0, "limit": 10, "total": null } } -``` - -| Field | Type | Meaning | +| Key | Type | Meaning | |---|---|---| -| `offset` | number | Index this page started at — echoes `--offset` | +| `offset` | number | Index the page started at — echoes `--offset` | | `limit` | number \| **null** | Page size; `null` = unlimited (no `--limit` given) | -| `total` | number \| **null** | Matching records in total; `null` = **no count exists**, not "we omitted it" | +| `total` | number \| **null** | Matching records in total; `null` means **no count exists**, not "it was omitted" | -All three keys are always present, so `null` is the only "unknown" signal and you never have to distinguish absent from null. +All three keys are always present, so `null` is the only "unknown" signal and absent never has to be told apart 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: +`total` is `null` permanently for the commands served by TRON's paginated node endpoints — [`asset list`](commands/asset/list.md) and [`exchange list`](commands/exchange/list.md). The endpoint returns no count, and computing one would mean transferring every record (5,187 assets, 2.7 MB on mainnet). Page until a short page comes back rather than comparing against a total: ```bash -# works whether or not a total is knowable offset=0 while :; do page=$(wallet-cli asset list --limit 50 --offset "$offset" -o json) @@ -112,7 +107,9 @@ while :; do 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`. +Commands that page a local, bounded set ([`backup --records`](commands/backup.md)) or that fetch everything and window it client-side ([`proposal list`](commands/proposal/list.md)) do report a `total`. + +Text mode titles the same window (`Assets (limit 50, offset 0)`, `Proposals (showing 2 of 4)`, `Backup records (showing 3 of 12)`), but text is not part of this contract — parse `-o json`. ## Error codes @@ -133,30 +130,26 @@ Common codes at exit **2** (usage — fix the call): | `missing_network` / `unsupported_network` | `--network` absent, or not a known canonical id | | `unknown_command` | No such command | | `output_exists` | Target file already exists and is never overwritten (`backup --out`, `address generate --out`). Deterministic — retrying the same path always fails | +| `file_not_found` | An input file named by a flag does not exist (`contract create2 --code-file`) | | `keystore_not_found` | `import keystore`: no file at the given path | -| `invalid_keystore` | `import keystore`: not a valid Web3 V3 keystore — bad JSON, `version` ≠ 3, unsupported cipher/kdf, or a payload that is not a 32-byte private key | +| `invalid_keystore` | `import keystore`: not a valid Web3 V3 keystore — bad JSON, `version` ≠ 3, an unsupported cipher/KDF, or a payload that is not a 32-byte private key | | `invalid_config` | `config.yaml` cannot be read or is not valid YAML — fix or remove the file. The parser detail is withheld: it quotes the offending line, which may carry a credential | | `insecure_config` | `config.yaml` holds service credentials but is a symlink or is group/world-readable — run `chmod 600` on it (POSIX only; not enforced on Windows) | | `token_not_in_book` / `token_is_official` / `token_metadata_unavailable` | Token address-book conditions | -| `unknown_parameter` | Unknown governance parameter name or id | +| `unknown_parameter` | No chain parameter by that name or id (`proposal create --set`) | +| `invalid_asset_name` | A TRC10 name or abbreviation outside 1–32 visible ASCII characters | Common codes at exit **1** (execution — runtime failure): | Code | Meaning | |---|---| | `rpc_error` | The TRON node rejected or failed the request | -| `invalid_node_response` | The node's answer contradicts the request or the protocol: a TRC10/exchange record whose id is not the one asked for, a `precision` outside 0..6, or a rate pair that is not a positive int32. These fields decide signed amounts, so the command stops rather than acting on them. List reads drop the offending record and keep the page | +| `invalid_node_response` | The node's answer contradicts the request or the protocol: a TRC10/exchange record whose id is not the one asked for, a `precision` outside 0..6, or a rate pair that is not a positive int32. These decide signed amounts, so the command stops rather than acting on them. List reads drop the offending record and keep the page | | `timeout` | Aborted waiting for network or device (`--timeout` exceeded) | | `auth_required` | Master password required but not supplied | | `auth_failed` | Wrong master password (decryption failed) | -| `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 | @@ -164,10 +157,38 @@ Common codes at exit **1** (execution — runtime failure): | `gasfree_credentials_missing` / `tronlink_credentials_missing` | Required service credentials are not configured (set them with `config`) | | `tx_expired` | The transaction's expiration passed before signatures were collected | | `history_not_supported` | The endpoint lacks TronGrid history support | +| `not_found` | The addressed thing does not exist — an unactivated account, a contact, a chain parameter, a GasFree or TronLink resource. Lookups that have a group of their own use the specific code below | +| `proposal_not_found` / `contract_not_found` / `asset_not_found` / `exchange_not_found` | Nothing on chain under that proposal id, contract address, TRC10 reference, or exchange pair id | +| `ambiguous_asset_name` | A TRC10 name matches more than one token; `error.details` carries the candidates — see [`error.details.matches`](#errordetailsmatches) | +| `ledger_unsupported` | The Ledger TRON app cannot sign this contract type — refused before the device is touched (`asset` writes, `witness` writes) | +| `not_a_witness` / `already_witness` / `not_proposal_owner` | Governance identity does not meet the operation's rule | +| `already_approved` / `not_approved` / `proposal_expired` / `already_canceled` | Proposal voting conditions | +| `account_not_active` / `chain_parameter_unavailable` | `witness create`: the account is not activated on chain, or the node did not return `getAccountUpgradeCost` | +| `not_contract_deployer` | The account did not deploy that contract | +| `already_issued_asset` / `not_an_issuer` | The account has already issued a TRC10, or has never issued one | +| `not_in_ico_window` / `self_participation` | TRC10 ICO participation conditions | +| `no_frozen_supply` / `not_yet_unfreezable` | Nothing frozen, or nothing matured yet (`asset unfreeze`) | +| `not_exchange_creator` / `token_not_in_exchange` / `exchange_closed` / `same_token` | Exchange-pair access and state conditions | +| `insufficient_reserve` | `exchange withdraw`: more than that side of the pair holds | +| `precision_loss` / `slippage_exceeded` / `exchange_trading_disabled` | Node rejections named from a narrow allowlist — an amount the reserve ratio cannot convert cleanly, a return below the floor, or a network that is not accepting Bancor trades at all | +| `not_exportable` | The account holds no exportable secret (watch-only or Ledger) — `backup` | +| `account_exists` / `wrong_keystore_password` | `import keystore`: the address is already in the wallet, or the file's own password is wrong (distinct from `auth_failed`, which is the master password). A file whose `mac` is missing or not hex is `invalid_keystore`, not a wrong password — hex case is not significant | | `internal_error` | Unexpected internal failure; message is intentionally generic | Unexpected exceptions are **redacted** to `internal_error` with a generic message, so a library error that happens to echo secret material can never reach the envelope. This list is representative, not exhaustive — new codes may be added within v1. +### `error.details.matches` + +Some failures are a **choice**, not a dead end: the call was well formed but names something that resolves to several candidates, and the caller has to pick one. Those errors put the candidates in `error.details.matches` — an array of flat objects sharing one key set: + +```json +{"code":"ambiguous_asset_name","message":"2 TRC10 tokens are named MyToken; re-run with the id","details":{"name":"MyToken","assetIds":["1000123","1000488"],"matches":[{"assetId":"1000123","issuerAddress":"TQkXm4vN...","totalSupply":"1000000000000000","precision":6},{"assetId":"1000488","issuerAddress":"TZx9kP2m...","totalSupply":"5000000000","precision":2}]}} +``` + +`matches` is the convention, not a per-code special case: **any** error may carry it, and any that does gets the same treatment. In text mode the candidates are printed as a table under the `error [...]` line, on stderr. Quantities inside `matches` stay raw (minimal units), matching how the corresponding success payload reports them; the text table scales them for display when the row carries a `precision`. + +Alongside it, an error may carry a scalar list of just the identifiers to retry with — `assetIds` above. Prefer that for scripting; `matches` exists so a human can tell the candidates apart. + ## Secret handling Secrets never travel via argv or environment variables — they would leak into shell history and process listings. Two channels only: @@ -186,12 +207,14 @@ printf '%s' "$MASTER_PASSWORD_FROM_YOUR_VAULT" | wallet-cli tx send \ This is a wallet; a wrong success check loses money. The rules: -1. Broadcast (✍️) commands **by default return after submission**, not confirmation. The payload is a flat object with a `kind` naming the operation (`send`, `stake-freeze`, `permission-update`, `account-activate`, …), a `stage`, and the `txId`; the `submitted` stage carries no block / fee / result (those appear only after `--wait` confirms): +1. Broadcast (✍️) commands **by default return after submission**, not confirmation. The payload is a flat object with a `kind` naming the operation (`send`, `stake-freeze`, `permission-update`, `account-activate`, `proposal-create`, `asset-issue`, `exchange-trade`, …), a `stage`, and the `txId`; the `submitted` stage carries no block / fee / result (those appear only after `--wait` confirms): ```json { "kind": "send", "stage": "submitted", "txId": "7d9b6a08…", "rawAmount": "1000000", "to": "TSx72…" } ``` + **Ids the chain assigns arrive only with confirmation.** A new proposal's `proposalId`, a TRC10's `assetId`, an exchange pair's `exchangeId` do not exist at submission — they are absent from the submitted receipt and appear once `--wait` (or a later query) sees the transaction on chain. Scripts that create one of these must wait for it. + 2. To block until the outcome is known, pass `--wait` (polls until confirmed/failed, capped by `--wait-timeout`, default 60000 ms; on cap it returns the submitted receipt). **A `--wait` receipt reports the transaction outcome in `data.stage`, never in `success`.** A transaction that was accepted, mined, and then reverted is a *successful command* carrying a *failed transaction*: the envelope stays `success: true` and the exit code stays `0`, while `data.stage` is `"failed"`. Exit codes say whether the CLI could carry out the request, not whether the chain accepted the result — so after any `--wait`, branch on `data.stage` (`confirmed` / `failed` / `submitted`) before recording the operation as done. diff --git a/ts/src/application/services/broadcast-identity.ts b/ts/src/application/services/broadcast-identity.ts new file mode 100644 index 000000000..404746559 --- /dev/null +++ b/ts/src/application/services/broadcast-identity.ts @@ -0,0 +1,38 @@ +/** + * Which transaction id to believe after a broadcast. + * + * A TRON txID is the sha256 of the transaction body: not an identifier the node assigns, but one + * derivable from the bytes we signed — and we derive it. The broadcast reply carries the node's own + * copy, and it is the same value whenever the node is honest (verified against a live Nile node). + * Taking it on trust anyway means a node whose copy is wrong chooses which transaction we then poll + * for with `--wait` and quote back to the caller, which is how a receipt ends up describing someone + * else's successful transaction. + * + * So the locally derived id wins where we have one. Disagreement is surfaced rather than thrown on: + * the transaction has already been broadcast, and failing a submitted transaction would repeat the + * mistake of reporting a side effect that happened as if it had not. + */ +export function authoritativeTxId( + local: string | undefined, + reported: string | undefined, + warn: (message: string) => void, +): string { + const nodeId = reported ?? ""; + if (!local) return nodeId; + // Compared case-insensitively: hex case carries no meaning, and comparing the representation + // rather than the value is what made a valid keystore look like a wrong password (CR-033). + if (nodeId && nodeId.toLowerCase() !== local.toLowerCase()) { + warn( + `the node reported transaction id ${nodeId} for a transaction whose id is ${local}; ` + + "using the id derived from the signed transaction. The node is misreporting or faulty", + ); + } + return local; +} + +/** best-effort transaction id of a signed tx, derived from its own bytes (TRON: txID). */ +export function localTxId(signed: unknown): string | undefined { + const s = signed as { txID?: unknown; hash?: unknown } | null; + const id = s?.txID ?? s?.hash; + return typeof id === "string" && id !== "" ? id : undefined; +} diff --git a/ts/src/application/services/pipeline/index.ts b/ts/src/application/services/pipeline/index.ts index c0c2ca4e0..7db113155 100644 --- a/ts/src/application/services/pipeline/index.ts +++ b/ts/src/application/services/pipeline/index.ts @@ -9,6 +9,7 @@ import type { TransactionScope } from "../../contracts/execution-scope.js"; import { SignerResolver } from "../signer/index.js"; import { UsageError } from "../../../domain/errors/index.js"; import { obtainSignature } from "../signing/obtain-signature.js"; +import { authoritativeTxId, localTxId as txIdOf } from "../broadcast-identity.js"; import type { Broadcaster } from "../../ports/chain/broadcaster.js"; import type { TransactionExecutionMode } from "../transaction-mode.js"; @@ -124,7 +125,7 @@ export class TxPipeline { // the same transaction handed to `tx broadcast`. authorization?.assertBroadcastable(); const result = await p.broadcaster.broadcast(signed); - const txId = String(result.txId ?? result.hash ?? ""); + const txId = authoritativeTxId(txIdOf(signed), String(result.txId ?? result.hash ?? ""), (m) => p.ctx.warn(m)); // default (no --wait): non-blocking, return the submitted txid only (fee/energy unknown yet). if (!p.ctx.wait || !p.confirm || !txId) { // --wait asked but we can't even attempt confirmation (no confirm hook or no txid): the @@ -132,7 +133,7 @@ export class TxPipeline { if (p.ctx.wait && !txId) { p.ctx.warn("--wait requested but the broadcast returned no txid; returning submitted (unconfirmed)"); } - return { stage: "submitted", ...result }; + return { stage: "submitted", ...result, ...(txId ? { txId } : {}) }; } // --wait: poll until the tx mines so the receipt carries real fee/energy/result. // Best-effort — a confirmation failure/timeout never fails an already-broadcast tx; we just @@ -146,15 +147,10 @@ export class TxPipeline { if (!confirmed) { // The user asked to wait; a silent "submitted" reads like confirmation was never attempted. p.ctx.warn(`--wait: ${txId} not confirmed within ${p.ctx.waitTimeoutMs}ms; returning submitted (it may still confirm on-chain)`); - return { stage: "submitted", ...result }; + return { stage: "submitted", ...result, txId }; } - return { stage: confirmed.failed ? "failed" : "confirmed", ...result, ...confirmed }; + return { stage: confirmed.failed ? "failed" : "confirmed", ...result, txId, ...confirmed }; } } -/** best-effort transaction id of a signed tx, for the sign-only receipt (TRON: txID). */ -function txIdOf(signed: SignedTx): string | undefined { - const s = signed as { txID?: unknown; hash?: unknown } | null; - const id = s?.txID ?? s?.hash; - return typeof id === "string" ? id : undefined; -} + diff --git a/ts/src/application/services/pipeline/pipeline.test.ts b/ts/src/application/services/pipeline/pipeline.test.ts index 154a28fe0..e8de17c52 100644 --- a/ts/src/application/services/pipeline/pipeline.test.ts +++ b/ts/src/application/services/pipeline/pipeline.test.ts @@ -197,3 +197,43 @@ describe("TxPipeline permission/expiration binding guard", () => { .rejects.toMatchObject({ code: "invalid_option" }); }); }); + +/** + * The pipeline's own broadcast path settles the same question as stageTronBroadcast: the id we + * derived from the signed bytes outranks the one the node echoes back, and a disagreement is + * surfaced rather than swallowed. Kept here as well as at the adapter because every family's + * writes come through this method, so the rule travels with the Broadcaster port. + */ +describe("TxPipeline reports the transaction id derived from the signed bytes", () => { + const LOCAL = "defbf1e676a7b53c03a30ec3e17e455175231dcf6165ae2a762d2d973f81dbc9"; + const OTHER = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + + const broadcastWith = async (nodeTxId: string) => { + const warnings: string[] = []; + const signer: Signer = { + kind: "software", address: "TSender", + sign: async () => ({ txID: LOCAL }) as never, + signMessage: async () => "", signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + const signers = { assertCanSign: vi.fn(), resolve: () => signer } as unknown as SignerResolver; + const outcome = await new TxPipeline(signers).run(params(signer, { + ctx: scope({ warn: (m: string) => warnings.push(String(m)) }), + broadcast: true, + mode: "broadcast", + broadcaster: { broadcast: async () => ({ txId: nodeTxId }) } as never, + })); + return { outcome, warnings }; + }; + + it("prefers the derived id and warns when the node disagrees", async () => { + const { outcome, warnings } = await broadcastWith(OTHER); + expect(outcome).toMatchObject({ stage: "submitted", txId: LOCAL }); + expect(warnings.join(" ")).toContain(OTHER); + }); + + it("says nothing when the node agrees", async () => { + const { outcome, warnings } = await broadcastWith(LOCAL); + expect(outcome).toMatchObject({ txId: LOCAL }); + expect(warnings).toEqual([]); + }); +}); diff --git a/ts/src/application/services/tron-confirmation.test.ts b/ts/src/application/services/tron-confirmation.test.ts index d97555e67..5c81b8387 100644 --- a/ts/src/application/services/tron-confirmation.test.ts +++ b/ts/src/application/services/tron-confirmation.test.ts @@ -61,3 +61,55 @@ describe("stageTronBroadcast (issue #7 — --wait fallback is not silent)", () = expect(s.warnings).toEqual([]); }); }); + +/** + * A TRON txID is the sha256 of the transaction body, so it is not a number the node assigns — it is + * derivable from the bytes we signed, and we derive it. The broadcast reply carries the node's own + * copy, and taking that one on trust means a node whose copy is wrong decides which transaction we + * then poll for and report. Confirmed against a live Nile node: its txID matches ours exactly, so + * this costs nothing when the node is honest and only bites when it is not. + * + * Preferring our own is the protection; the warning is disclosure. Getting only the warning right + * would still leave --wait polling the wrong id and the receipt quoting it. + */ +describe("stageTronBroadcast reports the transaction id we signed", () => { + const LOCAL = "defbf1e676a7b53c03a30ec3e17e455175231dcf6165ae2a762d2d973f81dbc9"; + const OTHER = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + + it("uses the locally derived id when the node reports a different one, and says so", async () => { + const s = scope({ wait: false }); + const staged = await stageTronBroadcast(gateway(undefined), s, { txId: OTHER }, LOCAL); + + expect(staged).toMatchObject({ stage: "submitted", txId: LOCAL }); + expect(s.warnings.join(" ")).toContain(OTHER); + }); + + it("stays silent when the node agrees, whatever case it uses", async () => { + const s = scope({ wait: false }); + const staged = await stageTronBroadcast(gateway(undefined), s, { txId: LOCAL.toUpperCase() }, LOCAL); + + expect(staged).toMatchObject({ txId: LOCAL }); + expect(s.warnings).toEqual([]); + }); + + it("confirms against the locally derived id, not the one the node offered", async () => { + const polled: string[] = []; + const g = { + getTransactionInfoById: async (id: string) => { + polled.push(id); + return { blockNumber: 9 } as TronTxInfo; + }, + } as unknown as TronGateway; + + await stageTronBroadcast(g, scope(), { txId: OTHER }, LOCAL); + + expect(polled).toEqual([LOCAL]); + }); + + it("falls back to the node's id when the signed transaction carries none", async () => { + const s = scope({ wait: false }); + expect(await stageTronBroadcast(gateway(undefined), s, { txId: OTHER }, undefined)) + .toMatchObject({ txId: OTHER }); + expect(s.warnings).toEqual([]); + }); +}); diff --git a/ts/src/application/services/tron-confirmation.ts b/ts/src/application/services/tron-confirmation.ts index f5a7c8870..c18be5cb5 100644 --- a/ts/src/application/services/tron-confirmation.ts +++ b/ts/src/application/services/tron-confirmation.ts @@ -1,6 +1,7 @@ import type { TxOutcome } from "../../domain/types/index.js"; import type { TransactionScope } from "../contracts/execution-scope.js"; import type { TronGateway, TronTxInfo } from "../ports/chain/tron-gateway.js"; +import { authoritativeTxId } from "./broadcast-identity.js"; const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); @@ -57,18 +58,19 @@ export async function stageTronBroadcast( gateway: TronGateway, scope: TransactionScope, result: Record, + local?: string, ): Promise { - const txId = String(result.txId ?? result.hash ?? ""); + const txId = authoritativeTxId(local, String(result.txId ?? result.hash ?? ""), (m) => scope.warn(m)); if (!scope.wait || !txId) { if (scope.wait && !txId) { scope.warn("--wait requested but the broadcast returned no txid; returning submitted (unconfirmed)"); } - return { stage: "submitted", ...result }; + return { stage: "submitted", ...result, ...(txId ? { txId } : {}) }; } const confirmed = await tronConfirmation(gateway, scope)(txId).catch(() => undefined); if (!confirmed) { scope.warn(`--wait: ${txId} not confirmed within ${scope.waitTimeoutMs}ms; returning submitted (it may still confirm on-chain)`); - return { stage: "submitted", ...result }; + return { stage: "submitted", ...result, txId }; } - return { stage: confirmed.failed ? "failed" : "confirmed", ...result, ...confirmed }; + return { stage: confirmed.failed ? "failed" : "confirmed", ...result, txId, ...confirmed }; } diff --git a/ts/src/application/use-cases/tron/multisig-service.ts b/ts/src/application/use-cases/tron/multisig-service.ts index 23ea788de..32993e5f1 100644 --- a/ts/src/application/use-cases/tron/multisig-service.ts +++ b/ts/src/application/use-cases/tron/multisig-service.ts @@ -10,6 +10,7 @@ import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { TronGateway } from "../../ports/chain/tron-gateway.js"; import { stageTronBroadcast } from "../../services/tron-confirmation.js"; +import { localTxId } from "../../services/broadcast-identity.js"; import type { TronSigService } from "./sig-service.js"; import { assertThresholdReached, @@ -69,7 +70,7 @@ export class TronMultisigService { const result = await gateway.broadcastHex(hex); return { kind: "broadcast" as const, - ...(await stageTronBroadcast(gateway, scope, result)), + ...(await stageTronBroadcast(gateway, scope, result, localTxId(transaction))), transaction: approval, multiSignFeeSun, }; diff --git a/ts/src/application/use-cases/tron/proposal-service.test.ts b/ts/src/application/use-cases/tron/proposal-service.test.ts index c224ae4ba..48dba2966 100644 --- a/ts/src/application/use-cases/tron/proposal-service.test.ts +++ b/ts/src/application/use-cases/tron/proposal-service.test.ts @@ -113,3 +113,75 @@ describe("TronProposalService", () => { .rejects.toMatchObject({ code: "not_proposal_owner" }); }); }); + +/** + * The chain does not put the new proposal's id in the receipt, so `--wait` has to find it by looking + * at the list afterwards. Matching on proposer + parameter set is not enough to identify it: on + * mainnet, 10 of 106 proposals (9.4%) share a proposer and an identical parameter set with another, + * because a rejected proposal gets re-submitted unchanged. `findCreatedProposal` also never filtered + * by state, so every historical one stayed a candidate, and it resolved ties by taking the highest + * id — right whenever the list is fresh, and wrong in the case that needs no hostile node at all: + * + * confirmation reads the fullnode's unsolidified data (~3s), the proposal list lags behind it, and + * the caller had proposed these same parameters before. The new proposal is not listed yet, the + * old one is, so the "highest match" IS the old one — and its id is handed back as the new + * proposal's, to be passed to `proposal approve` or the irreversible `proposal delete`. + * + * Comparing against a snapshot taken before submitting is what separates "already there" from "just + * appeared". Anything other than exactly one new match is reported as unknown rather than guessed. + */ +describe("proposal create --wait identifies the proposal it created, or admits it cannot", () => { + const PARAMS = { "3": "15" }; + const proposal = (id: number, over: Partial = {}): TronProposal => ({ + id, proposerAddress: OWNER, parameters: PARAMS, + expirationTime: Date.now() + 60_000, createTime: Date.now(), approvals: [], state: "PENDING", + ...over, + } as TronProposal); + + function harness(listings: TronProposal[][]) { + const calls: number[] = []; + const warnings: string[] = []; + const waiting = { ...scope, wait: true, warn: (m: string) => warnings.push(String(m)) } as typeof scope; + const { service } = createService( + { + getChainParameters: async () => [{ key: "getTransactionFee", value: 10 }], + getWitness: async () => ({ address: OWNER, voteCount: "1" }), + buildProposalCreate: async () => ({ raw_data: { contract: [{ type: "ProposalCreateContract" }] } }), + getProposals: async () => listings[Math.min(calls.push(1) - 1, listings.length - 1)]!, + }, + async () => ({ stage: "confirmed", txId: "tx", confirmed: true } as never), + ); + return { service, waiting, warnings, listCalls: () => calls.length }; + } + + it("returns the proposal that appeared, not the highest id that matches", async () => { + // 41 already exists with identical parameters; 42 is the one just created. + const h = harness([[proposal(41)], [proposal(41), proposal(42)]]); + const out = await h.service.create(h.waiting, NET, { set: ["getTransactionFee=15"] } as never); + expect(out).toMatchObject({ proposalId: 42 }); + }); + + it("admits it cannot tell when the list has not caught up, instead of naming the old one", async () => { + // The realistic failure: nothing new is listed yet, but a prior identical proposal is. + const h = harness([[proposal(41)], [proposal(41)]]); + const out = await h.service.create(h.waiting, NET, { set: ["getTransactionFee=15"] } as never) as { proposalId?: number }; + + expect(out.proposalId).toBeUndefined(); + expect(h.warnings.join(" ")).toMatch(/could not|unable|not identify/i); + }); + + it("admits it cannot tell when two identical proposals appeared at once", async () => { + const h = harness([[proposal(41)], [proposal(41), proposal(42), proposal(43)]]); + const out = await h.service.create(h.waiting, NET, { set: ["getTransactionFee=15"] } as never) as { proposalId?: number }; + + expect(out.proposalId).toBeUndefined(); + expect(h.warnings.join(" ")).toBeTruthy(); + }); + + it("does not pay for a snapshot when no confirmation was asked for", async () => { + const h = harness([[proposal(41)]]); + const notWaiting = { ...h.waiting, wait: false } as typeof scope; + await h.service.create(notWaiting, NET, { set: ["getTransactionFee=15"] } as never); + expect(h.listCalls()).toBe(0); + }); +}); diff --git a/ts/src/application/use-cases/tron/proposal-service.ts b/ts/src/application/use-cases/tron/proposal-service.ts index 5817c26ff..57b9fa305 100644 --- a/ts/src/application/use-cases/tron/proposal-service.ts +++ b/ts/src/application/use-cases/tron/proposal-service.ts @@ -84,9 +84,14 @@ export class TronProposalService { const gateway = this.gateways.get(network, "tron"); const mode = governanceTransactionMode(this.pipeline, scope, input); const owner = scope.resolveAddress("tron"); - const [parameters] = await Promise.all([ + // Taken BEFORE submitting, and only when a confirmation is actually going to be awaited: it is + // the only way to tell a proposal that just appeared from one that was already there. A failure + // to read it must not block the write, so it degrades to "no snapshot" and the id is omitted. + const wantsId = scope.wait && mode.mode === "broadcast"; + const [parameters, , before] = await Promise.all([ gateway.getChainParameters(), assertWitness(gateway, owner), + wantsId ? proposalIdSnapshot(gateway).catch(() => undefined) : Promise.resolve(undefined), ]); const changes = parseChainParameterAssignments(input.set, parameters); const outcome = await this.pipeline.run({ @@ -105,8 +110,8 @@ export class TronProposalService { estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "proposal creation uses bandwidth only" }), }); const data = outcomeData(outcome); - const proposalId = outcome.stage === "confirmed" - ? await findCreatedProposal(gateway, owner, changes).catch(() => undefined) + const proposalId = outcome.stage === "confirmed" && before !== undefined + ? await findCreatedProposal(gateway, owner, changes, before, scope).catch(() => undefined) : undefined; return { kind: "proposal-create" as const, @@ -243,16 +248,43 @@ function listView(proposal: TronProposal) { }; } +async function proposalIdSnapshot(gateway: TronGateway): Promise> { + return new Set((await gateway.getProposals()).map((proposal) => proposal.id)); +} + +/** + * The id of the proposal this call created, or nothing. + * + * The chain does not report it, so it has to be recognised in the list afterwards — and proposer + * plus parameter set does not identify it: on mainnet 10 of 106 proposals share both with another, + * because a rejected proposal gets re-submitted unchanged. `before` is what makes the difference + * decidable; without it the best available answer was "the highest id that matches", which is the + * OLD proposal whenever the list has not caught up with the confirmation yet. + * + * Anything other than exactly one new match is reported as unknown. A guessed id is worse than no + * id: it is handed straight to `proposal approve` and the irreversible `proposal delete`. + */ async function findCreatedProposal( gateway: TronGateway, owner: string, changes: ChainParameterChange[], + before: Set, + scope: TransactionScope, ): Promise { const expected = new Map(changes.map((change) => [String(change.id), String(change.proposedValue)])); const matches = (await gateway.getProposals()).filter((proposal) => + !before.has(proposal.id) && proposal.proposerAddress === owner && expected.size === Object.keys(proposal.parameters).length && [...expected].every(([id, value]) => proposal.parameters[id] === value), ); - return matches.sort((left, right) => right.id - left.id)[0]?.id; + if (matches.length === 1) return matches[0]!.id; + scope.warn( + matches.length === 0 + ? "could not identify the created proposal yet: the node's proposal list has not caught up with the confirmation. " + + "Find it with `proposal list` — the transaction itself succeeded" + : `could not identify the created proposal: ${matches.length} new proposals match these parameters. ` + + "Find it with `proposal list` before approving or deleting anything", + ); + return undefined; } diff --git a/ts/src/application/use-cases/tron/transaction-service.ts b/ts/src/application/use-cases/tron/transaction-service.ts index 4b14f0f71..b940b767b 100644 --- a/ts/src/application/use-cases/tron/transaction-service.ts +++ b/ts/src/application/use-cases/tron/transaction-service.ts @@ -13,6 +13,7 @@ import { type TransactionModeInput, } from "../../services/transaction-mode.js"; import { stageTronBroadcast, tronConfirmation } from "../../services/tron-confirmation.js"; +import { localTxId } from "../../services/broadcast-identity.js"; import { tronTransactionHooks } from "./multisig-authorization.js"; import type { RecipientResolver } from "../../services/recipient-resolver.js"; @@ -101,7 +102,7 @@ export class TronTransactionService { const result = await gateway.broadcast(signed); return { kind: "broadcast" as const, - ...(await stageTronBroadcast(gateway, scope, result)), + ...(await stageTronBroadcast(gateway, scope, result, localTxId(signed))), }; } diff --git a/ts/src/domain/keystore/index.ts b/ts/src/domain/keystore/index.ts index 0d6f88448..b2ca7382c 100644 --- a/ts/src/domain/keystore/index.ts +++ b/ts/src/domain/keystore/index.ts @@ -106,7 +106,12 @@ export const KeystoreV3 = { 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) { + // Through hexField like every other hex field, then compared as BYTES: `A1B2` and `a1b2` are the + // same MAC, and comparing our lowercase rendering against the file's own spelling reported a + // valid keystore as a wrong password. It also keeps a missing or non-hex mac reported as the + // malformed file it is, rather than as a password the reader would then go and "fix". + const mac = hexField(c.mac, "crypto.mac"); + if (!equalBytes(Web3Crypto.mac(dk, ciphertext), mac)) { throw new ExecutionError("wrong_keystore_password", "incorrect keystore file password"); } const plaintext = Web3Crypto.crypt(dk, iv, ciphertext); @@ -152,6 +157,12 @@ function asRecord(value: unknown, why: string): Record { return value as Record; } +/** Same bytes, same MAC — no timing claim: whoever can time this already holds the file and can + * try passwords offline, so the comparison is plain. */ +function equalBytes(a: Bytes, b: Bytes): boolean { + return a.length === b.length && a.every((byte, i) => byte === b[i]); +} + function hexField(value: unknown, field: string): Bytes { if (typeof value !== "string" || !/^[0-9a-fA-F]*$/.test(value) || value.length % 2 !== 0) { throw invalid(`${field} is not a hex string`); diff --git a/ts/src/domain/keystore/keystore-v3.test.ts b/ts/src/domain/keystore/keystore-v3.test.ts index d68b9d2cb..dee3098c8 100644 --- a/ts/src/domain/keystore/keystore-v3.test.ts +++ b/ts/src/domain/keystore/keystore-v3.test.ts @@ -193,3 +193,44 @@ describe("V3 import rejects a derived key too short to authenticate the password expect(KeystoreV3.decrypt(lightV3(), PW)).toEqual(KEY); }); }); + +/** + * The MAC is a hex string, and `A1B2` and `a1b2` are the same bytes. It was the one hex field that + * skipped `hexField` and was compared as a STRING against our lowercase rendering, so a file written + * with uppercase hex — legal, and what `Arrays.equals` in the Java implementation accepts without + * noticing — came back as `wrong_keystore_password`. Two things wrong with that: a valid file is + * refused, and the refusal sends the reader to fix a password that was never wrong. + * + * The same misreport covered a malformed file: a missing or non-string `mac` compared unequal and + * was also reported as a bad password, which the codec's own contract says it should not be + * ("a malformed file is reported as such instead of as a wrong password"). + */ +describe("V3 import compares the MAC by value, not by how it was written", () => { + const withMac = (mac: unknown) => { + const file = lightV3() as unknown as { crypto: Record }; + file.crypto.mac = mac; + return file; + }; + const macOf = () => (lightV3() as unknown as { crypto: { mac: string } }).crypto.mac; + + it.each([ + ["uppercase", (m: string) => m.toUpperCase()], + ["mixed case", (m: string) => m.slice(0, 8).toUpperCase() + m.slice(8)], + ])("accepts a correct MAC written in %s", (_label, rewrite) => { + expect(KeystoreV3.decrypt(withMac(rewrite(macOf())), PW)).toEqual(KEY); + }); + + it("still rejects a wrong password as a wrong password", () => { + expect(() => KeystoreV3.decrypt(lightV3(), "not-the-password")) + .toThrowError(/incorrect keystore file password/); + }); + + it.each([ + ["missing", undefined], + ["not a string", 123], + ["not hex", "zzzz"], + ["an odd number of digits", "abc"], + ])("reports a MAC that is %s as a malformed file, not a bad password", (_label, mac) => { + expect(() => KeystoreV3.decrypt(withMac(mac), PW)).toThrowError(/not a valid V3 keystore/); + }); +}); From 85bb42ca325e4a7adbb195525f02c21a3743dca2 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 17 Aug 2026 15:02:04 +0800 Subject: [PATCH 15/15] chore(ts): adopt Prettier and ESLint, aligned to the existing style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TypeScript package had no formatter or linter. Add both, configured to match how the code was already written rather than to impose new defaults. Prettier carries a single option, printWidth 100 — every other default (double quotes, semicolons, 2-space indent, trailing commas, arrow parens) already matched the tree. Measured across five widths, 100 was the best fit for code that reads as ~100 columns; the reformat itself is unavoidable, since Prettier cannot preserve the leading-operator continuation lines and packed property lines used in places. Markdown is ignored: the docs are hand-authored and their tables and wrapping are deliberate. ESLint runs flat config with typescript-eslint recommended, untyped, plus eslint-config-prettier last so the two never disagree on formatting. Three rules are calibrated to this codebase: - no-console as an error, locking in an invariant already held — the CLI renders through the streams port and has zero console calls. - no-control-regex off. Every hit is a security control: the renderers match C0/C1 bytes on purpose to strip terminal escape-sequence injection out of chain-controlled text. - no-explicit-any off. Those casts sit at the adapter boundary where TronWeb, Ledger and yargs ship no usable types, and tsc is already the type authority here. The 21 remaining errors were genuine dead code and are fixed: unused imports and locals, a dead `pw = ""` initializer, `let passphraseSet = false` narrowed to a bare declaration, and one comma-operator expression expanded to a braced block. All are behaviour-free. ChainSpec's unused type parameter is renamed to `_I` rather than removed, to avoid rippling into ChainCommandDefinition. Verified: eslint clean, prettier --check clean, tsc --noEmit clean, depcruise clean (377 modules), vitest 1231 passed / 2 skipped, build ok. --- ts/.dependency-cruiser.cjs | 3 +- ts/.prettierignore | 8 + ts/.prettierrc.json | 3 + ts/eslint.config.js | 43 + ts/package-lock.json | 1184 ++++++++++++++++- ts/package.json | 9 + .../adapters/inbound/cli/arity/arity.test.ts | 5 +- ts/src/adapters/inbound/cli/arity/index.ts | 19 +- .../adapters/inbound/cli/commands/account.ts | 52 +- .../inbound/cli/commands/address.test.ts | 32 +- .../adapters/inbound/cli/commands/address.ts | 22 +- .../inbound/cli/commands/artifact.test.ts | 39 +- .../adapters/inbound/cli/commands/artifact.ts | 7 +- ts/src/adapters/inbound/cli/commands/asset.ts | 102 +- ts/src/adapters/inbound/cli/commands/block.ts | 10 +- ts/src/adapters/inbound/cli/commands/chain.ts | 30 +- .../cli/commands/change-password.test.ts | 13 +- .../adapters/inbound/cli/commands/config.ts | 4 +- .../adapters/inbound/cli/commands/contact.ts | 30 +- .../cli/commands/contract.deploy.test.ts | 25 +- .../adapters/inbound/cli/commands/contract.ts | 149 ++- .../adapters/inbound/cli/commands/encoding.ts | 15 +- .../adapters/inbound/cli/commands/exchange.ts | 94 +- .../adapters/inbound/cli/commands/gasfree.ts | 63 +- .../inbound/cli/commands/message.sign.test.ts | 7 +- .../adapters/inbound/cli/commands/network.ts | 14 +- .../inbound/cli/commands/permission.ts | 43 +- .../adapters/inbound/cli/commands/proposal.ts | 19 +- .../adapters/inbound/cli/commands/reward.ts | 13 +- .../adapters/inbound/cli/commands/shared.ts | 74 +- ts/src/adapters/inbound/cli/commands/stake.ts | 80 +- .../cli/commands/text-formatters.test.ts | 412 ++++-- .../inbound/cli/commands/token-selector.ts | 5 +- ts/src/adapters/inbound/cli/commands/token.ts | 28 +- .../cli/commands/transaction-options.test.ts | 22 +- .../inbound/cli/commands/tx.sign.test.ts | 177 ++- ts/src/adapters/inbound/cli/commands/tx.ts | 193 ++- .../inbound/cli/commands/typed-data.test.ts | 10 +- .../inbound/cli/commands/typed-data.ts | 12 +- ts/src/adapters/inbound/cli/commands/vote.ts | 31 +- .../cli/commands/wallet.backup.test.ts | 30 +- .../cli/commands/wallet.current.test.ts | 42 +- .../cli/commands/wallet.keystore.test.ts | 113 +- .../inbound/cli/commands/wallet.test.ts | 81 +- .../adapters/inbound/cli/commands/wallet.ts | 453 ++++--- .../adapters/inbound/cli/commands/witness.ts | 12 +- .../inbound/cli/context/context.test.ts | 7 +- ts/src/adapters/inbound/cli/context/index.ts | 23 +- .../adapters/inbound/cli/contracts/command.ts | 7 +- .../cli/contracts/command.types.test.ts | 20 +- ts/src/adapters/inbound/cli/globals/index.ts | 102 +- ts/src/adapters/inbound/cli/help/catalog.ts | 84 +- ts/src/adapters/inbound/cli/help/help.test.ts | 445 ++++--- ts/src/adapters/inbound/cli/help/index.ts | 446 ++++--- .../cli/help/root-help-coverage.test.ts | 17 +- .../inbound/cli/input/prompt/index.ts | 90 +- .../inbound/cli/input/prompt/prompter.test.ts | 48 +- .../cli/input/prompt/validators.test.ts | 17 +- .../inbound/cli/input/prompt/validators.ts | 3 +- .../inbound/cli/input/secret/index.ts | 51 +- .../inbound/cli/input/secret/secret.test.ts | 50 +- .../inbound/cli/output/envelope.test.ts | 4 +- .../adapters/inbound/cli/output/envelope.ts | 2 +- ts/src/adapters/inbound/cli/output/index.ts | 55 +- .../inbound/cli/output/output.test.ts | 126 +- ts/src/adapters/inbound/cli/registry/index.ts | 3 +- .../cli/registry/registry.chain.test.ts | 9 +- ts/src/adapters/inbound/cli/render/account.ts | 202 +-- .../adapters/inbound/cli/render/approval.ts | 31 +- ts/src/adapters/inbound/cli/render/asset.ts | 48 +- ts/src/adapters/inbound/cli/render/chain.ts | 74 +- ts/src/adapters/inbound/cli/render/contact.ts | 34 +- .../adapters/inbound/cli/render/encoding.ts | 21 +- .../inbound/cli/render/error-details.test.ts | 7 +- .../adapters/inbound/cli/render/exchange.ts | 55 +- .../inbound/cli/render/family-render.test.ts | 7 +- ts/src/adapters/inbound/cli/render/family.ts | 27 +- ts/src/adapters/inbound/cli/render/gasfree.ts | 94 +- .../inbound/cli/render/governance.test.ts | 90 +- .../adapters/inbound/cli/render/governance.ts | 125 +- ts/src/adapters/inbound/cli/render/index.ts | 66 +- ts/src/adapters/inbound/cli/render/layout.ts | 25 +- ts/src/adapters/inbound/cli/render/misc.ts | 91 +- .../inbound/cli/render/multisig.test.ts | 13 +- .../adapters/inbound/cli/render/multisig.ts | 34 +- .../adapters/inbound/cli/render/permission.ts | 34 +- ts/src/adapters/inbound/cli/render/reward.ts | 20 +- ts/src/adapters/inbound/cli/render/scalars.ts | 13 +- ts/src/adapters/inbound/cli/render/stake.ts | 111 +- ts/src/adapters/inbound/cli/render/tx.ts | 528 ++++---- ts/src/adapters/inbound/cli/render/vote.ts | 73 +- ts/src/adapters/inbound/cli/render/wallet.ts | 173 +-- ts/src/adapters/inbound/cli/schemas/index.ts | 8 +- ts/src/adapters/inbound/cli/shell/index.ts | 480 ++++--- .../cli/shell/positional-contract.test.ts | 78 +- .../inbound/cli/shell/shell.chain.test.ts | 79 +- .../adapters/inbound/cli/shell/shell.test.ts | 208 ++- ts/src/adapters/inbound/cli/stream/index.ts | 3 +- .../inbound/cli/stream/stream.test.ts | 7 +- .../chain/tron/account-builders.test.ts | 12 +- .../chain/tron/asset-contract-codec.test.ts | 90 +- .../chain/tron/asset-contract-codec.ts | 62 +- .../chain/tron/asset-response.test.ts | 15 +- .../chain/tron/contract-response.test.ts | 21 +- .../outbound/chain/tron/contract-response.ts | 5 +- .../chain/tron/history-reader.timeout.test.ts | 14 +- .../outbound/chain/tron/history-reader.ts | 17 +- .../outbound/chain/tron/node-errors.test.ts | 32 +- .../outbound/chain/tron/node-errors.ts | 9 +- .../chain/tron/proposal-protobuf.test.ts | 54 +- .../outbound/chain/tron/proposal-protobuf.ts | 47 +- .../outbound/chain/tron/provider.test.ts | 18 +- .../adapters/outbound/chain/tron/provider.ts | 3 +- .../chain/tron/signing-strategy.test.ts | 124 +- .../outbound/chain/tron/signing-strategy.ts | 33 +- .../transaction-codec.deploy-address.test.ts | 31 +- .../tron/transaction-codec.governance.test.ts | 24 +- .../chain/tron/transaction-codec.test.ts | 218 ++- .../outbound/chain/tron/transaction-codec.ts | 75 +- .../chain/tron/transaction-decoder.test.ts | 38 +- .../chain/tron/transaction-decoder.ts | 22 +- .../chain/tron/tron-responses.test.ts | 4 +- .../outbound/chain/tron/tron-responses.ts | 5 +- .../chain/tron/tron.asset-reads.test.ts | 87 +- .../chain/tron/tron.block-lossless.test.ts | 26 +- .../chain/tron/tron.broadcast-guard.test.ts | 32 +- .../chain/tron/tron.governance-build.test.ts | 22 +- .../chain/tron/tron.governance.test.ts | 18 +- .../chain/tron/tron.permissions.test.ts | 202 +-- .../chain/tron/tron.proposals.test.ts | 90 +- .../outbound/chain/tron/tron.redact.test.ts | 6 +- .../outbound/chain/tron/tron.timeout.test.ts | 14 +- .../chain/tron/tron.token-info.test.ts | 25 +- ts/src/adapters/outbound/chain/tron/tron.ts | 645 ++++++--- .../chain/tron/tron.tx-info-lossless.test.ts | 11 +- .../outbound/chain/tron/tx-guard.test.ts | 4 +- .../adapters/outbound/chain/tron/tx-guard.ts | 5 +- .../outbound/chain/tron/tx-integrity.ts | 121 +- ts/src/adapters/outbound/config/builtins.ts | 8 +- .../adapters/outbound/config/config.test.ts | 39 +- ts/src/adapters/outbound/config/index.ts | 32 +- .../outbound/config/yaml-config-document.ts | 3 +- .../outbound/contactbook/contactbook.test.ts | 38 +- ts/src/adapters/outbound/contactbook/index.ts | 115 +- .../adapters/outbound/gasfree/client.test.ts | 78 +- ts/src/adapters/outbound/gasfree/client.ts | 213 +-- ts/src/adapters/outbound/keystore/index.ts | 149 ++- .../outbound/keystore/keystore.test.ts | 52 +- ts/src/adapters/outbound/ledger/index.test.ts | 81 +- ts/src/adapters/outbound/ledger/index.ts | 94 +- .../persistence/backup-records.test.ts | 21 +- .../persistence/backup-writer.test.ts | 15 +- .../outbound/persistence/crypto/index.ts | 7 +- .../outbound/persistence/fs/fs.test.ts | 49 +- .../adapters/outbound/persistence/fs/index.ts | 94 +- .../persistence/keypair-writer.test.ts | 25 +- .../outbound/persistence/keypair-writer.ts | 21 +- .../transaction-artifact-writer.test.ts | 4 +- .../transaction-artifact-writer.ts | 10 +- .../adapters/outbound/price/coingecko.test.ts | 56 +- ts/src/adapters/outbound/price/coingecko.ts | 8 +- ts/src/adapters/outbound/qr/index.test.ts | 26 +- ts/src/adapters/outbound/qr/index.ts | 38 +- .../adapters/outbound/tokenbook/builtins.ts | 40 +- ts/src/adapters/outbound/tokenbook/index.ts | 6 +- .../outbound/tokenbook/tokenbook.test.ts | 36 +- .../adapters/outbound/tronlink/auth.test.ts | 3 +- ts/src/adapters/outbound/tronlink/auth.ts | 5 +- .../adapters/outbound/tronlink/client.test.ts | 188 ++- ts/src/adapters/outbound/tronlink/client.ts | 74 +- .../application/ports/chain/tron-gateway.ts | 41 +- .../ports/chain/tron-history-reader.ts | 1 - .../ports/config-document-repository.ts | 1 - ts/src/application/ports/ledger-device.ts | 23 +- ts/src/application/ports/price-provider.ts | 1 - ts/src/application/ports/token-repository.ts | 8 +- ts/src/application/ports/wallet-repository.ts | 11 +- .../services/broadcast-identity.ts | 4 +- .../application/services/capability/index.ts | 12 +- .../ledger-account-interactive.test.ts | 16 +- .../services/ledger-account.test.ts | 9 +- ts/src/application/services/ledger-account.ts | 3 +- ts/src/application/services/pipeline/index.ts | 58 +- .../services/pipeline/pipeline.test.ts | 142 +- .../services/pipeline/sign-only.test.ts | 4 +- ts/src/application/services/post-check.ts | 6 +- .../services/recipient-resolver.test.ts | 25 +- .../services/recipient-resolver.ts | 15 +- ts/src/application/services/signer/index.ts | 37 +- .../services/signer/ledger.test.ts | 15 +- ts/src/application/services/signer/ledger.ts | 14 +- .../services/signer/resolver.test.ts | 18 +- .../application/services/signer/software.ts | 16 +- .../services/signing/obtain-signature.test.ts | 17 +- ts/src/application/services/target/index.ts | 11 +- .../services/target/target.test.ts | 36 +- .../services/transaction-mode.test.ts | 16 +- .../application/services/transaction-mode.ts | 35 +- .../services/tron-confirmation.test.ts | 16 +- .../application/services/tron-confirmation.ts | 20 +- .../use-cases/address-service.test.ts | 13 +- .../application/use-cases/address-service.ts | 21 +- .../use-cases/config-service.test.ts | 74 +- .../application/use-cases/config-service.ts | 14 +- .../application/use-cases/contact-service.ts | 19 +- .../use-cases/tron/account-service.test.ts | 237 +++- .../use-cases/tron/account-service.ts | 116 +- .../use-cases/tron/asset-service.test.ts | 184 ++- .../use-cases/tron/asset-service.ts | 108 +- .../use-cases/tron/chain-service.test.ts | 64 +- .../use-cases/tron/chain-service.ts | 20 +- .../tron/contract-service.deploy.test.ts | 4 +- .../tron/contract-service.fee-limit.test.ts | 4 +- .../tron/contract-service.governance.test.ts | 115 +- .../use-cases/tron/contract-service.ts | 70 +- .../use-cases/tron/exchange-service.test.ts | 116 +- .../use-cases/tron/exchange-service.ts | 100 +- .../use-cases/tron/gasfree-service.test.ts | 111 +- .../use-cases/tron/gasfree-service.ts | 253 ++-- .../tron/governance-artifact.test.ts | 76 +- .../tron/governance-transaction-mode.test.ts | 164 ++- .../use-cases/tron/governance-transaction.ts | 20 +- .../tron/multisig-authorization.test.ts | 55 +- .../use-cases/tron/multisig-authorization.ts | 46 +- .../multisig-collaboration-service.test.ts | 133 +- .../tron/multisig-collaboration-service.ts | 117 +- .../use-cases/tron/multisig-service.test.ts | 54 +- .../use-cases/tron/multisig-service.ts | 30 +- .../use-cases/tron/permission-service.test.ts | 117 +- .../use-cases/tron/permission-service.ts | 11 +- .../use-cases/tron/proposal-service.test.ts | 144 +- .../use-cases/tron/proposal-service.ts | 91 +- .../use-cases/tron/reward-service.test.ts | 54 +- .../use-cases/tron/reward-service.ts | 18 +- .../use-cases/tron/sig-service.test.ts | 57 +- .../application/use-cases/tron/sig-service.ts | 15 +- .../tron/stake-service.query.test.ts | 58 +- .../use-cases/tron/stake-service.ts | 119 +- .../tron/stake-service.unfreeze.test.ts | 18 +- .../tron/stake-service.withdraw.test.ts | 8 +- .../use-cases/tron/token-service.ts | 6 +- .../use-cases/tron/transaction-artifact.ts | 10 +- .../tron/transaction-service.send.test.ts | 6 +- .../tron/transaction-service.status.test.ts | 22 +- .../use-cases/tron/transaction-service.ts | 105 +- .../use-cases/tron/vote-service.test.ts | 70 +- .../use-cases/tron/vote-service.ts | 81 +- .../use-cases/tron/witness-service.test.ts | 69 +- .../use-cases/tron/witness-service.ts | 26 +- .../use-cases/typed-data-service.test.ts | 14 +- .../use-cases/typed-data-service.ts | 11 +- .../use-cases/wallet-service.keystore.test.ts | 171 ++- .../application/use-cases/wallet-service.ts | 64 +- ts/src/bootstrap/argv.ts | 13 +- ts/src/bootstrap/composition.ts | 16 +- ts/src/bootstrap/families/tron.ts | 42 +- ts/src/bootstrap/families/types.ts | 2 +- ts/src/bootstrap/runner.test.ts | 16 +- ts/src/bootstrap/runner.ts | 81 +- ts/src/domain/address/index.ts | 81 +- ts/src/domain/amounts/amounts.test.ts | 10 +- ts/src/domain/amounts/index.ts | 17 +- ts/src/domain/asset/asset.test.ts | 6 +- ts/src/domain/asset/index.ts | 7 +- ts/src/domain/async/index.ts | 10 +- ts/src/domain/contact/contact.test.ts | 16 +- ts/src/domain/contact/index.ts | 12 +- ts/src/domain/derivation/index.ts | 11 +- ts/src/domain/encoding/encoding.test.ts | 20 +- ts/src/domain/encoding/index.ts | 35 +- ts/src/domain/errors/index.ts | 8 +- ts/src/domain/exchange/exchange.test.ts | 5 +- ts/src/domain/exchange/index.ts | 17 +- ts/src/domain/family/index.ts | 10 +- ts/src/domain/gasfree/gasfree.test.ts | 15 +- ts/src/domain/gasfree/index.ts | 65 +- .../governance/chain-parameters.test.ts | 34 +- ts/src/domain/governance/chain-parameters.ts | 37 +- ts/src/domain/governance/create2.test.ts | 10 +- ts/src/domain/governance/create2.ts | 7 +- ts/src/domain/keystore/index.ts | 46 +- ts/src/domain/keystore/keystore-v3.test.ts | 75 +- ts/src/domain/permission/index.ts | 174 ++- ts/src/domain/permission/permission.test.ts | 80 +- ts/src/domain/sources/sources.test.ts | 7 +- ts/src/domain/typed-data/index.test.ts | 62 +- ts/src/domain/typed-data/index.ts | 34 +- ts/src/domain/types/primitives.ts | 1 - ts/src/domain/types/tx.ts | 60 +- ts/src/domain/wallet/index.ts | 12 +- ts/test/contract-deploy.test.ts | 38 +- ts/test/golden.test.ts | 1041 +++++++++------ 292 files changed, 12479 insertions(+), 6297 deletions(-) create mode 100644 ts/.prettierignore create mode 100644 ts/.prettierrc.json create mode 100644 ts/eslint.config.js diff --git a/ts/.dependency-cruiser.cjs b/ts/.dependency-cruiser.cjs index 3af44982d..e2e0ac004 100644 --- a/ts/.dependency-cruiser.cjs +++ b/ts/.dependency-cruiser.cjs @@ -30,7 +30,8 @@ module.exports = { { name: "inbound-does-not-know-outbound", severity: "error", - comment: "CLI adapters call application ports/use-cases; bootstrap/composition supplies outbound implementations", + comment: + "CLI adapters call application ports/use-cases; bootstrap/composition supplies outbound implementations", from: { path: "^src/adapters/inbound/", pathNot: "\\.test\\.ts$" }, to: { path: "^src/(adapters/outbound|bootstrap)/" }, }, diff --git a/ts/.prettierignore b/ts/.prettierignore new file mode 100644 index 000000000..98e0146ae --- /dev/null +++ b/ts/.prettierignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.wallet-cli/ +.private/ +package-lock.json + +# hand-authored prose: tables and wrapping are deliberate, Prettier reflows them +*.md diff --git a/ts/.prettierrc.json b/ts/.prettierrc.json new file mode 100644 index 000000000..de753c537 --- /dev/null +++ b/ts/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/ts/eslint.config.js b/ts/eslint.config.js new file mode 100644 index 000000000..bb3e628f0 --- /dev/null +++ b/ts/eslint.config.js @@ -0,0 +1,43 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import prettier from "eslint-config-prettier"; + +export default tseslint.config( + { + ignores: ["dist/**", "node_modules/**", ".wallet-cli/**", ".private/**"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + // the CLI renders through the streams port, never straight to the console + "no-console": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, + ], + // several renderers and sanitizers match control bytes on purpose, to strip terminal + // escape-sequence injection out of chain-controlled text (see cli/render/scalars.ts) + "no-control-regex": "off", + // `any` sits at the adapter boundary, where TronWeb / Ledger / yargs ship no usable types. + // tsc is the type authority here; flagging every such cast is noise, not signal. + "@typescript-eslint/no-explicit-any": "off", + }, + }, + { + // golden-output assertions match the CLI's literal column spacing + files: ["**/*.test.ts", "test/**/*.ts"], + rules: { + "no-regex-spaces": "off", + }, + }, + { + files: ["**/*.cjs"], + languageOptions: { + sourceType: "commonjs", + globals: { module: "writable", require: "readonly", __dirname: "readonly" }, + }, + }, + // formatting is Prettier's job — must stay last so it can switch stylistic rules off + prettier, +); diff --git a/ts/package-lock.json b/ts/package-lock.json index baec512e1..8358f4d74 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -31,14 +31,19 @@ "wallet-cli": "dist/index.js" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^25.9.3", "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", "dependency-cruiser": "^17.4.3", + "eslint": "^10.8.1", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.9.6", "tsup": "^8.5.1", "tsx": "^4.22.4", "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.9" }, "engines": { @@ -539,6 +544,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1476,6 +1675,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1483,6 +1689,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", @@ -1536,6 +1749,226 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -1723,6 +2156,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1785,6 +2235,16 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1834,6 +2294,19 @@ "readable-stream": "^3.4.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -2033,6 +2506,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2083,6 +2571,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2264,44 +2759,293 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=18" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "engines": { + "node": ">=4.0" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=6" + "node": ">=4.0" } }, "node_modules/estree-walker": { @@ -2314,6 +3058,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ethereum-cryptography": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz", @@ -2493,6 +3247,27 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2511,6 +3286,19 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2542,6 +3330,27 @@ "rollup": "^4.34.8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -2672,6 +3481,19 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -2805,6 +3627,16 @@ "node": ">= 4" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2847,6 +3679,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2856,6 +3698,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-installed-globally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", @@ -2886,6 +3741,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -2896,6 +3758,27 @@ "node": ">=10" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2909,6 +3792,16 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -2919,6 +3812,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3298,6 +4205,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -3369,6 +4292,13 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -3449,6 +4379,24 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -3494,6 +4442,16 @@ "node": ">=8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3658,6 +4616,32 @@ "node": ">=10" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -3691,6 +4675,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -4076,6 +5070,29 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4433,6 +5450,19 @@ "node": ">=10" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4561,6 +5591,19 @@ "node": "*" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -4575,6 +5618,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", @@ -4589,6 +5656,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/usb": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/usb/-/usb-2.9.0.tgz", @@ -4816,6 +5893,22 @@ "node": "^20.12||^22.13||>=24.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -4839,6 +5932,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -4945,6 +6048,19 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/ts/package.json b/ts/package.json index 9d85f7e9e..5a5f59d25 100644 --- a/ts/package.json +++ b/ts/package.json @@ -44,6 +44,10 @@ "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit", "depcruise": "depcruise src", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "test": "vitest run", "test:watch": "vitest", "prepublishOnly": "npm run build" @@ -74,14 +78,19 @@ "esbuild": "^0.28.1" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^25.9.3", "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", "dependency-cruiser": "^17.4.3", + "eslint": "^10.8.1", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.9.6", "tsup": "^8.5.1", "tsx": "^4.22.4", "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.9" } } diff --git a/ts/src/adapters/inbound/cli/arity/arity.test.ts b/ts/src/adapters/inbound/cli/arity/arity.test.ts index 9e1b3b19d..198b192be 100644 --- a/ts/src/adapters/inbound/cli/arity/arity.test.ts +++ b/ts/src/adapters/inbound/cli/arity/arity.test.ts @@ -12,7 +12,10 @@ describe("enumOptions", () => { }); it("descends ciEnum's preprocess pipe to find the literals (through default/optional)", () => { expect(enumOptions(ciEnum(["energy", "bandwidth"]))).toEqual(["energy", "bandwidth"]); - expect(enumOptions(ciEnum(["energy", "bandwidth"]).default("bandwidth"))).toEqual(["energy", "bandwidth"]); + expect(enumOptions(ciEnum(["energy", "bandwidth"]).default("bandwidth"))).toEqual([ + "energy", + "bandwidth", + ]); expect(enumOptions(ciEnum(["native", "token"]).optional())).toEqual(["native", "token"]); }); }); diff --git a/ts/src/adapters/inbound/cli/arity/index.ts b/ts/src/adapters/inbound/cli/arity/index.ts index d7a62918f..e93f41768 100644 --- a/ts/src/adapters/inbound/cli/arity/index.ts +++ b/ts/src/adapters/inbound/cli/arity/index.ts @@ -52,13 +52,22 @@ export function camelToKebab(s: string): string { return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); } -function unwrap(schema: ZodType): { base: ZodType; optional: boolean; hasDefault: boolean; defaultValue?: unknown; description?: string } { +function unwrap(schema: ZodType): { + base: ZodType; + optional: boolean; + hasDefault: boolean; + defaultValue?: unknown; + description?: string; +} { let s: any = schema; let optional = false; let hasDefault = false; let defaultValue: unknown; let description: string | undefined = s?.description; - while (s?.def && (s.def.type === "optional" || s.def.type === "default" || s.def.type === "nullable")) { + while ( + s?.def && + (s.def.type === "optional" || s.def.type === "default" || s.def.type === "nullable") + ) { if (s.def.type === "optional" || s.def.type === "nullable") optional = true; if (s.def.type === "default") { hasDefault = true; @@ -109,7 +118,11 @@ export function introspectFields(fields: ZodObject): FieldInfo[] { /** literal options of an enum field (after unwrapping optional/default), else undefined. */ export function enumOptions(schema: ZodType): string[] | undefined { const { base } = unwrap(schema as ZodType); - let def = (base as unknown as { def?: { type?: string; entries?: Record; out?: { def?: any } } }).def; + let def = ( + base as unknown as { + def?: { type?: string; entries?: Record; out?: { def?: any } }; + } + ).def; // ciEnum() wraps the enum in a preprocess pipe; the literals live on the pipe's output side. if (def?.type === "pipe") def = def.out?.def; if (def?.type !== "enum" || !def.entries) return undefined; diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index eb0319419..5d598ecf5 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -40,9 +40,9 @@ export const accountActivateSpec: ChainSpec = { capability: "account.activate", summary: "Activate a new TRON account", description: - "Create an AccountCreateContract funded by the active account. The target must not already be\n" - + "active; use --dry-run to inspect current creation fees. Note: a plain transfer also activates\n" - + "the recipient, so use this command only when the address just needs to exist.", + "Create an AccountCreateContract funded by the active account. The target must not already be\n" + + "active; use --dry-run to inspect current creation fees. Note: a plain transfer also activates\n" + + "the recipient, so use this command only when the address just needs to exist.", baseFields: z.object({ address: Schemas.addressFor("tron").describe("unactivated TRON base58 address"), ...txModeFields, @@ -55,9 +55,7 @@ export const accountActivateSpec: ChainSpec = { formatText: TextFormatters.txReceipt, }; -export const accountActivateTronBinding = ( - service: TronAccountService, -): FamilyBinding => ({ +export const accountActivateTronBinding = (service: TronAccountService): FamilyBinding => ({ run: async (ctx, network, input) => service.activate(ctx, network, input), }); @@ -70,11 +68,15 @@ export const accountSetSpec: ChainSpec = { capability: "account.set", summary: "Set the one-time on-chain account name or ID", description: - "Set exactly one immutable account field. Names are 1-32 UTF-8 bytes; IDs are unique and 8-32\n" - + "UTF-8 bytes. Each can be set only once and can never be changed afterwards — rehearse with\n" - + "--dry-run to check the value first. This is not `wallet-cli rename`, which changes the local label.", + "Set exactly one immutable account field. Names are 1-32 UTF-8 bytes; IDs are unique and 8-32\n" + + "UTF-8 bytes. Each can be set only once and can never be changed afterwards — rehearse with\n" + + "--dry-run to check the value first. This is not `wallet-cli rename`, which changes the local label.", baseFields: z.object({ - name: z.string().min(1).optional().describe("one-time on-chain account name (1-32 UTF-8 bytes)"), + name: z + .string() + .min(1) + .optional() + .describe("one-time on-chain account name (1-32 UTF-8 bytes)"), id: z.string().min(1).optional().describe("one-time unique account ID (8-32 UTF-8 bytes)"), ...txModeFields, }), @@ -96,15 +98,15 @@ export const accountSetSpec: ChainSpec = { formatText: TextFormatters.txReceipt, }; -export const accountSetTronBinding = ( - service: TronAccountService, -): FamilyBinding => ({ +export const accountSetTronBinding = (service: TronAccountService): FamilyBinding => ({ run: async (ctx, network, input) => service.setOnChain(ctx, network, input), }); export const accountBalanceSpec: ChainSpec = { path: ["account", "balance"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "account.balance.native", summary: "Show native balance (TRX/SUN)", baseFields: z.object({}), @@ -118,7 +120,9 @@ export const accountBalanceTronBinding = (svc: TronAccountService): FamilyBindin export const accountInfoSpec: ChainSpec = { path: ["account", "info"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", summary: "Show raw account data (getAccount; TRON includes resources)", baseFields: z.object({}), examples: [{ cmd: "wallet-cli account info" }], @@ -131,12 +135,20 @@ export const accountInfoTronBinding = (svc: TronAccountService): FamilyBinding = export const accountHistorySpec: ChainSpec = { path: ["account", "history"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", summary: "Show transaction history (requires TronGrid)", baseFields: z.object({ - limit: z.coerce.number().int().positive().max(200).default(20) + limit: z.coerce + .number() + .int() + .positive() + .max(200) + .default(20) .describe("maximum records to return, in records; range: 1-200"), - only: ciEnum(["native", "token"]).optional() + only: ciEnum(["native", "token"]) + .optional() .describe("filter history by transfer type; omit to show all transfer types"), }), examples: [{ cmd: "wallet-cli account history --limit 10" }], @@ -149,7 +161,9 @@ export const accountHistoryTronBinding = (svc: TronAccountService): FamilyBindin export const accountPortfolioSpec: ChainSpec = { path: ["account", "portfolio"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "account.portfolio", summary: "Show native + token balances with best-effort USD value", baseFields: z.object({}), diff --git a/ts/src/adapters/inbound/cli/commands/address.test.ts b/ts/src/adapters/inbound/cli/commands/address.test.ts index 6602ebd4f..b9d70d208 100644 --- a/ts/src/adapters/inbound/cli/commands/address.test.ts +++ b/ts/src/adapters/inbound/cli/commands/address.test.ts @@ -9,8 +9,14 @@ function helpFor(path: string[]): string { registerAddressCommands(registry, { generate: async () => ({}) } as never); let rendered = ""; const streams = { - result(text: string) { rendered = text; }, - diagnostic() {}, errorLine() {}, event() {}, readStdinOnce: () => "", warnings: () => [], + result(text: string) { + rendered = text; + }, + diagnostic() {}, + errorLine() {}, + event() {}, + readStdinOnce: () => "", + warnings: () => [], } as unknown as StreamManager; new HelpService(registry, streams, "0.0.0").handleMeta([...path, "--help"]); return rendered; @@ -21,8 +27,14 @@ function schemaFor(path: string[]): any { registerAddressCommands(registry, { generate: async () => ({}) } as never); let rendered = ""; const streams = { - result(text: string) { rendered = text; }, - diagnostic() {}, errorLine() {}, event() {}, readStdinOnce: () => "", warnings: () => [], + result(text: string) { + rendered = text; + }, + diagnostic() {}, + errorLine() {}, + event() {}, + readStdinOnce: () => "", + warnings: () => [], } as unknown as StreamManager; new HelpService(registry, streams, "0.0.0").handleMeta([...path, "--json-schema"]); return JSON.parse(rendered); @@ -33,8 +45,10 @@ function schemaFor(path: string[]): any { // machine or a CI runner — so the location has to be visible up front, not only in the receipt. describe("address generate --out documents its default location", () => { it("names the default path in the flag description", () => { - const out = helpFor(["address", "generate"]) - .split("\n").find((line) => line.trimStart().startsWith("--out")) ?? ""; + const out = + helpFor(["address", "generate"]) + .split("\n") + .find((line) => line.trimStart().startsWith("--out")) ?? ""; // shape asserted against the writer in keypair-writer.test.ts ("derives the default location…") expect(out).toContain("generated/keypair-
"); }); @@ -48,8 +62,10 @@ describe("address generate --out documents its default location", () => { // The rendered "[optional, default: X]" tag is derived from zod, so faking one here would put a // default in help that --json-schema does not have. it("does not fake a default tag on the rendered flag line", () => { - const out = helpFor(["address", "generate"]) - .split("\n").find((line) => line.trimStart().startsWith("--out")) ?? ""; + const out = + helpFor(["address", "generate"]) + .split("\n") + .find((line) => line.trimStart().startsWith("--out")) ?? ""; expect(out).toContain("[optional]"); expect(out).not.toMatch(/\[optional, default:/); }); diff --git a/ts/src/adapters/inbound/cli/commands/address.ts b/ts/src/adapters/inbound/cli/commands/address.ts index 05fb08a2d..22f21dab3 100644 --- a/ts/src/adapters/inbound/cli/commands/address.ts +++ b/ts/src/adapters/inbound/cli/commands/address.ts @@ -4,17 +4,22 @@ import type { CommandRegistry } from "../registry/index.js"; import type { AddressService } from "../../../../application/use-cases/address-service.js"; import { TextFormatters } from "../render/index.js"; -export function registerAddressCommands( - registry: CommandRegistry, - service: AddressService, -): void { +export function registerAddressCommands(registry: CommandRegistry, service: AddressService): void { const fields = z.object({ // The default lives in SecureKeypairWriter, not in this schema, so it is stated in prose: // a "[optional, default: …]" tag is derived from zod and would claim a default --json-schema // does not have. Readers need the location before running, not only in the receipt. - out: z.string().min(1).max(4096).optional() - .describe("exclusive 0600 output path; existing files are never overwritten (default: /generated/keypair-
)"), - printSecret: z.boolean().default(false) + out: z + .string() + .min(1) + .max(4096) + .optional() + .describe( + "exclusive 0600 output path; existing files are never overwritten (default: /generated/keypair-
)", + ), + printSecret: z + .boolean() + .default(false) .describe("print the private key instead of writing it; use only offline"), }); registry.add({ @@ -22,8 +27,7 @@ export function registerAddressCommands( network: "none", wallet: "none", auth: "none", - summary: - "Generate a random TRON/EVM keypair locally without adding it to the wallet", + summary: "Generate a random TRON/EVM keypair locally without adding it to the wallet", description: "Generate a secp256k1 keypair offline. By default the private key is written exclusively to a 0600 file and never printed or added to the keystore.", fields, diff --git a/ts/src/adapters/inbound/cli/commands/artifact.test.ts b/ts/src/adapters/inbound/cli/commands/artifact.test.ts index 2d4ba7fb2..efd29810c 100644 --- a/ts/src/adapters/inbound/cli/commands/artifact.test.ts +++ b/ts/src/adapters/inbound/cli/commands/artifact.test.ts @@ -13,15 +13,13 @@ afterEach(() => { }); describe("readBoundedTextFile", () => { - it.runIf(process.platform !== "win32")( - "rejects a FIFO without waiting for a writer", - () => { - const root = mkdtempSync(join(tmpdir(), "wallet-cli-artifact-")); - roots.push(root); - const fifo = join(root, "transaction.hex"); - execFileSync("mkfifo", [fifo]); + it.runIf(process.platform !== "win32")("rejects a FIFO without waiting for a writer", () => { + const root = mkdtempSync(join(tmpdir(), "wallet-cli-artifact-")); + roots.push(root); + const fifo = join(root, "transaction.hex"); + execFileSync("mkfifo", [fifo]); - const script = ` + const script = ` import { readBoundedTextFile } from ${JSON.stringify(new URL("./artifact.ts", import.meta.url).href)}; try { readBoundedTextFile(${JSON.stringify(fifo)}, 1024, "transaction hex file"); @@ -30,18 +28,17 @@ describe("readBoundedTextFile", () => { process.stdout.write(JSON.stringify({ code: error.code, message: error.message })); } `; - const result = spawnSync( - process.execPath, - ["--import", "tsx", "--input-type=module", "--eval", script], - { encoding: "utf8", timeout: 1_000 }, - ); + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { encoding: "utf8", timeout: 1_000 }, + ); - expect(result.error).toBeUndefined(); - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - code: "invalid_value", - message: "transaction hex file must be a regular file", - }); - }, - ); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + code: "invalid_value", + message: "transaction hex file must be a regular file", + }); + }); }); diff --git a/ts/src/adapters/inbound/cli/commands/artifact.ts b/ts/src/adapters/inbound/cli/commands/artifact.ts index 4785d5c9b..2b3f754e2 100644 --- a/ts/src/adapters/inbound/cli/commands/artifact.ts +++ b/ts/src/adapters/inbound/cli/commands/artifact.ts @@ -6,13 +6,12 @@ export function readBoundedTextFile(path: string, maxBytes: number, label: strin try { fd = openSync( path, - constants.O_RDONLY - | (constants.O_NOFOLLOW ?? 0) - | (constants.O_NONBLOCK ?? 0), + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), ); const stat = fstatSync(fd); if (!stat.isFile()) throw new UsageError("invalid_value", `${label} must be a regular file`); - if (stat.size > maxBytes) throw new UsageError("invalid_value", `${label} exceeds the ${maxBytes}-byte limit`); + if (stat.size > maxBytes) + throw new UsageError("invalid_value", `${label} exceeds the ${maxBytes}-byte limit`); return readFileSync(fd, "utf8"); } catch (error) { if (error instanceof UsageError) throw error; diff --git a/ts/src/adapters/inbound/cli/commands/asset.ts b/ts/src/adapters/inbound/cli/commands/asset.ts index 21f300443..3543830de 100644 --- a/ts/src/adapters/inbound/cli/commands/asset.ts +++ b/ts/src/adapters/inbound/cli/commands/asset.ts @@ -17,12 +17,16 @@ 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) +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", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "asset.issue", summary: "Issue a TRC10 token and lock in its ICO terms", @@ -33,36 +37,57 @@ export const assetIssueSpec: ChainSpec = { "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], + 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"), + 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() + freeNetPerAccount: z.coerce + .number() + .int() + .min(0) + .optional() .describe("free bandwidth each holder may use"), - publicFreeNet: z.coerce.number().int().min(0).optional() + 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() + 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", - }], + 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", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "asset.update", summary: "Update the mutable fields of the TRC10 you issued", @@ -77,9 +102,17 @@ export const assetUpdateSpec: ChainSpec = { 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() + freeNetPerAccount: z.coerce + .number() + .int() + .min(0) + .optional() .describe("free bandwidth each holder may use"), - publicFreeNet: z.coerce.number().int().min(0).optional() + publicFreeNet: z.coerce + .number() + .int() + .min(0) + .optional() .describe("shared free bandwidth pool for holders"), ...txModeFields, }), @@ -89,7 +122,9 @@ export const assetUpdateSpec: ChainSpec = { export const assetParticipateSpec: ChainSpec = { path: ["asset", "participate"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "asset.participate", summary: "Buy into a TRC10's ICO at its fixed rate", @@ -114,7 +149,9 @@ export const assetParticipateSpec: ChainSpec = { export const assetUnfreezeSpec: ChainSpec = { path: ["asset", "unfreeze"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "asset.unfreeze", summary: "Release the matured frozen supply of the TRC10 you issued", @@ -132,7 +169,9 @@ export const assetUnfreezeSpec: ChainSpec = { export const assetInfoSpec: ChainSpec = { path: ["asset", "info"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "asset.info", summary: "Show a TRC10 in full", description: @@ -146,7 +185,9 @@ export const assetInfoSpec: ChainSpec = { positionals: [{ field: "assetRef", placeholder: "asset" }], baseFields: z.object({ assetRef: assetReference.optional(), - issuer: Schemas.addressFor("tron").optional().describe("look up the token issued by this address"), + issuer: Schemas.addressFor("tron") + .optional() + .describe("look up the token issued by this address"), }), examples: [ { cmd: "wallet-cli asset info 1000123" }, @@ -158,7 +199,9 @@ export const assetInfoSpec: ChainSpec = { export const assetListSpec: ChainSpec = { path: ["asset", "list"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "asset.list", summary: "List TRC10 tokens, one page at a time", description: @@ -168,7 +211,12 @@ export const assetListSpec: ChainSpec = { "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) + 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"), }), @@ -179,12 +227,20 @@ export const assetListSpec: ChainSpec = { formatText: TextFormatters.assetList, }; -export function assetDefinitions(svc: TronAssetService): Array<{ spec: ChainSpec; binding: FamilyBinding }> { +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: assetParticipateSpec, + binding: { run: (ctx, net, input) => svc.participate(ctx, net, input) }, + }, + { + spec: assetUnfreezeSpec, + binding: { run: (ctx, net, input) => svc.unfreeze(ctx, net, input) }, + }, { spec: assetInfoSpec, binding: { run: (_ctx, net, input) => svc.info(net, input) } }, { spec: assetListSpec, binding: { run: (_ctx, net, input) => svc.list(net, input) } }, ]; diff --git a/ts/src/adapters/inbound/cli/commands/block.ts b/ts/src/adapters/inbound/cli/commands/block.ts index fdc268a89..5cc28f9c8 100644 --- a/ts/src/adapters/inbound/cli/commands/block.ts +++ b/ts/src/adapters/inbound/cli/commands/block.ts @@ -6,10 +6,16 @@ import { TextFormatters } from "../render/index.js"; export const blockSpec: ChainSpec = { path: ["block"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", positionals: [{ field: "number" }], summary: "Get a block (latest if omitted)", - baseFields: z.object({ number: Schemas.uintString().optional().describe("block number to fetch, in block height; omit to fetch the latest block") }), + baseFields: z.object({ + number: Schemas.uintString() + .optional() + .describe("block number to fetch, in block height; omit to fetch the latest block"), + }), examples: [{ cmd: "wallet-cli block" }, { cmd: "wallet-cli block 12345" }], formatText: TextFormatters.block, }; diff --git a/ts/src/adapters/inbound/cli/commands/chain.ts b/ts/src/adapters/inbound/cli/commands/chain.ts index 11d93bd5b..baa6f37a2 100644 --- a/ts/src/adapters/inbound/cli/commands/chain.ts +++ b/ts/src/adapters/inbound/cli/commands/chain.ts @@ -3,18 +3,28 @@ import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronChainService } from "../../../../application/use-cases/tron/chain-service.js"; import { TextFormatters } from "../render/index.js"; -export function chainDefinitions(service: TronChainService): Array<{ spec: ChainSpec; binding: FamilyBinding }> { +export function chainDefinitions( + service: TronChainService, +): Array<{ spec: ChainSpec; binding: FamilyBinding }> { return [ { spec: { path: ["chain", "params"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", summary: "On-chain governance parameters", description: "Show on-chain governance parameters. Use --key for one value.", baseFields: z.object({ - key: z.string().optional().describe("return only this parameter (e.g. getEnergyFee); omit to list all"), + key: z + .string() + .optional() + .describe("return only this parameter (e.g. getEnergyFee); omit to list all"), }), - examples: [{ cmd: "wallet-cli chain params" }, { cmd: "wallet-cli chain params --key getEnergyFee" }], + examples: [ + { cmd: "wallet-cli chain params" }, + { cmd: "wallet-cli chain params --key getEnergyFee" }, + ], formatText: TextFormatters.chainParams, }, binding: { run: async (_ctx, net, input) => service.params(net, input.key) }, @@ -22,7 +32,9 @@ export function chainDefinitions(service: TronChainService): Array<{ spec: Chain { spec: { path: ["chain", "prices"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", summary: "Energy/bandwidth unit price and memo fee", description: "Show current energy/bandwidth unit price (in SUN; 1 TRX = 1,000,000 SUN)\n" + @@ -36,12 +48,14 @@ export function chainDefinitions(service: TronChainService): Array<{ spec: Chain { spec: { path: ["chain", "node"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", summary: "Connected node status (version / sync / peers)", description: "Show the connected node's status: version, head/solid block height, sync state,\n" + - "and peer connections. Useful to tell \"node out of sync\" from \"problem with my\n" + - "transaction\". Fields the endpoint does not expose are shown as \"—\" (null in json).", + 'and peer connections. Useful to tell "node out of sync" from "problem with my\n' + + 'transaction". Fields the endpoint does not expose are shown as "—" (null in json).', baseFields: z.object({}), examples: [{ cmd: "wallet-cli chain node" }], formatText: TextFormatters.chainNode, diff --git a/ts/src/adapters/inbound/cli/commands/change-password.test.ts b/ts/src/adapters/inbound/cli/commands/change-password.test.ts index 50ceb8aba..86476e305 100644 --- a/ts/src/adapters/inbound/cli/commands/change-password.test.ts +++ b/ts/src/adapters/inbound/cli/commands/change-password.test.ts @@ -38,18 +38,25 @@ function setup(opts: { newPrompt?: string; confirm?: boolean } = {}) { describe("change-password command (TTY-only)", () => { it("prompts for the new password and returns the changePassword receipt", async () => { const { command, ctx, changePassword } = setup(); - await expect(command.run(ctx, undefined, { yes: true })).resolves.toEqual({ wallets: ["seed", "hot"], count: 2 }); + await expect(command.run(ctx, undefined, { yes: true })).resolves.toEqual({ + wallets: ["seed", "hot"], + count: 2, + }); expect(changePassword).toHaveBeenCalledWith(OLD, NEW); }); it("rejects a new password equal to the old password", async () => { const { command, ctx } = setup({ newPrompt: OLD }); - await expect(command.run(ctx, undefined, { yes: true })).rejects.toMatchObject({ code: "invalid_value" }); + await expect(command.run(ctx, undefined, { yes: true })).rejects.toMatchObject({ + code: "invalid_value", + }); }); it("returns aborted when the confirmation is declined", async () => { const { command, ctx } = setup({ confirm: false }); - await expect(command.run(ctx, undefined, { yes: false })).rejects.toMatchObject({ code: "aborted" }); + await expect(command.run(ctx, undefined, { yes: false })).rejects.toMatchObject({ + code: "aborted", + }); }); it("skips the confirmation prompt with --yes", async () => { diff --git a/ts/src/adapters/inbound/cli/commands/config.ts b/ts/src/adapters/inbound/cli/commands/config.ts index e770aeefb..f9f263155 100644 --- a/ts/src/adapters/inbound/cli/commands/config.ts +++ b/ts/src/adapters/inbound/cli/commands/config.ts @@ -9,7 +9,9 @@ import { TextFormatters } from "../render/index.js"; export function registerConfigCommands(registry: CommandRegistry, service: ConfigService): void { const fields = z.object({ - key: z.enum(CONFIG_KEYS).optional() + key: z + .enum(CONFIG_KEYS) + .optional() .describe("config key to read or set; omit to show the whole effective config"), value: z.string().min(1).optional().describe("new value; omit to read the key"), }); diff --git a/ts/src/adapters/inbound/cli/commands/contact.ts b/ts/src/adapters/inbound/cli/commands/contact.ts index 041af6d27..3f5e3b45c 100644 --- a/ts/src/adapters/inbound/cli/commands/contact.ts +++ b/ts/src/adapters/inbound/cli/commands/contact.ts @@ -4,17 +4,15 @@ import type { CommandRegistry } from "../registry/index.js"; import type { ContactService } from "../../../../application/use-cases/contact-service.js"; import { TextFormatters } from "../render/index.js"; -export function registerContactCommands( - registry: CommandRegistry, - service: ContactService, -): void { +export function registerContactCommands(registry: CommandRegistry, service: ContactService): void { const addFields = z.object({ - name: z.string().min(1).max(256) + name: z + .string() + .min(1) + .max(256) .describe("local name for this recipient; usable anywhere an address is accepted"), - address: z.string().min(1).max(128) - .describe("recipient address to store under this name"), - note: z.string().max(512).optional() - .describe("free-form note, up to 128 safe characters"), + address: z.string().min(1).max(128).describe("recipient address to store under this name"), + note: z.string().max(512).optional().describe("free-form note, up to 128 safe characters"), }); registry.add({ path: ["contact", "add"], @@ -27,12 +25,13 @@ export function registerContactCommands( "Add a locally stored TRON recipient. The Base58Check address is validated and the name can then be used by tx send and gasfree transfer.", fields: addFields, input: addFields, - examples: [{ - cmd: "wallet-cli contact add alice TBy6... --note 'Alice mainnet'", - }], + examples: [ + { + cmd: "wallet-cli contact add alice TBy6... --note 'Alice mainnet'", + }, + ], formatText: TextFormatters.contactAdd, - run: async (_context, _network, input) => - service.add(input.name, input.address, input.note), + run: async (_context, _network, input) => service.add(input.name, input.address, input.note), } satisfies CommandDefinition); const empty = z.object({}); @@ -42,8 +41,7 @@ export function registerContactCommands( wallet: "none", auth: "none", summary: "List recipients", - description: - "List every recipient in the local plaintext address book.", + description: "List every recipient in the local plaintext address book.", fields: empty, input: empty, examples: [{ cmd: "wallet-cli contact list" }], diff --git a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts index 166065e15..dd1b16397 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts @@ -16,14 +16,16 @@ import type { TronContractService } from "../../../../application/use-cases/tron */ function deployWith(input: { abi: string; params?: string }) { - const deploy = vi.fn(async (_ctx: unknown, _net: unknown, _input: { parameters: unknown[] }) => - ({ kind: "tx-receipt" as const })); + const deploy = vi.fn(async (_ctx: unknown, _net: unknown, _input: { parameters: unknown[] }) => ({ + kind: "tx-receipt" as const, + })); const binding = contractDeployTronBinding({ deploy } as unknown as TronContractService); - const run = () => binding.run( - {} as never, - {} as never, - { bytecode: "6080", feeLimit: "1000000", ...input } as never, - ); + const run = () => + binding.run( + {} as never, + {} as never, + { bytecode: "6080", feeLimit: "1000000", ...input } as never, + ); return { run, deploy }; } @@ -95,7 +97,9 @@ describe("contract deploy — ABI constructor guard", () => { }); it("passes an { entrys } wrapper whose constructor is well-formed", async () => { - const abi = JSON.stringify({ entrys: [{ type: "constructor", stateMutability: "nonpayable" }] }); + const abi = JSON.stringify({ + entrys: [{ type: "constructor", stateMutability: "nonpayable" }], + }); const { run, deploy } = deployWith({ abi }); await expect(run()).resolves.toBeDefined(); expect(deploy).toHaveBeenCalledOnce(); @@ -119,7 +123,10 @@ describe("contract deploy — --params form guard", () => { const ABI = ctor({ stateMutability: "nonpayable" }); it("passes raw positional values, the documented deploy form", async () => { - const { run, deploy } = deployWith({ abi: ABI, params: '[100, "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"]' }); + const { run, deploy } = deployWith({ + abi: ABI, + params: '[100, "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"]', + }); await expect(run()).resolves.toBeDefined(); expect(deploy.mock.calls[0]![2]).toMatchObject({ parameters: [100, "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"], diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 41de46129..00cbbd00e 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -69,8 +69,8 @@ function assertConstructorEncodable(abi: unknown): void { if (typeof e.stateMutability !== "string") { throw new UsageError( "invalid_value", - '--abi constructor entry needs a string "stateMutability" ("nonpayable" or "payable"); ' - + "solc emits it — add it by hand if the ABI was trimmed or came from solc < 0.5", + '--abi constructor entry needs a string "stateMutability" ("nonpayable" or "payable"); ' + + "solc emits it — add it by hand if the ABI was trimmed or came from solc < 0.5", ); } } @@ -89,20 +89,24 @@ function assertConstructorEncodable(abi: unknown): void { */ function deployParameters(raw: string | undefined): unknown[] { const values = jsonArray(raw); - const allTyped = values.length > 0 && values.every((v) => { - if (!v || typeof v !== "object" || Array.isArray(v)) return false; - const keys = Object.keys(v); - return keys.length === 2 - && keys.includes("type") - && keys.includes("value") - && typeof (v as { type: unknown }).type === "string" - && (v as { type: string }).type !== ""; - }); + const allTyped = + values.length > 0 && + values.every((v) => { + if (!v || typeof v !== "object" || Array.isArray(v)) return false; + const keys = Object.keys(v); + return ( + keys.length === 2 && + keys.includes("type") && + keys.includes("value") && + typeof (v as { type: unknown }).type === "string" && + (v as { type: string }).type !== "" + ); + }); if (allTyped) { throw new UsageError( "invalid_value", - '--params takes raw positional values for deploy (e.g. [100, "T..."]); {"type","value"} ' - + "entries are the `contract call`/`send` form — deploy reads the types from the ABI constructor", + '--params takes raw positional values for deploy (e.g. [100, "T..."]); {"type","value"} ' + + "entries are the `contract call`/`send` form — deploy reads the types from the ABI constructor", ); } return values; @@ -111,84 +115,108 @@ function deployParameters(raw: string | undefined): unknown[] { const callFields = z.object({ contract: Schemas.addressFor("tron").describe("TRON contract address"), method: z.string().min(1).describe("function signature, e.g. balanceOf(address)"), - params: z.string().optional() + params: z + .string() + .optional() .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), }); export const contractCallSpec: ChainSpec = { path: ["contract", "call"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "contract.call", summary: "Read-only call (triggerConstantContract)", baseFields: callFields, - examples: [{ - cmd: `wallet-cli contract call --contract TR7... --method "balanceOf(address)" --params '[{"type":"address","value":"T..."}]'`, - }], + examples: [ + { + cmd: `wallet-cli contract call --contract TR7... --method "balanceOf(address)" --params '[{"type":"address","value":"T..."}]'`, + }, + ], formatText: TextFormatters.contractCall, }; export const contractCallTronBinding = (svc: TronContractService): FamilyBinding => ({ - run: async (_ctx, net, input) => svc.call( - net, input.contract, input.method, typedParams(input.params), - ), + run: async (_ctx, net, input) => + svc.call(net, input.contract, input.method, typedParams(input.params)), }); const sendFields = z.object({ contract: Schemas.addressFor("tron").describe("TRON contract address"), method: z.string().min(1).describe("function signature, e.g. transfer(address,uint256)"), - params: z.string().optional() + params: z + .string() + .optional() .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), - callValueSun: Schemas.uintString().default("0") + callValueSun: Schemas.uintString() + .default("0") .describe("native TRX attached to the call, in SUN"), - feeLimit: Schemas.positiveIntString().default("100000000") + feeLimit: Schemas.positiveIntString() + .default("100000000") .describe("maximum energy fee to burn, in SUN"), ...governanceTxModeFields, }); export const contractSendSpec: ChainSpec = { path: ["contract", "send"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "contract.call", summary: "State-changing call (triggerSmartContract)", baseFields: sendFields, baseRefine: governanceTxRefine, - examples: [{ - cmd: `wallet-cli contract send --contract TR7... --method "transfer(address,uint256)" --params '[...]'`, - }], + examples: [ + { + cmd: `wallet-cli contract send --contract TR7... --method "transfer(address,uint256)" --params '[...]'`, + }, + ], formatText: TextFormatters.txReceipt, }; export const contractSendTronBinding = (svc: TronContractService): FamilyBinding => ({ - run: async (ctx, net, input) => svc.send(ctx, net, { - ...input, - parameters: typedParams(input.params), - }), + run: async (ctx, net, input) => + svc.send(ctx, net, { + ...input, + parameters: typedParams(input.params), + }), }); const deployFields = z.object({ abi: z.string().min(1).describe("contract ABI as a JSON array string"), bytecode: z.string().min(1).describe("compiled contract bytecode as hex, 0x-prefixed or bare"), feeLimit: Schemas.positiveIntString().describe("maximum energy fee to burn, in SUN"), - params: z.string().optional() - .describe("constructor args as a JSON array of raw positional values, e.g. [100, \"T...\"]; types are taken from the ABI constructor; omit to pass no constructor args"), + params: z + .string() + .optional() + .describe( + 'constructor args as a JSON array of raw positional values, e.g. [100, "T..."]; types are taken from the ABI constructor; omit to pass no constructor args', + ), ...governanceTxModeFields, }); export const contractDeploySpec: ChainSpec = { path: ["contract", "deploy"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "contract.deploy", summary: "Deploy a smart contract", // The Ledger TRON app firmware rejects CreateSmartContract (APDU 0x6a80), even with // blind-signing enabled; software accounts sign and deploy it fine. - requires: ["a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type"], + requires: [ + "a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type", + ], baseFields: deployFields, baseRefine: governanceTxRefine, - examples: [{ - cmd: "wallet-cli contract deploy --abi '[...]' --bytecode 60... --fee-limit 1000000000 --params '[100, \"T...\"]'", - }], + examples: [ + { + cmd: "wallet-cli contract deploy --abi '[...]' --bytecode 60... --fee-limit 1000000000 --params '[100, \"T...\"]'", + }, + ], formatText: TextFormatters.txReceipt, }; @@ -215,7 +243,9 @@ const infoFields = z.object({ export const contractInfoSpec: ChainSpec = { path: ["contract", "info"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "contract.call", summary: "Show contract ABI + metadata", baseFields: infoFields, @@ -237,7 +267,9 @@ const contractGovernanceBase = { formatText: TextFormatters.governanceReceipt, }; -const governedContract = Schemas.addressFor("tron").describe("contract address; the selected account must be its deployer"); +const governedContract = Schemas.addressFor("tron").describe( + "contract address; the selected account must be its deployer", +); export const contractClearAbiSpec: ChainSpec = { path: ["contract", "clear-abi"], @@ -278,7 +310,9 @@ export const contractSetOriginEnergyLimitSpec: ChainSpec = { examples: [{ cmd: "wallet-cli contract set-origin-energy-limit TQ5... 50000000 --wait" }], }; -export const contractSetOriginEnergyLimitTronBinding = (svc: TronContractService): FamilyBinding => ({ +export const contractSetOriginEnergyLimitTronBinding = ( + svc: TronContractService, +): FamilyBinding => ({ run: async (ctx, net, input) => svc.setOriginEnergyLimit(ctx, net, input), }); @@ -293,14 +327,20 @@ export const contractSetUserResourcePercentSpec: ChainSpec = { requires: ["the contract deployer account"], baseFields: z.object({ address: governedContract, - percent: z.coerce.number().int().min(0).max(100) + 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 => ({ +export const contractSetUserResourcePercentTronBinding = ( + svc: TronContractService, +): FamilyBinding => ({ run: async (ctx, net, input) => svc.setUserResourcePercent(ctx, net, input), }); @@ -312,7 +352,9 @@ function create2Refine(value: { code?: string; codeFile?: string }, ctx: z.Refin export const contractCreate2Spec: ChainSpec = { path: ["contract", "create2"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "contract.create2", summary: "Compute a TVM CREATE2 contract address locally", description: @@ -320,13 +362,21 @@ export const contractCreate2Spec: ChainSpec = { "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"), + 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"), + 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-file ./Token.creation.hex --salt 1", + }, { cmd: "wallet-cli contract create2 --deployer TQk... --code 60806040 --salt 255" }, ], formatText: TextFormatters.contractCreate2, @@ -340,7 +390,8 @@ export const contractCreate2TronBinding = (svc: TronContractService): FamilyBind 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}`); + 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}`); } } diff --git a/ts/src/adapters/inbound/cli/commands/encoding.ts b/ts/src/adapters/inbound/cli/commands/encoding.ts index 014e5954e..4a020faca 100644 --- a/ts/src/adapters/inbound/cli/commands/encoding.ts +++ b/ts/src/adapters/inbound/cli/commands/encoding.ts @@ -9,8 +9,13 @@ export function registerEncodingCommands( service: EncodingService, ): void { const fields = z.object({ - input: z.string().min(1).max(2 * 1024 * 1024) - .describe("value to convert: TRON base58 / hex address, EVM 0x address, public key, hex, or Base64"), + input: z + .string() + .min(1) + .max(2 * 1024 * 1024) + .describe( + "value to convert: TRON base58 / hex address, EVM 0x address, public key, hex, or Base64", + ), }); registry.add({ path: ["encoding", "convert"], @@ -18,8 +23,7 @@ export function registerEncodingCommands( wallet: "none", auth: "none", positionals: [{ field: "input" }], - summary: - "Convert and validate address, hex, Base64, and Base58Check encodings", + summary: "Convert and validate address, hex, Base64, and Base58Check encodings", description: "Auto-detect an address/public-key or generic encoding and print all equivalent forms. Runs locally; 32-byte private-key-shaped values are rejected from argv.", fields, @@ -29,7 +33,6 @@ export function registerEncodingCommands( { cmd: "wallet-cli encoding convert deadbeef0102" }, ], formatText: TextFormatters.encodingConvert, - run: async (_context, _network, input) => - service.convert(input.input), + run: async (_context, _network, input) => service.convert(input.input), } satisfies CommandDefinition); } diff --git a/ts/src/adapters/inbound/cli/commands/exchange.ts b/ts/src/adapters/inbound/cli/commands/exchange.ts index 942cfdbfe..ec50c6352 100644 --- a/ts/src/adapters/inbound/cli/commands/exchange.ts +++ b/ts/src/adapters/inbound/cli/commands/exchange.ts @@ -30,7 +30,9 @@ const amountFields = (side: string) => ({ export const exchangeCreateSpec: ChainSpec = { path: ["exchange", "create"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "exchange.create", summary: "Create a Bancor pair and seed both sides", @@ -41,13 +43,22 @@ export const exchangeCreateSpec: ChainSpec = { "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, + "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"), + 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: [ @@ -58,7 +69,9 @@ export const exchangeCreateSpec: ChainSpec = { export const exchangeInjectSpec: ChainSpec = { path: ["exchange", "inject"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "exchange.inject", summary: "Add liquidity to a pair you created", @@ -66,7 +79,8 @@ export const exchangeInjectSpec: ChainSpec = { "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, + "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"] }], @@ -82,7 +96,9 @@ export const exchangeInjectSpec: ChainSpec = { export const exchangeWithdrawSpec: ChainSpec = { path: ["exchange", "withdraw"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "exchange.withdraw", summary: "Take liquidity out of a pair you created", @@ -92,7 +108,8 @@ export const exchangeWithdrawSpec: ChainSpec = { "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, + "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"] }], @@ -108,7 +125,9 @@ export const exchangeWithdrawSpec: ChainSpec = { export const exchangeTradeSpec: ChainSpec = { path: ["exchange", "trade"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "exchange.trade", summary: "Swap one side of a pair for the other", @@ -120,36 +139,56 @@ export const exchangeTradeSpec: ChainSpec = { "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, + "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" }, + { + 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() + minReceived: z + .string() + .min(1) + .optional() .describe("lowest acceptable return, in whole tokens; below this the trade reverts"), - rawMinReceived: z.string().regex(/^\d+$/).optional() + rawMinReceived: z + .string() + .regex(/^\d+$/) + .optional() .describe("lowest acceptable return, in minimal units"), - slippage: z.coerce.number().gt(0).lt(100).optional() + 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" }, + { + 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", + network: "optional", + wallet: "none", + auth: "none", capability: "exchange.show", summary: "Show one exchange pair", description: @@ -166,7 +205,9 @@ export const exchangeShowSpec: ChainSpec = { export const exchangeListSpec: ChainSpec = { path: ["exchange", "list"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "exchange.list", summary: "List exchange pairs, one page at a time", description: @@ -187,11 +228,22 @@ export const exchangeListSpec: ChainSpec = { formatText: TextFormatters.exchangeList, }; -export function exchangeDefinitions(svc: TronExchangeService): Array<{ spec: ChainSpec; binding: FamilyBinding }> { +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: exchangeCreateSpec, + binding: { run: (ctx, net, input) => svc.create(ctx, net, input) }, + }, + { + spec: exchangeInjectSpec, + binding: { run: (ctx, net, input) => svc.inject(ctx, net, input) }, + }, + { + spec: exchangeWithdrawSpec, + binding: { run: (ctx, net, input) => svc.withdraw(ctx, net, input) }, + }, { spec: exchangeTradeSpec, binding: { run: (ctx, net, input) => svc.trade(ctx, net, input) } }, { spec: exchangeShowSpec, binding: { run: (_ctx, net, input) => svc.show(net, input) } }, { spec: exchangeListSpec, binding: { run: (_ctx, net, input) => svc.list(net, input) } }, diff --git a/ts/src/adapters/inbound/cli/commands/gasfree.ts b/ts/src/adapters/inbound/cli/commands/gasfree.ts index 068d42b2f..95274b983 100644 --- a/ts/src/adapters/inbound/cli/commands/gasfree.ts +++ b/ts/src/adapters/inbound/cli/commands/gasfree.ts @@ -9,9 +9,7 @@ export const gasFreeInfoSpec: ChainSpec = { wallet: "optional", auth: "none", capability: "gasfree.info", - requires: [ - "config gasfreeApiKey / gasfreeApiSecret", - ], + requires: ["config gasfreeApiKey / gasfreeApiSecret"], summary: "Show GasFree address, activation status, nonce, balances, and fees", description: "Show this account's GasFree address, activation status, nonce, supported tokens, balances, and current token-denominated fees.", @@ -20,25 +18,30 @@ export const gasFreeInfoSpec: ChainSpec = { formatText: TextFormatters.gasFreeInfo, }; -export const gasFreeInfoTronBinding = ( - service: GasFreeService, -): FamilyBinding => ({ +export const gasFreeInfoTronBinding = (service: GasFreeService): FamilyBinding => ({ run: async (context, network) => service.info(context, network), }); const transferFields = z.object({ - to: z.string().trim().min(1).max(128) - .describe("recipient TRON address or local contact name"), - amount: z.string().regex(/^\d+(\.\d+)?$/, "must be a positive decimal amount") - .refine( - (value) => !/^0+(\.0+)?$/.test(value), - "must be greater than zero", - ) + to: z.string().trim().min(1).max(128).describe("recipient TRON address or local contact name"), + amount: z + .string() + .regex(/^\d+(\.\d+)?$/, "must be a positive decimal amount") + .refine((value) => !/^0+(\.0+)?$/.test(value), "must be greater than zero") .describe("human token amount to transfer, in token units"), - token: z.string().trim().min(1).max(32).default("USDT") + token: z + .string() + .trim() + .min(1) + .max(32) + .default("USDT") .describe("token symbol supported by the GasFree provider"), - dryRun: z.boolean().default(false) - .describe("check token balance and the fee breakdown without unlocking, signing, or submitting"), + dryRun: z + .boolean() + .default(false) + .describe( + "check token balance and the fee breakdown without unlocking, signing, or submitting", + ), }); export const gasFreeTransferSpec: ChainSpec = { @@ -48,9 +51,7 @@ export const gasFreeTransferSpec: ChainSpec = { auth: "conditional", broadcasts: true, capability: "gasfree.transfer", - requires: [ - "config gasfreeApiKey / gasfreeApiSecret", - ], + requires: ["config gasfreeApiKey / gasfreeApiSecret"], summary: "Sign and submit a TIP-712 GasFree token transfer", description: "Sign a GasFree PermitTransfer and submit it to the provider. No TRX is needed; --dry-run checks the token balance and fee breakdown without unlocking or signing.", @@ -75,15 +76,16 @@ export const gasFreeTransferSpec: ChainSpec = { formatText: TextFormatters.gasFreeTransfer, }; -export const gasFreeTransferTronBinding = ( - service: GasFreeService, -): FamilyBinding => ({ - run: async (context, network, input) => - service.transfer(context, network, input), +export const gasFreeTransferTronBinding = (service: GasFreeService): FamilyBinding => ({ + run: async (context, network, input) => service.transfer(context, network, input), }); const traceFields = z.object({ - traceId: z.string().trim().min(1).max(128) + traceId: z + .string() + .trim() + .min(1) + .max(128) .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/) .describe("trace id returned by `gasfree transfer`"), }); @@ -94,9 +96,7 @@ export const gasFreeTraceSpec: ChainSpec = { wallet: "none", auth: "none", capability: "gasfree.trace", - requires: [ - "config gasfreeApiKey / gasfreeApiSecret", - ], + requires: ["config gasfreeApiKey / gasfreeApiSecret"], positionals: [{ field: "traceId", placeholder: "traceId" }], summary: "Track a GasFree transfer by provider trace id", description: @@ -110,9 +110,6 @@ export const gasFreeTraceSpec: ChainSpec = { formatText: TextFormatters.gasFreeTrace, }; -export const gasFreeTraceTronBinding = ( - service: GasFreeService, -): FamilyBinding => ({ - run: async (_context, network, input) => - service.trace(network, input.traceId), +export const gasFreeTraceTronBinding = (service: GasFreeService): FamilyBinding => ({ + run: async (_context, network, input) => service.trace(network, input.traceId), }); diff --git a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts index b266a4b18..cb468ac1a 100644 --- a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts @@ -13,7 +13,8 @@ describe("message sign exclusive group", () => { }); it("states the constraint once — in the group, not also in the field description", () => { - const description = (messageSignSpec.baseFields.shape.message as { description?: string }).description ?? ""; + const description = + (messageSignSpec.baseFields.shape.message as { description?: string }).description ?? ""; expect(description).not.toMatch(/exactly one|OR --message-stdin/i); expect(description).toBeTruthy(); }); @@ -30,7 +31,9 @@ describe("message sign exclusive group", () => { activeAccount: "main", secrets: { pick: (inline: string | undefined) => inline ?? "from-stdin" }, } as never; - await messageSignBinding(service as never).run(ctx, { family: "tron" } as never, { message: "hello" }); + await messageSignBinding(service as never).run(ctx, { family: "tron" } as never, { + message: "hello", + }); expect(received).toBe("hello"); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/network.ts b/ts/src/adapters/inbound/cli/commands/network.ts index 14f7f2a59..16a292e23 100644 --- a/ts/src/adapters/inbound/cli/commands/network.ts +++ b/ts/src/adapters/inbound/cli/commands/network.ts @@ -11,13 +11,21 @@ export function registerNetworkCommands(reg: CommandRegistry): void { // ── networks ──────────────────────────────────────────────────────────────── reg.add({ - path: ["networks"], network: "none", wallet: "none", auth: "none", - summary: "List known networks", fields: empty, input: empty, + path: ["networks"], + network: "none", + wallet: "none", + auth: "none", + summary: "List known networks", + fields: empty, + input: empty, examples: [{ cmd: "wallet-cli networks" }], formatText: TextFormatters.networks, run: async (ctx) => ctx.networkRegistry.all().map((n) => ({ - id: n.id, family: n.family, chainId: n.chainId, feeModel: n.feeModel, + id: n.id, + family: n.family, + chainId: n.chainId, + feeModel: n.feeModel, })), } satisfies CommandDefinition); } diff --git a/ts/src/adapters/inbound/cli/commands/permission.ts b/ts/src/adapters/inbound/cli/commands/permission.ts index d8f689f02..8f17329b5 100644 --- a/ts/src/adapters/inbound/cli/commands/permission.ts +++ b/ts/src/adapters/inbound/cli/commands/permission.ts @@ -16,7 +16,8 @@ export const permissionShowSpec: ChainSpec = { auth: "none", capability: "permission.read", summary: "Show owner, witness, and active permission groups", - description: "Show thresholds, authorized keys, and decoded operation bitmaps. --account may be a local account or any activated TRON address.", + description: + "Show thresholds, authorized keys, and decoded operation bitmaps. --account may be a local account or any activated TRON address.", baseFields: showFields, examples: [ { cmd: "wallet-cli permission show" }, @@ -28,8 +29,14 @@ export const permissionShowSpec: ChainSpec = { const updateFields = z.object({ file: z.string().min(1).optional().describe("complete replacement permission JSON file"), json: z.string().min(1).optional().describe("inline complete replacement permission JSON"), - dryRun: z.boolean().default(false).describe("validate, build, and estimate without signing or broadcasting"), - signOnly: z.boolean().default(false).describe("build and sign, then output complete transaction hex"), + dryRun: z + .boolean() + .default(false) + .describe("validate, build, and estimate without signing or broadcasting"), + signOnly: z + .boolean() + .default(false) + .describe("build and sign, then output complete transaction hex"), buildOnly: txModeFields.buildOnly, // dry-run/sign-only wording is specific to a permission replacement, but the permission group // and expiration semantics are the shared ones — reuse them rather than keep a second copy. @@ -46,21 +53,33 @@ export const permissionUpdateSpec: ChainSpec = { capability: "permission.update", summary: "Replace the complete account permission structure", description: - "Replaces owner/witness/active permissions in one AccountPermissionUpdateContract. The input is\n" - + "the complete structure, in the same shape as `permission show -o json` data.\n" - + "There is no confirmation prompt: it warns about a permanent lockout but does not block the\n" - + "submission, so rehearse with --dry-run first.", + "Replaces owner/witness/active permissions in one AccountPermissionUpdateContract. The input is\n" + + "the complete structure, in the same shape as `permission show -o json` data.\n" + + "There is no confirmation prompt: it warns about a permanent lockout but does not block the\n" + + "submission, so rehearse with --dry-run first.", baseFields: updateFields, exclusive: [{ label: "the new permission structure", flags: ["file", "json"] }], baseRefine: (input, context) => { if ([input.file, input.json].filter((value) => value !== undefined).length !== 1) { - context.addIssue({ code: "custom", path: ["file"], message: "provide exactly one of --file or --json" }); + context.addIssue({ + code: "custom", + path: ["file"], + message: "provide exactly one of --file or --json", + }); } if ([input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length > 1) { - context.addIssue({ code: "custom", path: ["dryRun"], message: "choose at most one of --dry-run, --sign-only, --build-only" }); + context.addIssue({ + code: "custom", + path: ["dryRun"], + message: "choose at most one of --dry-run, --sign-only, --build-only", + }); } if (input.expiration !== undefined && !input.signOnly && !input.buildOnly) { - context.addIssue({ code: "custom", path: ["expiration"], message: "--expiration is only valid with --sign-only or --build-only" }); + context.addIssue({ + code: "custom", + path: ["expiration"], + message: "--expiration is only valid with --sign-only or --build-only", + }); } }, examples: [ @@ -98,7 +117,9 @@ function normalizeLossless(value: unknown): unknown { } if (Array.isArray(value)) return value.map(normalizeLossless); if (value && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, normalizeLossless(entry)])); + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, normalizeLossless(entry)]), + ); } return value; } diff --git a/ts/src/adapters/inbound/cli/commands/proposal.ts b/ts/src/adapters/inbound/cli/commands/proposal.ts index 0a1313aac..07b49e097 100644 --- a/ts/src/adapters/inbound/cli/commands/proposal.ts +++ b/ts/src/adapters/inbound/cli/commands/proposal.ts @@ -7,7 +7,9 @@ import { TextFormatters } from "../render/index.js"; export const proposalListSpec: ChainSpec = { path: ["proposal", "list"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "proposal.read", summary: "List on-chain governance proposals", description: @@ -16,7 +18,8 @@ export const proposalListSpec: ChainSpec = { "value column is what the proposal sets, not the current value on chain — see\n" + "'chain params' for those. Active proposals are shown by default.", baseFields: z.object({ - state: ciEnum(["active", "all"]).default("active") + 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"), @@ -34,7 +37,9 @@ export const proposalListTronBinding = (service: TronProposalService): FamilyBin export const proposalShowSpec: ChainSpec = { path: ["proposal", "show"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "proposal.read", positionals: [{ field: "id" }], summary: "Show one governance proposal", @@ -76,13 +81,17 @@ export const proposalCreateSpec: ChainSpec = { "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) + 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" }, + { + cmd: "wallet-cli proposal create --set getTransactionFee=15 --set getCreateAccountFee=200000 --wait", + }, ], }; diff --git a/ts/src/adapters/inbound/cli/commands/reward.ts b/ts/src/adapters/inbound/cli/commands/reward.ts index 7cf79373d..da2236772 100644 --- a/ts/src/adapters/inbound/cli/commands/reward.ts +++ b/ts/src/adapters/inbound/cli/commands/reward.ts @@ -6,7 +6,9 @@ import { TextFormatters } from "../render/index.js"; export const rewardBalanceSpec: ChainSpec = { path: ["reward", "balance"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "reward.balance", summary: "Show claimable voting/block reward and withdraw status", description: @@ -24,7 +26,9 @@ export const rewardBalanceTronBinding = (svc: TronRewardService): FamilyBinding export const rewardWithdrawSpec: ChainSpec = { path: ["reward", "withdraw"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "reward.withdraw", summary: "Withdraw accrued voting/block rewards", @@ -32,10 +36,7 @@ export const rewardWithdrawSpec: ChainSpec = { "Withdraw accrued voting/block rewards into your available balance.\n" + "Rewards can be withdrawn at most once every 24 hours.", baseFields: z.object({ ...txModeFields }), - examples: [ - { cmd: "wallet-cli reward withdraw" }, - { cmd: "wallet-cli reward withdraw --wait" }, - ], + examples: [{ cmd: "wallet-cli reward withdraw" }, { cmd: "wallet-cli reward withdraw --wait" }], formatText: TextFormatters.txReceipt, }; diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index 4a4482a68..0e9bdc731 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -12,25 +12,51 @@ import type { MessageService } from "../../../../application/use-cases/message-s // ── execution-mode flags shared by every signing command ───────────────────────── /** Transaction execution fields; default (no mode flag) = sign and broadcast on-chain. */ export const txModeFields = { - dryRun: z.boolean().default(false).describe("build and estimate only, with no signature and no broadcast"), - signOnly: z.boolean().default(false).describe("sign and output complete transaction hex without broadcasting"), + dryRun: z + .boolean() + .default(false) + .describe("build and estimate only, with no signature and no broadcast"), + signOnly: z + .boolean() + .default(false) + .describe("sign and output complete transaction hex without broadcasting"), // Both multi-sig routes start from this artifact: the hex relay (`tx sign --file --out`) and the // TronLink queue (`tx multisig --create`). Naming only one would read as "service path only". - buildOnly: z.boolean().default(false) - .describe("build and output unsigned complete transaction hex without unlocking; the entry point for multi-party signing (relay it with `tx sign`, or open a queue with `tx multisig --create`)"), - permissionId: z.coerce.number().int().min(0).max(9).default(0) + buildOnly: z + .boolean() + .default(false) + .describe( + "build and output unsigned complete transaction hex without unlocking; the entry point for multi-party signing (relay it with `tx sign`, or open a queue with `tx multisig --create`)", + ), + permissionId: z.coerce + .number() + .int() + .min(0) + .max(9) + .default(0) .describe("TRON permission group to sign with (0=owner, 1=witness, 2-9=active)"), // The 24h bound is the chain's, enforced by max() above; the omitted case is the node's own // ~60s, which is why extending it is the whole point of this flag when collecting signatures. - expiration: z.coerce.number().int().min(1).max(86_400_000).optional() - .describe("transaction expiration in ms, up to 86400000 (24h); only with --sign-only or --build-only; omitted = node default (~60s)"), + expiration: z.coerce + .number() + .int() + .min(1) + .max(86_400_000) + .optional() + .describe( + "transaction expiration in ms, up to 86400000 (24h); only with --sign-only or --build-only; omitted = node default (~60s)", + ), }; /** Full transaction controls required by governance/administrative writes. */ export const governanceTxModeFields = { ...txModeFields, - buildOnly: z.boolean().default(false) - .describe("build an unsigned transaction without signing or broadcasting; mutually exclusive with --dry-run/--sign-only"), + buildOnly: z + .boolean() + .default(false) + .describe( + "build an unsigned transaction without signing or broadcasting; mutually exclusive with --dry-run/--sign-only", + ), }; export function governanceTxRefine( @@ -38,17 +64,25 @@ export function governanceTxRefine( 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" }); + 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" }); + ctx.addIssue({ + code: "custom", + path: ["expiration"], + message: "only valid with --sign-only or --build-only", + }); } } // ── unified --amount / --raw-amount selector (shared by every chain's `tx send`) ──── // A transfer of 0 is meaningless on any chain — reject it here (exit 2) rather than let the node // reject it with an opaque error. regex-based zero check (never BigInt): zod v4 keeps running // refinements after the regex fails, so a throwing check would escape safeParse. -const positiveDecimalAmount = z.string() +const positiveDecimalAmount = z + .string() .regex(/^\d+(\.\d+)?$/, "must be a non-negative decimal string") .refine((v) => !/^0+(\.0+)?$/.test(v), { message: "must be greater than zero" }); @@ -61,9 +95,17 @@ export function unifiedAmountFields(amountDesc: string, rawDesc: string) { } /** superRefine: exactly one of --amount or --raw-amount must be present. */ -export function amountSelector(v: { amount?: string; rawAmount?: string }, ctx: z.RefinementCtx): void { +export function amountSelector( + v: { amount?: string; rawAmount?: string }, + ctx: z.RefinementCtx, +): void { const n = [v.amount !== undefined, v.rawAmount !== undefined].filter(Boolean).length; - if (n !== 1) ctx.addIssue({ code: "custom", path: ["amount"], message: "provide exactly one of --amount or --raw-amount" }); + if (n !== 1) + ctx.addIssue({ + code: "custom", + path: ["amount"], + message: "provide exactly one of --amount or --raw-amount", + }); } const messageSignFields = z.object({ @@ -80,7 +122,9 @@ export const messageSignSpec: ChainSpec = { summary: "Sign an arbitrary message (TIP-191/V2 · EIP-191)", baseFields: messageSignFields, // SecretResolver.pick enforces this: both sources → invalid_option, neither → missing_option. - exclusive: [{ label: "the message to sign", flags: ["message", "message-stdin"], select: "exactly-one" }], + exclusive: [ + { label: "the message to sign", flags: ["message", "message-stdin"], select: "exactly-one" }, + ], examples: [{ cmd: `wallet-cli message sign --message "hello"` }], formatText: TextFormatters.messageSign, }; diff --git a/ts/src/adapters/inbound/cli/commands/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts index faa82c78f..e7d3cb5a9 100644 --- a/ts/src/adapters/inbound/cli/commands/stake.ts +++ b/ts/src/adapters/inbound/cli/commands/stake.ts @@ -1,10 +1,6 @@ import { z } from "zod"; import type { NetworkDescriptor } from "../../../../domain/types/index.js"; -import type { - ChainSpec, - ExecutionContext, - FamilyBinding, -} from "../contracts/index.js"; +import type { ChainSpec, ExecutionContext, FamilyBinding } from "../contracts/index.js"; import type { TronStakeService } from "../../../../application/use-cases/tron/stake-service.js"; import { RESOURCES } from "../../../../domain/resources/index.js"; import { Schemas } from "../schemas/index.js"; @@ -38,7 +34,9 @@ function stakeCommand( return { spec: { path: ["stake", action], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: options.capability ?? "staking.freeze", summary, @@ -52,7 +50,9 @@ function stakeCommand( }; } -export function stakeDefinitions(service: TronStakeService): Array<{ spec: ChainSpec; binding: FamilyBinding }> { +export function stakeDefinitions( + service: TronStakeService, +): Array<{ spec: ChainSpec; binding: FamilyBinding }> { return [ stakeCommand( "freeze", @@ -85,7 +85,9 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain { // The Ledger TRON app firmware rejects CancelAllUnfreezeV2Contract (APDU 0x6a80), // even with blind-signing enabled; software accounts sign it fine. - requires: ["a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type"], + requires: [ + "a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type", + ], }, ), stakeCommand( @@ -93,14 +95,19 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain "Delegate resource to another address (DelegateResourceV2)", (context, network, input) => service.delegate(context, network, input), { - amountSun: Schemas.positiveIntString() - .describe("staked-TRX amount backing the delegated resource, in SUN"), - receiver: Schemas.addressFor("tron") - .describe("TRON address receiving the delegated resource"), + amountSun: Schemas.positiveIntString().describe( + "staked-TRX amount backing the delegated resource, in SUN", + ), + receiver: Schemas.addressFor("tron").describe( + "TRON address receiving the delegated resource", + ), resource: resourceField("resource type to delegate or reclaim"), - lock: z.boolean().default(false) + lock: z + .boolean() + .default(false) .describe("lock the delegation and prevent early undelegation"), - lockPeriod: Schemas.positiveIntString().optional() + lockPeriod: Schemas.positiveIntString() + .optional() .describe("lock duration in blocks, approximately 3 seconds per block; requires --lock"), }, { @@ -121,10 +128,12 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain "Reclaim delegated resource (UnDelegateResourceV2)", (context, network, input) => service.undelegate(context, network, input), { - amountSun: Schemas.positiveIntString() - .describe("staked-TRX amount backing the resource to reclaim, in SUN"), - receiver: Schemas.addressFor("tron") - .describe("TRON address that previously received the delegated resource"), + amountSun: Schemas.positiveIntString().describe( + "staked-TRX amount backing the resource to reclaim, in SUN", + ), + receiver: Schemas.addressFor("tron").describe( + "TRON address that previously received the delegated resource", + ), resource: resourceField("resource type to delegate or reclaim"), }, { capability: "staking.delegate" }, @@ -132,13 +141,19 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain { spec: { path: ["stake", "info"], - network: "optional", wallet: "optional", auth: "none", - summary: "Staking & resource overview (staked / voting power / resource / unfreezing / withdrawable)", + network: "optional", + wallet: "optional", + auth: "none", + summary: + "Staking & resource overview (staked / voting power / resource / unfreezing / withdrawable)", description: "Staking & resource overview: staked amounts, voting power (TP), energy/bandwidth\n" + "usage, pending unstakes, currently withdrawable TRX, and available unfreeze slots.", baseFields: z.object({}), - examples: [{ cmd: "wallet-cli stake info" }, { cmd: "wallet-cli stake info --account main -o json" }], + examples: [ + { cmd: "wallet-cli stake info" }, + { cmd: "wallet-cli stake info --account main -o json" }, + ], formatText: TextFormatters.stakeInfo, }, binding: { run: async (ctx, net) => service.info(ctx, net) }, @@ -146,23 +161,32 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain { spec: { path: ["stake", "delegated"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", summary: "Delegation details and max delegatable size", description: "Delegation details (outbound/inbound) plus the maximum size you can still delegate.\n" + - "Outbound shows \"Locked until\" (you cannot reclaim before then); inbound shows\n" + - "\"Guaranteed until\" (the delegator cannot reclaim before then).", + 'Outbound shows "Locked until" (you cannot reclaim before then); inbound shows\n' + + '"Guaranteed until" (the delegator cannot reclaim before then).', baseFields: z.object({ - direction: ciEnum(["out", "in"]).default("out") + direction: ciEnum(["out", "in"]) + .default("out") .describe("out = delegated to others; in = delegated to me"), - resource: ciEnum(RESOURCES).optional() + resource: ciEnum(RESOURCES) + .optional() .describe("filter to a single resource type; omit to show both"), - to: Schemas.addressFor("tron").optional() + to: Schemas.addressFor("tron") + .optional() .describe("only show delegation to this receiver (out only)"), }), baseRefine: (value, context) => { if (value.to !== undefined && value.direction === "in") { - context.addIssue({ code: "custom", path: ["to"], message: "--to only applies to --direction out" }); + context.addIssue({ + code: "custom", + path: ["to"], + message: "--to only applies to --direction out", + }); } }, examples: [ diff --git a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts index 8d6447932..b71e0125a 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -3,7 +3,10 @@ import { CommandRegistry } from "../registry/index.js"; import { registerWalletCommands } from "./wallet.js"; import { registerConfigCommands } from "./config.js"; import { registerNetworkCommands } from "./network.js"; -import { registerTronChainCommands, type TronChainCommandDependencies } from "../../../../bootstrap/families/tron.js"; +import { + registerTronChainCommands, + type TronChainCommandDependencies, +} from "../../../../bootstrap/families/tron.js"; import { commandId } from "../command-id.js"; import { TextFormatters } from "../render/index.js"; import { introspectFields } from "../arity/index.js"; @@ -14,7 +17,10 @@ import { registerContactCommands } from "./contact.js"; import { registerAddressCommands } from "./address.js"; import { registerEncodingCommands } from "./encoding.js"; -const ctx = (over: Partial = {}): TextRenderContext => ({ command: "x", ...over }); +const ctx = (over: Partial = {}): TextRenderContext => ({ + command: "x", + ...over, +}); describe("text formatters", () => { it("every registered command has a command-owned text formatter", () => { @@ -27,8 +33,11 @@ describe("text formatters", () => { registerEncodingCommands(registry, {} as never); registerTronChainCommands(registry, {} as TronChainCommandDependencies); - const missing = registry.all() - .filter((cmd) => typeof (isChainCommand(cmd) ? cmd.spec.formatText : cmd.formatText) !== "function") + const missing = registry + .all() + .filter( + (cmd) => typeof (isChainCommand(cmd) ? cmd.spec.formatText : cmd.formatText) !== "function", + ) .map((cmd) => commandId(isChainCommand(cmd) ? { path: cmd.spec.path } : cmd)) .sort(); @@ -65,15 +74,27 @@ describe("text formatters", () => { describe("permissionShow formatter", () => { const view = { address: "Towner", - owner: { id: 0, name: "owner", threshold: 1, keys: [{ address: "Towner", weight: 1, local: "main" }] }, - actives: [{ - id: 2, name: "finance", threshold: 2, - keys: [{ address: "TQkX", weight: 1 }, { address: "TXe4", weight: 1, local: "cold" }], - operations: ["TransferContract"], - operationsHex: "7fff1fc0033e0100000000000000000000000000000000000000000000000000", - operationLabels: ["Transfer TRX"], - unknownOperationIds: [], - }], + owner: { + id: 0, + name: "owner", + threshold: 1, + keys: [{ address: "Towner", weight: 1, local: "main" }], + }, + actives: [ + { + id: 2, + name: "finance", + threshold: 2, + keys: [ + { address: "TQkX", weight: 1 }, + { address: "TXe4", weight: 1, local: "cold" }, + ], + operations: ["TransferContract"], + operationsHex: "7fff1fc0033e0100000000000000000000000000000000000000000000000000", + operationLabels: ["Transfer TRX"], + unknownOperationIds: [], + }, + ], } as any; // Doc §3.1.1 keeps operationsHex in json — it is a machine value, and the human column already @@ -89,22 +110,31 @@ describe("permissionShow formatter", () => { expect(out).toContain("Transfer TRX"); expect(out).toContain("(1 total)"); expect(out).toContain("(this wallet: cold)"); - expect(out).toContain('finance (id 2, active)'); + expect(out).toContain("finance (id 2, active)"); }); }); describe("accountBalance formatter", () => { it("converts native balance to the human coin amount using decimals + symbol", () => { - const out = TextFormatters.accountBalance({ address: "TXaddress", balance: "1983993000", decimals: 6, symbol: "TRX" }, ctx()); + const out = TextFormatters.accountBalance( + { address: "TXaddress", balance: "1983993000", decimals: 6, symbol: "TRX" }, + ctx(), + ); expect(out).toContain("1983.993 TRX"); expect(out).not.toContain("sun"); }); it("falls back to raw scalar balance when decimals are missing", () => { - const out = TextFormatters.accountBalance({ address: "TXaddress", balance: "1983993000" }, ctx()); + const out = TextFormatters.accountBalance( + { address: "TXaddress", balance: "1983993000" }, + ctx(), + ); expect(out).toContain("1983993000"); }); it("prefers the account label over the address when present", () => { - const out = TextFormatters.accountBalance({ address: "TXaddress", balance: "1", decimals: 6, symbol: "TRX" }, ctx({ accountLabel: "main" })); + const out = TextFormatters.accountBalance( + { address: "TXaddress", balance: "1", decimals: 6, symbol: "TRX" }, + ctx({ accountLabel: "main" }), + ); expect(out).toContain("main"); expect(out).not.toContain("TXaddress"); }); @@ -125,19 +155,20 @@ describe("walletCurrent formatter", () => { expect(out).toContain("Selected account: treasury"); expect(out).toContain("█▀█\n▀▄▀"); - expect(out).toMatch( - /█▀█\n▀▄▀\nReceive address\s+TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC/, - ); + expect(out).toMatch(/█▀█\n▀▄▀\nReceive address\s+TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC/); }); }); describe("stake/chain TRX amount formatting", () => { it("groups the integer part without truncating fractional TRX", () => { - const stake = TextFormatters.stakeDelegated({ - direction: "out", - canDelegateMaxSun: { energy: "1234456789", bandwidth: "0" }, - delegations: [], - }, ctx()); + const stake = TextFormatters.stakeDelegated( + { + direction: "out", + canDelegateMaxSun: { energy: "1234456789", bandwidth: "0" }, + delegations: [], + }, + ctx(), + ); const chain = TextFormatters.chainPrices({ energy: { currentSunPerUnit: 210 }, bandwidth: { currentSunPerUnit: 1000 }, @@ -183,16 +214,31 @@ describe("stakeInfo unfreezing list", () => { describe("tokenBalance formatter", () => { it("formats balance with decimals and symbol when metadata is present", () => { - const out = TextFormatters.tokenBalance({ address: "TXaddress", token: "TR7token", balance: "1204560000", symbol: "USDT", decimals: 6 }, ctx()); + const out = TextFormatters.tokenBalance( + { + address: "TXaddress", + token: "TR7token", + balance: "1204560000", + symbol: "USDT", + decimals: 6, + }, + ctx(), + ); expect(out).toContain("1204.56"); expect(out).toContain("USDT"); }); it("falls back to raw scalar balance when metadata is missing", () => { - const out = TextFormatters.tokenBalance({ address: "TXaddress", token: "TR7token", balance: "1204560000" }, ctx()); + const out = TextFormatters.tokenBalance( + { address: "TXaddress", token: "TR7token", balance: "1204560000" }, + ctx(), + ); expect(out).toContain("1204560000"); }); it("prefers the account label over the address when present", () => { - const out = TextFormatters.tokenBalance({ address: "TXaddress", token: "t", balance: "1" }, ctx({ accountLabel: "main" })); + const out = TextFormatters.tokenBalance( + { address: "TXaddress", token: "t", balance: "1" }, + ctx({ accountLabel: "main" }), + ); expect(out).toContain("main"); expect(out).not.toContain("TXaddress"); }); @@ -201,8 +247,25 @@ describe("tokenBalance formatter", () => { describe("txReceipt formatter (typed kind, narrowed — no command-id matching)", () => { it("tx send submitted (default): pending receipt with txid + track hint, no fee/energy", () => { const out = TextFormatters.txReceipt( - { kind: "send", stage: "submitted", txId: "abc123", rawAmount: "5000000", token: "USDT", decimals: 6, to: "TrecipientAddress" }, - ctx({ net: { id: "tron:nile", family: "tron", chainId: "nile", feeModel: "tron-resource", aliases: [], capabilities: [] } }), + { + kind: "send", + stage: "submitted", + txId: "abc123", + rawAmount: "5000000", + token: "USDT", + decimals: 6, + to: "TrecipientAddress", + }, + ctx({ + net: { + id: "tron:nile", + family: "tron", + chainId: "nile", + feeModel: "tron-resource", + aliases: [], + capabilities: [], + }, + }), ); expect(out).toContain("⏳"); expect(out).toContain("Sent 5 USDT"); @@ -213,17 +276,39 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(out).not.toContain("Fee"); }); it("tx send TRC20 via --contract --raw-amount (no symbol): never mislabels as TRX", () => { - const out = TextFormatters.txReceipt({ kind: "send", stage: "submitted", txId: "t20", rawAmount: "10000", contract: "TXYZtokenContract", to: "Tdest" }); + const out = TextFormatters.txReceipt({ + kind: "send", + stage: "submitted", + txId: "t20", + rawAmount: "10000", + contract: "TXYZtokenContract", + to: "Tdest", + }); expect(out).toContain("Sent 10000 TXYZtokenContract"); expect(out).not.toContain("TRX"); }); it("tx send TRC10 via --asset-id --raw-amount (no symbol): labels by asset id, not TRX", () => { - const out = TextFormatters.txReceipt({ kind: "send", stage: "submitted", txId: "t10", rawAmount: "500000", assetId: "1005416", to: "Tdest" }); + const out = TextFormatters.txReceipt({ + kind: "send", + stage: "submitted", + txId: "t10", + rawAmount: "500000", + assetId: "1005416", + to: "Tdest", + }); expect(out).toContain("Sent 500000 asset 1005416"); expect(out).not.toContain("TRX"); }); it("tx send confirmed (--wait): success receipt with real block + fee", () => { - const out = TextFormatters.txReceipt({ kind: "send", stage: "confirmed", txId: "abc", rawAmount: "1000000", to: "Tdest", blockNumber: 66000000, feeSun: "268000" }); + const out = TextFormatters.txReceipt({ + kind: "send", + stage: "confirmed", + txId: "abc", + rawAmount: "1000000", + to: "Tdest", + blockNumber: 66000000, + feeSun: "268000", + }); expect(out).toContain("✅"); expect(out).toContain("Sent 1 TRX"); expect(out).toContain("#66,000,000"); @@ -232,15 +317,30 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("confirmed receipt preserves legitimate zero-valued chain fields", () => { const out = TextFormatters.txReceipt({ - kind: "send", stage: "confirmed", txId: "zero", - rawAmount: "0", to: "Tdest", blockNumber: 0, energyUsed: 0, feeSun: 0, + kind: "send", + stage: "confirmed", + txId: "zero", + rawAmount: "0", + to: "Tdest", + blockNumber: 0, + energyUsed: 0, + feeSun: 0, }); expect(out).toContain("#0"); expect(out).toMatch(/Energy\s+0/); expect(out).toContain("0 TRX"); }); it("contract send failed (--wait): failure receipt with reason", () => { - const out = TextFormatters.txReceipt({ kind: "contract-send", stage: "failed", txId: "abc", method: "transfer(address,uint256)", contract: "TR7contract", result: "OUT_OF_ENERGY", blockNumber: 1, failed: true }); + const out = TextFormatters.txReceipt({ + kind: "contract-send", + stage: "failed", + txId: "abc", + method: "transfer(address,uint256)", + contract: "TR7contract", + result: "OUT_OF_ENERGY", + blockNumber: 1, + failed: true, + }); expect(out).toContain("❌"); expect(out).toContain("Called transfer"); expect(out).toContain("TR7contract"); @@ -248,8 +348,22 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("contract deploy submitted: renders populated Address row", () => { const out = TextFormatters.txReceipt( - { kind: "contract-deploy", stage: "submitted", txId: "dep1", contractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" }, - ctx({ net: { id: "tron:nile", family: "tron", chainId: "nile", feeModel: "tron-resource", aliases: [], capabilities: [] } }), + { + kind: "contract-deploy", + stage: "submitted", + txId: "dep1", + contractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + }, + ctx({ + net: { + id: "tron:nile", + family: "tron", + chainId: "nile", + feeModel: "tron-resource", + aliases: [], + capabilities: [], + }, + }), ); expect(out).toContain("Contract deployed"); expect(out).toContain("Address"); @@ -257,9 +371,13 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("dry-run with an energy estimate (TRC20/contract): renders energy, never [object Object]", () => { const out = TextFormatters.txReceipt({ - kind: "send", mode: "dry-run", + kind: "send", + mode: "dry-run", fee: { feeModel: "tron-resource", energy: 29650, availableEnergy: 133440569 } as any, - tx: { txID: "deadbeef" } as any, rawAmount: "10000", contract: "TXYZtoken", to: "Tdest", + tx: { txID: "deadbeef" } as any, + rawAmount: "10000", + contract: "TXYZtoken", + to: "Tdest", } as any); expect(out).toContain("Dry run"); expect(out).not.toContain("[object Object]"); @@ -268,9 +386,13 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("dry-run energy estimate with insufficient available energy: no 'covered' note", () => { const out = TextFormatters.txReceipt({ - kind: "send", mode: "dry-run", + kind: "send", + mode: "dry-run", fee: { feeModel: "tron-resource", energy: 29650, availableEnergy: 100 } as any, - tx: { txID: "deadbeef" } as any, rawAmount: "10000", contract: "TXYZtoken", to: "Tdest", + tx: { txID: "deadbeef" } as any, + rawAmount: "10000", + contract: "TXYZtoken", + to: "Tdest", } as any); expect(out).toContain("29,650 energy"); expect(out).not.toContain("covered by staked energy"); @@ -285,10 +407,15 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" minimumFeeSun: "1100000", balanceSun: "1862126000", }; - const dryRun = (fee: unknown) => TextFormatters.txReceipt({ - kind: "account-activate", mode: "dry-run", - fee, tx: { txID: "cc0a6f68" }, address: "TEF2CvkixrkzwbreCRFCQ7sZGj9AVFAkQq", payer: "TMSgJxtPw29", - } as any) as string; + const dryRun = (fee: unknown) => + TextFormatters.txReceipt({ + kind: "account-activate", + mode: "dry-run", + fee, + tx: { txID: "cc0a6f68" }, + address: "TEF2CvkixrkzwbreCRFCQ7sZGj9AVFAkQq", + payer: "TMSgJxtPw29", + } as any) as string; it("account activate dry-run: renders the total creation fee, not [object Object]", () => { const out = dryRun(activateFee); @@ -339,17 +466,31 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" // multi-sign fee was non-zero (receiptRows pushed one row, the dry-run branch another). The QA // pass missed the duplicate because its sample fee was 0, which is falsy. const broadcastApproval = { - txId: "abc123", contractType: "TransferContract", operation: "Transfer TRX", - from: "Towner", to: "Trecipient", rawAmount: "1000000", + txId: "abc123", + contractType: "TransferContract", + operation: "Transfer TRX", + from: "Towner", + to: "Trecipient", + rawAmount: "1000000", permission: { id: 2, name: "finance", threshold: 2 }, - currentWeight: 2, missingWeight: 0, thresholdReached: true, - approved: [{ address: "TQkX", weight: 1 }, { address: "TXe4", weight: 1 }], - expiration: 1784388720000, expired: false, signatures: 2, + currentWeight: 2, + missingWeight: 0, + thresholdReached: true, + approved: [ + { address: "TQkX", weight: 1 }, + { address: "TXe4", weight: 1 }, + ], + expiration: 1784388720000, + expired: false, + signatures: 2, }; it("broadcast dry-run: projects the permission and approval block json already carries", () => { const out = TextFormatters.txReceipt({ - kind: "broadcast", mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: 1000000, + kind: "broadcast", + mode: "dry-run", + transaction: broadcastApproval, + multiSignFeeSun: 1000000, } as any) as string; expect(out).toContain("Dry run tx broadcast"); expect(out).toContain('Permission active "finance" (id 2) threshold 2'); @@ -360,7 +501,10 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" it("broadcast dry-run: identifies the transaction instead of leaving an empty Tx row", () => { const out = TextFormatters.txReceipt({ - kind: "broadcast", mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: 0, + kind: "broadcast", + mode: "dry-run", + transaction: broadcastApproval, + multiSignFeeSun: 0, } as any) as string; expect(out).toContain("abc123"); }); @@ -370,7 +514,10 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" ["zero multi-sign fee", 0, "0 TRX"], ])("broadcast dry-run: states the multi-sign fee exactly once (%s)", (_n, fee, expected) => { const out = TextFormatters.txReceipt({ - kind: "broadcast", mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: fee, + kind: "broadcast", + mode: "dry-run", + transaction: broadcastApproval, + multiSignFeeSun: fee, } as any) as string; expect(out.match(/multi-sign fee/gi) ?? []).toHaveLength(1); expect(out).toContain(expected); @@ -378,8 +525,11 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" it("broadcast submitted: keeps txid, status and the tracking hint, and does not duplicate the fee", () => { const out = TextFormatters.txReceipt({ - kind: "broadcast", stage: "submitted", txId: "abc123", - transaction: broadcastApproval, multiSignFeeSun: 1000000, + kind: "broadcast", + stage: "submitted", + txId: "abc123", + transaction: broadcastApproval, + multiSignFeeSun: 1000000, } as any) as string; expect(out).toContain("abc123"); expect(out).toContain("pending — not yet on-chain"); @@ -388,7 +538,13 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("stake freeze submitted: renders staked amount and resource", () => { - const out = TextFormatters.txReceipt({ kind: "stake-freeze", stage: "submitted", txId: "abc", amountSun: "2000000", resource: "energy" }); + const out = TextFormatters.txReceipt({ + kind: "stake-freeze", + stage: "submitted", + txId: "abc", + amountSun: "2000000", + resource: "energy", + }); expect(out).toContain("Staked"); expect(out).toContain("2 TRX"); expect(out).toContain("energy"); @@ -442,10 +598,10 @@ describe("local multisig formatters", () => { approval, }) as string; expect(out).toContain("Signature added"); - expect(out).toContain("Tsigner (weight 1)"); // signer weight on the action block + expect(out).toContain("Tsigner (weight 1)"); // signer weight on the action block expect(out).toContain('Permission active "operations" (id 2) threshold 2'); expect(out).toContain("Progress 1 / 2"); - expect(out).toContain("Approved signer"); // weight table header + expect(out).toContain("Approved signer"); // weight table header expect(out).not.toContain("local inspection"); expect(out).not.toContain("was not checked online"); }); @@ -465,11 +621,19 @@ describe("local multisig formatters", () => { it("prints the human operation name on the offline sign receipt too", () => { const out = TextFormatters.txSign({ - kind: "tx-sign", signer: "Tsigner", checked: false, hex: "aabb", + kind: "tx-sign", + signer: "Tsigner", + checked: false, + hex: "aabb", transaction: { - txId: approval.txId, contractType: "TransferContract", operation: "Transfer TRX", - rawAmount: "1000000", permissionId: 2, expiration: approval.expiration, - expired: false, signatures: 1, + txId: approval.txId, + contractType: "TransferContract", + operation: "Transfer TRX", + rawAmount: "1000000", + permissionId: 2, + expiration: approval.expiration, + expired: false, + signatures: 1, }, } as any) as string; expect(out).toContain("Transfer TRX"); @@ -541,30 +705,73 @@ describe("local multisig formatters", () => { describe("txStatus formatter (family-agnostic; command supplies `state`)", () => { it("tron: confirmed when not failed", () => { - const out = TextFormatters.txStatus({ txid: "abc", state: "confirmed", confirmed: true, failed: false, blockNumber: 123 }); + const out = TextFormatters.txStatus({ + txid: "abc", + state: "confirmed", + confirmed: true, + failed: false, + blockNumber: 123, + }); expect(out).toContain("confirmed"); expect(out).toContain("#123"); }); it("tron: failed when command flags it", () => { - const out = TextFormatters.txStatus({ txid: "abc", state: "failed", confirmed: true, failed: true, blockNumber: 1 }); + const out = TextFormatters.txStatus({ + txid: "abc", + state: "failed", + confirmed: true, + failed: true, + blockNumber: 1, + }); expect(out).toContain("failed"); }); it("pending when known but not yet confirmed", () => { - const out = TextFormatters.txStatus({ txid: "abc", state: "pending", confirmed: false, failed: false }); + const out = TextFormatters.txStatus({ + txid: "abc", + state: "pending", + confirmed: false, + failed: false, + }); expect(out).toContain("pending"); }); it("not found when the node has no record of the tx", () => { - const out = TextFormatters.txStatus({ txid: "abc", state: "not_found", confirmed: false, failed: false }); + const out = TextFormatters.txStatus({ + txid: "abc", + state: "not_found", + confirmed: false, + failed: false, + }); expect(out).toContain("not found"); }); }); describe("txInfo formatter (per-family, narrowed on ctx.net.family)", () => { it("tron: shows TRX amount, energy and fee in TRX", () => { - const out = TextFormatters.txInfo({ - txid: "abc", from: "Tfrom", to: "Tto", amount: "1.5", symbol: "TRX", - status: "SUCCESS", blockNumber: 66000000, energyUsed: 28000, feeSun: 268000, transaction: {}, info: {}, - }, ctx({ net: { id: "tron:nile", family: "tron", chainId: "nile", feeModel: "tron-resource", aliases: [], capabilities: [] } })); + const out = TextFormatters.txInfo( + { + txid: "abc", + from: "Tfrom", + to: "Tto", + amount: "1.5", + symbol: "TRX", + status: "SUCCESS", + blockNumber: 66000000, + energyUsed: 28000, + feeSun: 268000, + transaction: {}, + info: {}, + }, + ctx({ + net: { + id: "tron:nile", + family: "tron", + chainId: "nile", + feeModel: "tron-resource", + aliases: [], + capabilities: [], + }, + }), + ); expect(out).toContain("1.5 TRX"); expect(out).toContain("#66,000,000"); expect(out).toContain("28,000"); @@ -574,11 +781,15 @@ describe("txInfo formatter (per-family, narrowed on ctx.net.family)", () => { }); describe("accountInfo staking summary", () => { - const accountInfo = (amount: unknown) => TextFormatters.accountInfo({ - address: "Towner", - account: { balance: 0, frozenV2: [{ type: "ENERGY", amount }] }, - resources: {}, - }, ctx()); + const accountInfo = (amount: unknown) => + TextFormatters.accountInfo( + { + address: "Towner", + account: { balance: 0, frozenV2: [{ type: "ENERGY", amount }] }, + resources: {}, + }, + ctx(), + ); it("preserves staking amounts above Number.MAX_SAFE_INTEGER when supplied as strings", () => { expect(accountInfo("9007199254740993")).toContain("9007199254.740993 TRX"); @@ -591,13 +802,21 @@ describe("accountInfo staking summary", () => { describe("contractInfo formatter", () => { it("uses normalized methods + count", () => { - const out = TextFormatters.contractInfo({ address: "TR7c", name: "Foo", methods: ["a", "b"], functionCount: 2 }); + const out = TextFormatters.contractInfo({ + address: "TR7c", + name: "Foo", + methods: ["a", "b"], + functionCount: 2, + }); expect(out).toContain("Foo"); expect(out).toContain("Methods"); expect(out).toContain("2 (a / b)"); }); it("falls back to raw contract/info ABI shape", () => { - const out = TextFormatters.contractInfo({ address: "TR7c", contract: { name: "Bar", abi: { entrys: [{ type: "Function", name: "x" }] } } }); + const out = TextFormatters.contractInfo({ + address: "TR7c", + contract: { name: "Bar", abi: { entrys: [{ type: "Function", name: "x" }] } }, + }); expect(out).toContain("Bar"); expect(out).toContain("1 (x)"); }); @@ -605,17 +824,34 @@ describe("contractInfo formatter", () => { describe("accountHistory formatter", () => { it("renders normalized rows", () => { - const out = TextFormatters.accountHistory({ - address: "TXaddr", - records: [{ time: 1700000000000, type: "Transfer", amount: "1000000", symbol: "TRX", counterparty: "Tother", status: "ok" }], - }, ctx()); + const out = TextFormatters.accountHistory( + { + address: "TXaddr", + records: [ + { + time: 1700000000000, + type: "Transfer", + amount: "1000000", + symbol: "TRX", + counterparty: "Tother", + status: "ok", + }, + ], + }, + ctx(), + ); expect(out).toContain("Transfer"); expect(out).toContain("Tother"); }); }); describe("sign-only receipt", () => { - const base = { kind: "sign" as const, mode: "sign-only" as const, address: "TSigner", txId: "abc123" }; + const base = { + kind: "sign" as const, + mode: "sign-only" as const, + address: "TSigner", + txId: "abc123", + }; const ctx = { command: "tx sign", net: { family: "tron", id: "nile" } } as never; // The signature is the product of a signing command and has to be copied somewhere, so it must @@ -623,7 +859,10 @@ describe("sign-only receipt", () => { // useless as output. it("prints the signature in full", () => { const sig = "16a2ec10".repeat(16) + "1C"; - const out = TextFormatters.txReceipt({ ...base, signed: { txID: "abc123", signature: [sig] } }, ctx) as string; + const out = TextFormatters.txReceipt( + { ...base, signed: { txID: "abc123", signature: [sig] } }, + ctx, + ) as string; expect(out).toContain(sig); expect(out).not.toMatch(/\.\.\./); expect(out).toContain("Signature"); @@ -641,7 +880,10 @@ describe("sign-only receipt", () => { // tx sign estimates nothing, so there is no fee to report and the row is dropped entirely // rather than rendered as "unknown". it("omits the fee row when nothing was estimated", () => { - const out = TextFormatters.txReceipt({ ...base, signed: { signature: ["aa".repeat(65)] } }, ctx) as string; + const out = TextFormatters.txReceipt( + { ...base, signed: { signature: ["aa".repeat(65)] } }, + ctx, + ) as string; expect(out).not.toContain("Fee"); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/token-selector.ts b/ts/src/adapters/inbound/cli/commands/token-selector.ts index 24277b112..6cb452f21 100644 --- a/ts/src/adapters/inbound/cli/commands/token-selector.ts +++ b/ts/src/adapters/inbound/cli/commands/token-selector.ts @@ -5,8 +5,9 @@ export function tokenSelector( value: { contract?: string; assetId?: string }, context: z.RefinementCtx, ): void { - const count = [value.contract, value.assetId] - .filter((candidate) => candidate !== undefined).length; + const count = [value.contract, value.assetId].filter( + (candidate) => candidate !== undefined, + ).length; if (count !== 1) { context.addIssue({ code: "custom", diff --git a/ts/src/adapters/inbound/cli/commands/token.ts b/ts/src/adapters/inbound/cli/commands/token.ts index 7abc1d90d..783512fc7 100644 --- a/ts/src/adapters/inbound/cli/commands/token.ts +++ b/ts/src/adapters/inbound/cli/commands/token.ts @@ -6,15 +6,21 @@ import { TextFormatters } from "../render/index.js"; import { tokenSelector } from "./token-selector.js"; const selectorFields = z.object({ - contract: Schemas.addressFor("tron").optional() + contract: Schemas.addressFor("tron") + .optional() .describe("TRC20 contract address; provide exactly one of --contract or --asset-id"), - assetId: z.string().regex(/^\d+$/).optional() + assetId: z + .string() + .regex(/^\d+$/) + .optional() .describe("TRC10 numeric asset id; provide exactly one of --asset-id or --contract"), }); export const tokenBalanceSpec: ChainSpec = { path: ["token", "balance"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "account.balance.token", summary: "Show a single token balance (--contract / --asset-id)", baseFields: selectorFields, @@ -29,7 +35,9 @@ export const tokenBalanceTronBinding = (svc: TronTokenService): FamilyBinding => export const tokenInfoSpec: ChainSpec = { path: ["token", "info"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "account.balance.token", summary: "Show token metadata (name/symbol/decimals/totalSupply)", baseFields: selectorFields, @@ -44,7 +52,9 @@ export const tokenInfoTronBinding = (svc: TronTokenService): FamilyBinding => ({ export const tokenAddSpec: ChainSpec = { path: ["token", "add"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "token.tokenbook", summary: "Add a token to the address book (fetches symbol/decimals)", baseFields: selectorFields, @@ -59,7 +69,9 @@ export const tokenAddTronBinding = (svc: TronTokenService): FamilyBinding => ({ export const tokenListSpec: ChainSpec = { path: ["token", "list"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "token.tokenbook", summary: "List the address book (official + user)", baseFields: z.object({}), @@ -73,7 +85,9 @@ export const tokenListTronBinding = (svc: TronTokenService): FamilyBinding => ({ export const tokenRemoveSpec: ChainSpec = { path: ["token", "remove"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "token.tokenbook", summary: "Remove a user-added token from the address book", baseFields: selectorFields, diff --git a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts index f36980601..6b4b02319 100644 --- a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts +++ b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts @@ -88,7 +88,9 @@ describe("reference pages keep up with the shared transaction options", () => { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? pages(join(directory, entry.name)) - : entry.name.endsWith(".md") ? [join(directory, entry.name)] : [] + : entry.name.endsWith(".md") + ? [join(directory, entry.name)] + : [], ); } @@ -103,7 +105,12 @@ describe("reference pages keep up with the shared transaction options", () => { expect(key).toBeTruthy(); const behind = pages(DOCS) - .map((path) => ({ path, rows: readFileSync(path, "utf8").split("\n").filter((l) => l.startsWith("| `--permission-id")) })) + .map((path) => ({ + path, + rows: readFileSync(path, "utf8") + .split("\n") + .filter((l) => l.startsWith("| `--permission-id")), + })) .filter(({ rows }) => rows.some((row) => !row.includes(key!))) .map(({ path }) => relative(DOCS, path)); @@ -112,8 +119,15 @@ describe("reference pages keep up with the shared transaction options", () => { it("every --expiration row gives the readable cap and the omitted-case default", () => { const behind = pages(DOCS) - .map((path) => ({ path, rows: readFileSync(path, "utf8").split("\n").filter((l) => l.startsWith("| `--expiration")) })) - .filter(({ rows }) => rows.some((row) => !(row.includes("24h") && /node default.*60s/.test(row)))) + .map((path) => ({ + path, + rows: readFileSync(path, "utf8") + .split("\n") + .filter((l) => l.startsWith("| `--expiration")), + })) + .filter(({ rows }) => + rows.some((row) => !(row.includes("24h") && /node default.*60s/.test(row))), + ) .map(({ path }) => relative(DOCS, path)); expect(behind).toEqual([]); diff --git a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts index bedd21335..e98e72085 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts @@ -52,20 +52,29 @@ describe("tx sign binding", () => { return { kind: "sign" }; }, }; - await txSignTronBinding(svc as never, {} as never, {} as never, {} as never) - .run(ctx, net, { transaction: '{"txID":"abc"}' }); + await txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run(ctx, net, { + transaction: '{"txID":"abc"}', + }); expect(received).toEqual({ txID: "abc" }); }); it("rejects malformed JSON with invalid_value", async () => { const svc = { sign: async () => ({}) }; - await expect(txSignTronBinding(svc as never, {} as never, {} as never, {} as never) - .run(ctx, net, { transaction: "not json" })) - .rejects.toMatchObject({ code: "invalid_value" }); + await expect( + txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run(ctx, net, { + transaction: "not json", + }), + ).rejects.toMatchObject({ code: "invalid_value" }); }); const offlineSigner = () => ({ - sign: async () => ({ kind: "tx-sign", hex: "beef", signer: "T1", checked: false, transaction: {} }), + sign: async () => ({ + kind: "tx-sign", + hex: "beef", + signer: "T1", + checked: false, + transaction: {}, + }), }); const checkedSigner = () => ({ signChecked: async () => ({ @@ -78,36 +87,69 @@ describe("tx sign binding", () => { approval: {}, }), }); - const rejectOffline = { sign: async () => { throw new Error("unexpected offline route"); } }; - const rejectChecked = { signChecked: async () => { throw new Error("unexpected checked route"); } }; + const rejectOffline = { + sign: async () => { + throw new Error("unexpected offline route"); + }, + }; + const rejectChecked = { + signChecked: async () => { + throw new Error("unexpected checked route"); + }, + }; // Default: verify signer permission and resulting weight online (doc §3.2.1). A co-signer who is // not in the permission group, or who already signed, must fail before a signature is produced — // not silently emit a hex that only `tx broadcast` will reject, after it has been passed on. it("routes hex signing through the multisig authorization service by default", async () => { - await expect(txSignTronBinding({} as never, rejectOffline as never, checkedSigner() as never, {} as never) - .run(ctx, net, { hex: "abcd", offline: false })) - .resolves.toMatchObject({ checked: true, signerWeight: 1 }); + await expect( + txSignTronBinding( + {} as never, + rejectOffline as never, + checkedSigner() as never, + {} as never, + ).run(ctx, net, { hex: "abcd", offline: false }), + ).resolves.toMatchObject({ checked: true, signerWeight: 1 }); }); it("treats an absent --offline exactly like --offline false", async () => { - await expect(txSignTronBinding({} as never, rejectOffline as never, checkedSigner() as never, {} as never) - .run(ctx, net, { hex: "abcd" })) - .resolves.toMatchObject({ checked: true }); + await expect( + txSignTronBinding( + {} as never, + rejectOffline as never, + checkedSigner() as never, + {} as never, + ).run(ctx, net, { hex: "abcd" }), + ).resolves.toMatchObject({ checked: true }); }); it("routes --offline through the local signing service, never touching the node", async () => { - await expect(txSignTronBinding({} as never, offlineSigner() as never, rejectChecked as never, {} as never) - .run(ctx, net, { hex: "abcd", offline: true })) - .resolves.toMatchObject({ checked: false }); + await expect( + txSignTronBinding( + {} as never, + offlineSigner() as never, + rejectChecked as never, + {} as never, + ).run(ctx, net, { hex: "abcd", offline: true }), + ).resolves.toMatchObject({ checked: false }); }); it("writes --out on both routes", async () => { - for (const input of [{ hex: "abcd", out: "signed.hex" }, { hex: "abcd", out: "signed.hex", offline: true }]) { + for (const input of [ + { hex: "abcd", out: "signed.hex" }, + { hex: "abcd", out: "signed.hex", offline: true }, + ]) { let written: unknown; - const writer = { write: (path: string, hex: string) => { written = { path, hex }; } }; + const writer = { + write: (path: string, hex: string) => { + written = { path, hex }; + }, + }; const result = await txSignTronBinding( - {} as never, offlineSigner() as never, checkedSigner() as never, writer as never, + {} as never, + offlineSigner() as never, + checkedSigner() as never, + writer as never, ).run(ctx, net, input); expect(written).toEqual({ path: "signed.hex", hex: "beef" }); expect(result).toMatchObject({ out: "signed.hex", hex: "beef" }); @@ -116,9 +158,12 @@ describe("tx sign binding", () => { it("rejects --offline with the JSON payload route, which has no online check to skip", async () => { const svc = { sign: async () => ({ kind: "sign" }) }; - await expect(txSignTronBinding(svc as never, {} as never, {} as never, {} as never) - .run(ctx, net, { transaction: "{}", offline: true })) - .rejects.toMatchObject({ code: "invalid_option" }); + await expect( + txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run(ctx, net, { + transaction: "{}", + offline: true, + }), + ).rejects.toMatchObject({ code: "invalid_option" }); }); }); @@ -127,10 +172,19 @@ describe("tx sign binding", () => { // new `kind:"tx-sign"` shape appears only for --hex/--file, which did not exist in 4.10.0. describe("tx sign 4.10.0 JSON compatibility", () => { it("returns the transaction service result unwrapped and unannotated", async () => { - const legacy = { kind: "sign", mode: "sign-only", signed: { txID: "abc" }, address: "T1", txId: "abc" }; + const legacy = { + kind: "sign", + mode: "sign-only", + signed: { txID: "abc" }, + address: "T1", + txId: "abc", + }; const svc = { sign: async () => legacy }; - const result = await txSignTronBinding(svc as never, {} as never, {} as never, {} as never) - .run(ctx, net, { transaction: '{"txID":"abc"}' }); + const result = await txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run( + ctx, + net, + { transaction: '{"txID":"abc"}' }, + ); expect(result).toEqual(legacy); expect(result).not.toHaveProperty("checked"); expect(result).not.toHaveProperty("approval"); @@ -164,20 +218,22 @@ describe("tx send exclusive groups", () => { }); it("states each exclusivity once — in the group, not also in a field description", () => { - const descriptions = Object.values(txSendSpec.baseFields.shape) - .map((field) => (field as { description?: string }).description ?? ""); + const descriptions = Object.values(txSendSpec.baseFields.shape).map( + (field) => (field as { description?: string }).description ?? "", + ); expect(descriptions.filter((d) => d.includes("mutually exclusive"))).toEqual([]); }); }); describe("tx broadcast binding", () => { - const broadcastContext = (stdin?: string, wait = false) => ({ - wait, - secrets: { - has: (kind: string) => kind === "tx" && stdin !== undefined, - pick: (inline: string | undefined) => inline ?? stdin, - }, - }) as never; + const broadcastContext = (stdin?: string, wait = false) => + ({ + wait, + secrets: { + has: (kind: string) => kind === "tx" && stdin !== undefined, + pick: (inline: string | undefined) => inline ?? stdin, + }, + }) as never; it("retains JSON/stdin inputs and adds hex/file inputs", () => { expect(txBroadcastSpec.baseFields.safeParse({ transaction: "{}" }).success).toBe(true); @@ -190,10 +246,14 @@ describe("tx broadcast binding", () => { // description is a second copy that drifts the moment an input is added or renamed. it("declares every input in one exclusive group and nowhere else", () => { expect(txBroadcastSpec.exclusive).toEqual([ - { label: "the signed transaction to broadcast", flags: ["transaction", "tx-stdin", "hex", "file"] }, + { + label: "the signed transaction to broadcast", + flags: ["transaction", "tx-stdin", "hex", "file"], + }, ]); - const descriptions = Object.values(txBroadcastSpec.baseFields.shape) - .map((field) => (field as { description?: string }).description ?? ""); + const descriptions = Object.values(txBroadcastSpec.baseFields.shape).map( + (field) => (field as { description?: string }).description ?? "", + ); expect(descriptions.filter((d) => d.includes("mutually exclusive"))).toEqual([]); }); @@ -207,9 +267,12 @@ describe("tx broadcast binding", () => { throw new Error("unexpected JSON route"); }, }; - await expect(txBroadcastTronBinding(service as never) - .run(broadcastContext(), net, { hex: "aabb", dryRun: true })) - .resolves.toEqual({ hex: "aabb", dryRun: true }); + await expect( + txBroadcastTronBinding(service as never).run(broadcastContext(), net, { + hex: "aabb", + dryRun: true, + }), + ).resolves.toEqual({ hex: "aabb", dryRun: true }); }); it("routes the retained --tx-stdin JSON source", async () => { @@ -223,27 +286,39 @@ describe("tx broadcast binding", () => { return { txId: "abc" }; }, }; - await txBroadcastTronBinding(service as never) - .run(broadcastContext('{"txID":"abc"}'), net, { dryRun: false }); + await txBroadcastTronBinding(service as never).run(broadcastContext('{"txID":"abc"}'), net, { + dryRun: false, + }); expect(received).toEqual({ txID: "abc" }); }); it("rejects ambiguous input and --wait with --dry-run", async () => { const service = { broadcastHex: async () => ({}), broadcastJson: async () => ({}) }; - await expect(txBroadcastTronBinding(service as never) - .run(broadcastContext(), net, { transaction: "{}", hex: "aabb", dryRun: false })) - .rejects.toMatchObject({ code: "invalid_option" }); - await expect(txBroadcastTronBinding(service as never) - .run(broadcastContext(undefined, true), net, { hex: "aabb", dryRun: true })) - .rejects.toMatchObject({ code: "invalid_option" }); + await expect( + txBroadcastTronBinding(service as never).run(broadcastContext(), net, { + transaction: "{}", + hex: "aabb", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "invalid_option" }); + await expect( + txBroadcastTronBinding(service as never).run(broadcastContext(undefined, true), net, { + hex: "aabb", + dryRun: true, + }), + ).rejects.toMatchObject({ code: "invalid_option" }); }); }); describe("tx multisig spec", () => { it("supports list, unsigned create, sign, and WebSocket watch modes", () => { expect(txTronLinkMultisigSpec.baseFields.safeParse({}).success).toBe(true); - expect(txTronLinkMultisigSpec.baseFields.safeParse({ create: true, hex: "aabb" }).success).toBe(true); - expect(txTronLinkMultisigSpec.baseFields.safeParse({ sign: "ab".repeat(32) }).success).toBe(true); + expect(txTronLinkMultisigSpec.baseFields.safeParse({ create: true, hex: "aabb" }).success).toBe( + true, + ); + expect(txTronLinkMultisigSpec.baseFields.safeParse({ sign: "ab".repeat(32) }).success).toBe( + true, + ); expect(txTronLinkMultisigSpec.baseFields.safeParse({ watch: true }).success).toBe(true); }); diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index c49aff550..2c07320dc 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -7,26 +7,30 @@ import type { TronMultisigService } from "../../../../application/use-cases/tron import type { TronMultisigCollaborationService } from "../../../../application/use-cases/tron/multisig-collaboration-service.js"; import type { TransactionArtifactWriter } from "../../../../application/ports/transaction-artifact-writer.js"; import { Schemas } from "../schemas/index.js"; -import { - amountSelector, - txModeFields, - unifiedAmountFields, -} from "./shared.js"; +import { amountSelector, txModeFields, unifiedAmountFields } from "./shared.js"; import { TextFormatters } from "../render/index.js"; import { exactlyOne, readBoundedTextFile } from "./artifact.js"; // baseFields today (single family). When EVM lands, move feeLimit/assetId/contract into the TRON // binding.fields and put gasPrice/gasLimit/nonce into the EVM binding.fields (spec §4 base/delta). const sendFields = z.object({ - to: z.string().trim().min(1).max(128) + to: z + .string() + .trim() + .min(1) + .max(128) .describe("recipient TRON base58 address or local contact name"), - token: z.string().min(1).optional() - .describe("token symbol from the address book"), - contract: Schemas.addressFor("tron").optional() + token: z.string().min(1).optional().describe("token symbol from the address book"), + contract: Schemas.addressFor("tron") + .optional() .describe("TRC20 contract address; omit with --asset-id for native TRX"), - assetId: z.string().regex(/^\d+$/).optional() + assetId: z + .string() + .regex(/^\d+$/) + .optional() .describe("TRC10 numeric asset id; omit with --contract for native TRX"), - feeLimit: Schemas.positiveIntString().default("100000000") + feeLimit: Schemas.positiveIntString() + .default("100000000") .describe("maximum TRX energy fee to burn for TRC20 transfers, in SUN"), ...unifiedAmountFields( "human amount: TRX for native, token units for TRC20/TRC10", @@ -37,7 +41,9 @@ const sendFields = z.object({ export const txSendSpec: ChainSpec = { path: ["tx", "send"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "tx.send", summary: "Send native TRX or TRC20/TRC10 tokens with human --amount", @@ -45,7 +51,11 @@ export const txSendSpec: ChainSpec = { exclusive: [ { label: "the amount to send", flags: ["amount", "raw-amount"], select: "exactly-one" }, // omitting all three is the native-TRX path, so this set is optional as a whole. - { label: "which asset to send; omit for native TRX", flags: ["token", "contract", "asset-id"], select: "at-most-one" }, + { + label: "which asset to send; omit for native TRX", + flags: ["token", "contract", "asset-id"], + select: "at-most-one", + }, ], baseRefine: amountSelector, examples: [ @@ -65,22 +75,39 @@ export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => const broadcastFields = z.object({ transaction: z.string().optional().describe("signed TRON transaction JSON"), hex: z.string().min(2).optional().describe("complete signed protocol.Transaction hex"), - file: z.string().min(1).optional().describe("file containing complete signed protocol.Transaction hex"), - dryRun: z.boolean().default(false) - .describe("validate signatures, threshold, expiration, and dynamic multi-sign fee without broadcasting"), + file: z + .string() + .min(1) + .optional() + .describe("file containing complete signed protocol.Transaction hex"), + dryRun: z + .boolean() + .default(false) + .describe( + "validate signatures, threshold, expiration, and dynamic multi-sign fee without broadcasting", + ), }); export const txBroadcastSpec: ChainSpec = { path: ["tx", "broadcast"], stdin: "tx", - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", broadcasts: true, capability: "tx.broadcast", summary: "Validate and broadcast a presigned JSON or protobuf-hex transaction", baseFields: broadcastFields, - exclusive: [{ label: "the signed transaction to broadcast", flags: ["transaction", "tx-stdin", "hex", "file"] }], + exclusive: [ + { + label: "the signed transaction to broadcast", + flags: ["transaction", "tx-stdin", "hex", "file"], + }, + ], baseRefine: (input, context) => { - if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length > 1) { + if ( + [input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length > 1 + ) { context.addIssue({ code: "custom", path: ["transaction"], @@ -106,7 +133,8 @@ export const txBroadcastTronBinding = (service: TronMultisigService): FamilyBind "provide exactly one of --transaction, --tx-stdin, --hex, or --file", ); if (input.hex || input.file) { - const hex = input.hex ?? readBoundedTextFile(input.file, 1024 * 1024 + 4096, "transaction hex file"); + const hex = + input.hex ?? readBoundedTextFile(input.file, 1024 * 1024 + 4096, "transaction hex file"); return service.broadcastHex(ctx, net, hex, input.dryRun); } const raw = ctx.secrets.pick(input.transaction, "tx", "transaction"); @@ -130,10 +158,13 @@ const approvalsFields = z.object(artifactFields); export const txApprovalsSpec: ChainSpec = { path: ["tx", "approvals"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "tx.multisig.local", summary: "Show permission, signature approvals, current weight, and expiration", - description: "Inspect the transaction, selected permission group, approved signers, accumulated weight, missing weight, and expiration without signing.", + description: + "Inspect the transaction, selected permission group, approved signers, accumulated weight, missing weight, and expiration without signing.", baseFields: approvalsFields, exclusive: [{ label: "the transaction to inspect", flags: ["hex", "file"] }], baseRefine: hexOrFileRefine, @@ -146,17 +177,30 @@ export const txApprovalsTronBinding = (service: TronMultisigService): FamilyBind }); const signFields = z.object({ - transaction: z.string().min(1).optional() + transaction: z + .string() + .min(1) + .optional() .describe("unsigned TRON transaction JSON; retained for direct single-signature compatibility"), ...artifactFields, - offline: z.boolean().default(false) - .describe("sign locally without contacting a node; skips the signer-permission and approval-weight checks"), - out: z.string().min(1).optional().describe("atomically write co-signed transaction hex to this file"), + offline: z + .boolean() + .default(false) + .describe( + "sign locally without contacting a node; skips the signer-permission and approval-weight checks", + ), + out: z + .string() + .min(1) + .optional() + .describe("atomically write co-signed transaction hex to this file"), }); export const txSignSpec: ChainSpec = { path: ["tx", "sign"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", + wallet: "optional", + auth: "required", broadcasts: false, capability: "tx.sign", summary: "Sign transaction JSON or append a signature to transaction hex", @@ -170,7 +214,9 @@ export const txSignSpec: ChainSpec = { // --hex/--file first: --transaction is the compatibility path, not the co-signing one. exclusive: [{ label: "the transaction to co-sign", flags: ["hex", "file", "transaction"] }], baseRefine: (input, context) => { - if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1) { + if ( + [input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1 + ) { context.addIssue({ code: "custom", path: ["transaction"], @@ -178,14 +224,24 @@ export const txSignSpec: ChainSpec = { }); } if (input.out && input.transaction) { - context.addIssue({ code: "custom", path: ["out"], message: "--out is only valid with --hex or --file" }); + context.addIssue({ + code: "custom", + path: ["out"], + message: "--out is only valid with --hex or --file", + }); } if (input.offline && input.transaction) { - context.addIssue({ code: "custom", path: ["offline"], message: "--offline is only valid with --hex or --file" }); + context.addIssue({ + code: "custom", + path: ["offline"], + message: "--offline is only valid with --hex or --file", + }); } }, examples: [ - { cmd: `wallet-cli tx sign --transaction '{"txID":"...","raw_data":{...},"raw_data_hex":"..."}'` }, + { + cmd: `wallet-cli tx sign --transaction '{"txID":"...","raw_data":{...},"raw_data_hex":"..."}'`, + }, { cmd: "wallet-cli tx sign --file partially-signed.hex --out signed.hex --password-stdin" }, { cmd: "wallet-cli tx sign --file partially-signed.hex --offline --password-stdin" }, ], @@ -199,7 +255,10 @@ export const txSignTronBinding = ( writer: TransactionArtifactWriter, ): FamilyBinding => ({ run: async (ctx, net, input) => { - exactlyOne([input.transaction, input.hex, input.file], "provide exactly one of --transaction, --hex, or --file"); + exactlyOne( + [input.transaction, input.hex, input.file], + "provide exactly one of --transaction, --hex, or --file", + ); if (!input.transaction) { const hex = hexInput(input); const result = input.offline @@ -209,8 +268,10 @@ export const txSignTronBinding = ( writer.write(input.out, result.hex); return { ...result, out: input.out }; } - if (input.out) throw new UsageError("invalid_option", "--out is only valid with --hex or --file"); - if (input.offline) throw new UsageError("invalid_option", "--offline is only valid with --hex or --file"); + if (input.out) + throw new UsageError("invalid_option", "--out is only valid with --hex or --file"); + if (input.offline) + throw new UsageError("invalid_option", "--offline is only valid with --hex or --file"); let tx: unknown; try { tx = JSON.parse(input.transaction); @@ -222,19 +283,38 @@ export const txSignTronBinding = ( }); const tronLinkMultisigFields = z.object({ - create: z.boolean().default(false) + create: z + .boolean() + .default(false) .describe("sign one unsigned transaction and open a TronLink signature collection with it"), - hex: z.string().min(2).optional().describe("unsigned protocol.Transaction hex used with --create"), - file: z.string().min(1).optional().describe("file containing unsigned transaction hex used with --create"), - sign: z.string().regex(/^(?:0x)?[0-9a-fA-F]{64}$/).optional() + hex: z + .string() + .min(2) + .optional() + .describe("unsigned protocol.Transaction hex used with --create"), + file: z + .string() + .min(1) + .optional() + .describe("file containing unsigned transaction hex used with --create"), + sign: z + .string() + .regex(/^(?:0x)?[0-9a-fA-F]{64}$/) + .optional() .describe("fetch and co-sign one pending TronLink transaction by txId"), - watch: z.boolean().default(false) - .describe("keep a WebSocket open and report only the count of transactions awaiting this account"), + watch: z + .boolean() + .default(false) + .describe( + "keep a WebSocket open and report only the count of transactions awaiting this account", + ), }); export const txTronLinkMultisigSpec: ChainSpec = { path: ["tx", "multisig"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", capability: "tx.multisig.tronlink", summary: "Coordinate multi-signature collection through the TronLink service", description: @@ -246,7 +326,13 @@ export const txTronLinkMultisigSpec: ChainSpec = { "TronLink service credentials — config tronlinkSecretId / tronlinkSecretKey / tronlinkChannel", ], baseFields: tronLinkMultisigFields, - exclusive: [{ label: "which mode to run; omit all three to list", flags: ["create", "sign", "watch"], select: "at-most-one" }], + exclusive: [ + { + label: "which mode to run; omit all three to list", + flags: ["create", "sign", "watch"], + select: "at-most-one", + }, + ], baseRefine: tronLinkMultisigRefine, examples: [ { cmd: "wallet-cli tx multisig" }, @@ -257,7 +343,9 @@ export const txTronLinkMultisigSpec: ChainSpec = { formatText: TextFormatters.txTronLinkMultisig, }; -export const txTronLinkMultisigBinding = (service: TronMultisigCollaborationService): FamilyBinding => ({ +export const txTronLinkMultisigBinding = ( + service: TronMultisigCollaborationService, +): FamilyBinding => ({ run: async (ctx, network, input) => { const address = ctx.resolveAddress("tron"); if (input.create) return service.create(ctx, network, hexInput(input)); @@ -286,7 +374,9 @@ const statusFields = z.object({ txid: z.string().min(1).describe("TRON transacti export const txStatusSpec: ChainSpec = { path: ["tx", "status"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", summary: "Show confirmation status of a transaction", baseFields: statusFields, examples: [{ cmd: "wallet-cli tx status --txid abc123" }], @@ -301,7 +391,9 @@ const infoFields = z.object({ txid: z.string().min(1).describe("TRON transaction export const txInfoSpec: ChainSpec = { path: ["tx", "info"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", summary: "Show full transaction detail + receipt", baseFields: infoFields, examples: [{ cmd: "wallet-cli tx info --txid abc123" }], @@ -316,8 +408,9 @@ function tokenOptional( value: { token?: string; contract?: string; assetId?: string }, context: z.RefinementCtx, ): void { - const count = [value.token, value.contract, value.assetId] - .filter((candidate) => candidate !== undefined).length; + const count = [value.token, value.contract, value.assetId].filter( + (candidate) => candidate !== undefined, + ).length; if (count > 1) { context.addIssue({ code: "custom", @@ -329,7 +422,11 @@ function tokenOptional( function hexOrFileRefine(value: { hex?: string; file?: string }, context: z.RefinementCtx): void { if ([value.hex, value.file].filter((entry) => entry !== undefined).length !== 1) { - context.addIssue({ code: "custom", path: ["hex"], message: "provide exactly one of --hex or --file" }); + context.addIssue({ + code: "custom", + path: ["hex"], + message: "provide exactly one of --hex or --file", + }); } } diff --git a/ts/src/adapters/inbound/cli/commands/typed-data.test.ts b/ts/src/adapters/inbound/cli/commands/typed-data.test.ts index 1ea139fb2..5f9f5bc1e 100644 --- a/ts/src/adapters/inbound/cli/commands/typed-data.test.ts +++ b/ts/src/adapters/inbound/cli/commands/typed-data.test.ts @@ -6,7 +6,10 @@ const net = { family: "tron", id: "nile", chainId: "728126428" } as never; const PAYLOAD = JSON.stringify({ domain: { name: "SunPerp", version: "1", chainId: 728126428 }, - types: { EIP712Domain: [{ name: "name", type: "string" }], Order: [{ name: "size", type: "uint256" }] }, + types: { + EIP712Domain: [{ name: "name", type: "string" }], + Order: [{ name: "size", type: "uint256" }], + }, message: { size: "1" }, }); @@ -47,8 +50,9 @@ describe("typed-data sign binding", () => { it("rejects malformed JSON with invalid_value", async () => { const svc = { sign: async () => stubResult }; - await expect(typedDataSignBinding(svc as never).run(ctx, net, { typedData: "nope" })) - .rejects.toMatchObject({ code: "invalid_value" }); + await expect( + typedDataSignBinding(svc as never).run(ctx, net, { typedData: "nope" }), + ).rejects.toMatchObject({ code: "invalid_value" }); }); it("rejects a structurally invalid payload with invalid_value", async () => { diff --git a/ts/src/adapters/inbound/cli/commands/typed-data.ts b/ts/src/adapters/inbound/cli/commands/typed-data.ts index 421f932b4..3aa001c05 100644 --- a/ts/src/adapters/inbound/cli/commands/typed-data.ts +++ b/ts/src/adapters/inbound/cli/commands/typed-data.ts @@ -6,13 +6,17 @@ import type { TypedDataService } from "../../../../application/use-cases/typed-d import { TextFormatters } from "../render/index.js"; const typedDataFields = z.object({ - typedData: z.string().min(1) + typedData: z + .string() + .min(1) .describe(`EIP-712/TIP-712 JSON: {"domain":…,"types":…,"primaryType"?:…,"message":…}`), }); export const typedDataSignSpec: ChainSpec = { path: ["typed-data", "sign"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", + wallet: "optional", + auth: "required", broadcasts: false, capability: "typedData.sign", summary: "Sign EIP-712 / TIP-712 structured data", @@ -22,7 +26,9 @@ export const typedDataSignSpec: ChainSpec = { "addresses work in address fields.", baseFields: typedDataFields, examples: [ - { cmd: `wallet-cli typed-data sign --typed-data '{"domain":{...},"types":{...},"message":{...}}'` }, + { + cmd: `wallet-cli typed-data sign --typed-data '{"domain":{...},"types":{...},"message":{...}}'`, + }, ], formatText: TextFormatters.typedDataSign, }; diff --git a/ts/src/adapters/inbound/cli/commands/vote.ts b/ts/src/adapters/inbound/cli/commands/vote.ts index deb172938..c07476d73 100644 --- a/ts/src/adapters/inbound/cli/commands/vote.ts +++ b/ts/src/adapters/inbound/cli/commands/vote.ts @@ -6,12 +6,19 @@ import { TextFormatters } from "../render/index.js"; // repeatable flag: the arity layer sets yargs `array: true`, so `--for` always arrives as a // string[] (single or repeated) — no preprocess needed to normalize. -const voteForField = z.array(z.string().min(1)).min(1).max(30) - .describe("witness address = vote count (positive integer); repeatable; the set replaces all prior votes (at least 1, at most 30 entries)"); +const voteForField = z + .array(z.string().min(1)) + .min(1) + .max(30) + .describe( + "witness address = vote count (positive integer); repeatable; the set replaces all prior votes (at least 1, at most 30 entries)", + ); export const voteCastSpec: ChainSpec = { path: ["vote", "cast"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "vote.cast", summary: "Cast or replace your full SR vote allocation", @@ -33,15 +40,21 @@ export const voteCastTronBinding = (svc: TronVoteService): FamilyBinding => ({ export const voteListSpec: ChainSpec = { path: ["vote", "list"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "vote.list", summary: "List super representatives and candidates", description: "List super representatives (elected by default) with votes, APR, and reward ratio.", baseFields: z.object({ - limit: z.coerce.number().int().positive().max(127).default(27) + limit: z.coerce + .number() + .int() + .positive() + .max(127) + .default(27) .describe("number of ranks to return; max 127"), - candidates: z.boolean().default(false) - .describe("include non-elected candidates"), + candidates: z.boolean().default(false).describe("include non-elected candidates"), }), examples: [ { cmd: "wallet-cli vote list" }, @@ -56,7 +69,9 @@ export const voteListTronBinding = (svc: TronVoteService): FamilyBinding => ({ export const voteStatusSpec: ChainSpec = { path: ["vote", "status"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "vote.status", summary: "Show current votes, voting power, and reward overview", description: diff --git a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts index 41f499d5d..f9d7919f0 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts @@ -23,8 +23,9 @@ import { registerWalletCommands } from "./wallet.js"; import type { SessionRef } from "../contracts/index.js"; // Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. -vi.mock("@noble/hashes/scrypt.js", async () => - import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), +vi.mock( + "@noble/hashes/scrypt.js", + async () => import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), ); const VALID_MNEMONIC = "test test test test test test test test test test test junk"; @@ -37,8 +38,12 @@ function fixture(opts: { tty: boolean }) { const streams = new StreamManager("text", false); const prompter = new Prompter({ isTTY: () => opts.tty, - async question(_prompt: string, _hidden: boolean) { return VALID_PASSWORD; }, - async readKey() { return { name: "return" }; }, + async question(_prompt: string, _hidden: boolean) { + return VALID_PASSWORD; + }, + async readKey() { + return { name: "return" }; + }, write() {}, beginRaw() {}, endRaw() {}, @@ -82,8 +87,9 @@ describe("backup password gating", () => { address: LEDGER_ADDRESS, }); - await expect(buildCli(shellOpts).parseAsync(["backup", accountId])) - .rejects.toMatchObject({ code: "not_exportable" }); + await expect(buildCli(shellOpts).parseAsync(["backup", accountId])).rejects.toMatchObject({ + code: "not_exportable", + }); expect(spyPrime).not.toHaveBeenCalled(); }); @@ -107,12 +113,18 @@ describe("backup password gating", () => { * 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"]]) { + 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" }); + await expect( + buildCli(shellOpts).parseAsync(["backup", "main", ...args]), + ).rejects.toMatchObject({ code: "invalid_value" }); expect(spyPrime).not.toHaveBeenCalled(); }); } diff --git a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts index 29089216b..9de68246f 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts @@ -14,18 +14,18 @@ const descriptor = { addresses: { tron: ADDRESS }, } satisfies AccountDescriptor; -function command(options: { - output?: "text" | "json"; - encoded?: string | null; - account?: string; -} = {}) { +function command( + options: { + output?: "text" | "json"; + encoded?: string | null; + account?: string; + } = {}, +) { const walletService = { current: vi.fn(() => descriptor), }; const qr = { - encode: vi.fn(() => - options.encoded === undefined ? "QR-MATRIX" : options.encoded - ), + encode: vi.fn(() => (options.encoded === undefined ? "QR-MATRIX" : options.encoded)), }; const registry = new CommandRegistry(); registerWalletCommands(registry, { @@ -48,15 +48,9 @@ function command(options: { describe("current --qr", () => { it("encodes exactly the selected account's TRON address in text mode", async () => { const fixture = command({ account: "wlt_selected", encoded: "QR" }); - const result = await fixture.current.run( - fixture.context as never, - undefined, - { qr: true }, - ); + const result = await fixture.current.run(fixture.context as never, undefined, { qr: true }); - expect(fixture.walletService.current).toHaveBeenCalledWith( - "wlt_selected", - ); + expect(fixture.walletService.current).toHaveBeenCalledWith("wlt_selected"); expect(fixture.qr.encode).toHaveBeenCalledWith(ADDRESS); expect(result).toMatchObject({ receiveQr: "QR", @@ -66,11 +60,7 @@ describe("current --qr", () => { it("keeps JSON data unchanged and never builds terminal art", async () => { const fixture = command({ output: "json" }); - const result = await fixture.current.run( - fixture.context as never, - undefined, - { qr: true }, - ); + const result = await fixture.current.run(fixture.context as never, undefined, { qr: true }); expect(result).toEqual(descriptor); expect(fixture.qr.encode).not.toHaveBeenCalled(); @@ -78,15 +68,9 @@ describe("current --qr", () => { it("warns and returns the full normal descriptor on a narrow terminal", async () => { const fixture = command({ encoded: null }); - const result = await fixture.current.run( - fixture.context as never, - undefined, - { qr: true }, - ); + const result = await fixture.current.run(fixture.context as never, undefined, { qr: true }); expect(result).toEqual(descriptor); - expect(fixture.context.warn).toHaveBeenCalledWith( - expect.stringContaining("too narrow"), - ); + expect(fixture.context.warn).toHaveBeenCalledWith(expect.stringContaining("too narrow")); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts index 44b5d2067..706004393 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts @@ -25,8 +25,9 @@ import { registerWalletCommands } from "./wallet.js"; import type { SessionRef } from "../contracts/index.js"; // Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. -vi.mock("@noble/hashes/scrypt.js", async () => - import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), +vi.mock( + "@noble/hashes/scrypt.js", + async () => import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), ); const VALID_MNEMONIC = "test test test test test test test test test test test junk"; @@ -57,7 +58,9 @@ function fixture(opts: { tty: boolean; records?: BackupRecord[] }) { // 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" }; }, + async readKey() { + return { name: "return" }; + }, write() {}, beginRaw() {}, endRaw() {}, @@ -85,7 +88,7 @@ function fixture(opts: { tty: boolean; records?: BackupRecord[] }) { return { out, fileMode: "0600" as const, bytes: 491 }; }, }, - { append: () => {}, list: () => (opts.records ?? []) }, + { append: () => {}, list: () => opts.records ?? [] }, ), ledger: {} as any, } as any); @@ -106,7 +109,11 @@ function fixture(opts: { tty: boolean; records?: BackupRecord[] }) { } /** 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") { +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(); @@ -124,7 +131,12 @@ describe("backup --keystore", () => { const env = f.envelope(); expect(env.command).toBe("backup"); - expect(env.data).toMatchObject({ accountId, format: "keystore", secretType: "privateKey", fileMode: "0600" }); + expect(env.data).toMatchObject({ + accountId, + format: "keystore", + secretType: "privateKey", + fileMode: "0600", + }); expect(KeystoreV3.decrypt(f.writes[0]!.payload, VALID_PASSWORD)).toHaveLength(32); }); @@ -139,7 +151,13 @@ describe("backup --keystore", () => { 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"]); + await buildCli(f.shellOpts).parseAsync([ + "backup", + accountId, + "--keystore", + "--out", + "./main.keystore.json", + ]); expect(f.envelope().data.out).toBe("./main.keystore.json"); }); }); @@ -169,7 +187,10 @@ describe("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" })] }); + 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"]); @@ -179,15 +200,21 @@ describe("backup --records", () => { 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" }); + 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" }); + await expect( + buildCli(f.shellOpts).parseAsync(["backup", "main", "--from", "2026-08-01"]), + ).rejects.toMatchObject({ code: "invalid_value" }); }); it.each([ @@ -197,8 +224,9 @@ describe("backup --records", () => { ["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" }); + await expect( + buildCli(f.shellOpts).parseAsync(["backup", "--records", "--from", value]), + ).rejects.toMatchObject({ code: "invalid_value" }); }); it("accepts both accepted time spellings", async () => { @@ -211,14 +239,26 @@ describe("backup --records", () => { 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" }); + 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) { + 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)}`))); + writeFileSync( + path, + JSON.stringify( + KeystoreV3.encrypt(Buffer.from(keyHex, "hex"), password, `41${"00".repeat(20)}`), + ), + ); return path; } @@ -230,7 +270,13 @@ describe("import keystore", () => { const env = f.envelope(); expect(env.command).toBe("import.keystore"); - expect(env.data).toMatchObject({ status: "created", label: "imported", type: "privateKey", index: null, active: true }); + 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 () => { @@ -242,35 +288,44 @@ describe("import keystore", () => { 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" }); + 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" }); + 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" }); + 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)}`); + 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" }); + 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" }); + 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 be5fcac6c..394f65fc7 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.test.ts @@ -1,8 +1,7 @@ -import { describe, it, expect , vi } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { z } from "zod"; import { Keystore } from "../../../outbound/keystore/index.js"; import { AtomicFileStore } from "../../../outbound/persistence/fs/index.js"; import { SecretResolver } from "../input/secret/index.js"; @@ -11,17 +10,17 @@ import { Prompter } from "../input/prompt/index.js"; import { ConfigLoader, NetworkRegistry } from "../../../outbound/config/index.js"; import { buildExecutionContext, RuntimeDeps } from "../context/index.js"; import { createOutputFormatter } from "../output/index.js"; -import { registerWalletCommands, walletImportLedgerFields, walletImportLedgerInput } from "./wallet.js"; +import { registerWalletCommands } from "./wallet.js"; import { CommandRegistry } from "../registry/index.js"; import { commandId } from "../command-id.js"; import { isChainCommand } from "../contracts/index.js"; import type { CommandDefinition, Globals } from "../contracts/index.js"; -import { Derivation } from "../../../../domain/derivation/index.js"; import { WalletService } from "../../../../application/use-cases/wallet-service.js"; // Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. -vi.mock("@noble/hashes/scrypt.js", async () => - import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), +vi.mock( + "@noble/hashes/scrypt.js", + async () => import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), ); // ── test constants ───────────────────────────────────────────────────────────── @@ -39,7 +38,7 @@ interface FakePromptOpts { } function makeFakeBackend(opts: FakePromptOpts = {}): ConstructorParameters[0] { - const { tty = true, hiddenAnswers = [], confirmResult = true, confirmAnswer } = opts; + const { tty = true, hiddenAnswers = [], confirmAnswer } = opts; let hiddenIdx = 0; return { @@ -51,7 +50,9 @@ function makeFakeBackend(opts: FakePromptOpts = {}): ConstructorParameters