From 9731d11825be7efeb7d94a396563504764df2948 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Fri, 7 Aug 2026 16:07:48 +0800 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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"),