Skip to content

Commit 5cd44d3

Browse files
authored
Merge pull request #957 from tronprotocol/feat/ts-signing-commands
feat(ts): add tx sign and typed-data sign commands
2 parents cb62dbe + b5d69f8 commit 5cd44d3

57 files changed

Lines changed: 1954 additions & 95 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ts/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The agent-first implementation of wallet-cli, built for automation: every comman
77
- **Agent-first** — stable JSON output, deterministic exit codes, and discoverable schemas, built for scripts, CI, and AI agents (details in [The contract, in one paragraph](#the-contract-in-one-paragraph)).
88
- **Encrypted local storage** — software keystores are encrypted on disk; secrets are never passed via argv or environment variables.
99
- **Software and Ledger signing** — sign in software, or on a Ledger device (the private key never leaves the device).
10-
- **Covers the main TRON capabilities** — HD wallets, TRX and TRC20/TRC10 transfers, staking / resource delegation, voting / rewards, smart-contract calls and deployment, message signing, and on-chain queries.
10+
- **Covers the main TRON capabilities** — HD wallets, TRX and TRC20/TRC10 transfers, staking / resource delegation, voting / rewards, smart-contract calls and deployment, message and EIP-712/TIP-712 signing, and on-chain queries.
1111

1212
## Supported chains
1313

@@ -112,6 +112,7 @@ Every command — including every subcommand — has a reference page; run `wall
112112
| Command | Description |
113113
|---|---|
114114
| [`tx send`](docs/commands/tx/send.md) | Send native TRX or TRC20/TRC10 tokens |
115+
| [`tx sign`](docs/commands/tx/sign.md) | Sign a transaction built elsewhere, without broadcasting |
115116
| [`tx broadcast`](docs/commands/tx/broadcast.md) | Broadcast a presigned transaction |
116117
| [`tx status`](docs/commands/tx/status.md) | Show confirmation status (confirmed / failed / pending / not_found) |
117118
| [`tx info`](docs/commands/tx/info.md) | Show full transaction detail + receipt |
@@ -139,6 +140,7 @@ Every command — including every subcommand — has a reference page; run `wall
139140
| [`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)) |
140141
| [`reward`](docs/commands/reward/index.md) | Query / withdraw voting rewards ([balance](docs/commands/reward/balance.md) · [withdraw](docs/commands/reward/withdraw.md)) |
141142
| [`message`](docs/commands/message/index.md) | Sign arbitrary messages ([sign](docs/commands/message/sign.md)) |
143+
| [`typed-data`](docs/commands/typed-data/index.md) | Sign EIP-712 / TIP-712 structured data ([sign](docs/commands/typed-data/sign.md)) |
142144

143145
### Local configuration
144146

ts/docs/commands/contract/send.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkA
101101
| default (submit) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract` |
102102
| `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `feeSun`, `energyUsed`, `result` (`SUCCESS` / `OUT_OF_ENERGY`, etc.), `failed` |
103103
| `--dry-run` | `kind`, `mode: "dry-run"`, `fee` (`feeModel`, estimated `energy`, `availableEnergy`), unsigned `tx` |
104-
| `--sign-only` | `kind`, `mode: "sign-only"`, `signed` (feed to `tx broadcast`), `fee`, `method`, `contract` |
104+
| `--sign-only` | `kind`, `mode: "sign-only"`, `signed` (feed to `tx broadcast`), `address` (signer), `txId`, `fee`, `method`, `contract` |
105105

106106
## Exit status
107107

ts/docs/commands/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Every command — including every subcommand — has its own page, following a f
2626
|---|---|
2727
| `tx` (group) | [tx/index.md](tx/index.md) |
2828
| `tx send` | [tx/send.md](tx/send.md) |
29+
| `tx sign` | [tx/sign.md](tx/sign.md) |
2930
| `tx broadcast` | [tx/broadcast.md](tx/broadcast.md) |
3031
| `tx status` | [tx/status.md](tx/status.md) |
3132
| `tx info` | [tx/info.md](tx/info.md) |
@@ -88,6 +89,8 @@ Every command — including every subcommand — has its own page, following a f
8889
|---|---|
8990
| `message` (group) | [message/index.md](message/index.md) |
9091
| `message sign` | [message/sign.md](message/sign.md) |
92+
| `typed-data` (group) | [typed-data/index.md](typed-data/index.md) |
93+
| `typed-data sign` | [typed-data/sign.md](typed-data/sign.md) |
9194

9295
## Local
9396

ts/docs/commands/tx/send.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ printf '%s' "$PW" | wallet-cli tx send --to TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH -
8888
| default (submit) | `kind: "send"`, `stage: "submitted"`, `txId`, `rawAmount` (string), `to` |
8989
| `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `netUsed` (bandwidth used) or `feeSun` (fee burned), `failed` |
9090
| `--dry-run` | `kind`, `mode: "dry-run"`, `fee` (`feeModel`, e.g. `bandwidthBurnSunIfNoFreeze`), unsigned `tx` (TRON tx object incl. `txID`, `raw_data`), `rawAmount`, `to` |
91-
| `--sign-only` | `kind`, `mode: "sign-only"`, `signed` (full signed TRON tx incl. `signature[]` — feed to `tx broadcast`), `fee`, `rawAmount`, `to` |
91+
| `--sign-only` | `kind`, `mode: "sign-only"`, `signed` (full signed TRON tx incl. `signature[]` — feed to `tx broadcast`), `address` (signer), `txId`, `fee`, `rawAmount`, `to` |
9292

9393
## Exit status
9494

ts/docs/commands/tx/sign.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# wallet-cli tx sign
2+
3+
Sign a transaction built elsewhere, without broadcasting.
4+
5+
## Synopsis
6+
7+
```
8+
wallet-cli tx sign --transaction <json> [options]
9+
```
10+
11+
## Description
12+
13+
Signs a transaction that was constructed outside this CLI with the active account's key (or
14+
`--account`) and prints the signed result. Nothing is broadcast — pass the signed payload to
15+
[`tx broadcast`](broadcast.md) when you are ready. `--network` is optional: signing itself is
16+
offline for software accounts.
17+
18+
This is a **pure signer**. It does not check who owns the transaction, what contract it calls, or
19+
how much it moves — the caller decides what to sign; the wallet holds the key, not the policy.
20+
21+
What it does check is **payload integrity**, because a TRON transaction states its content three
22+
times and nothing in the format forces the three to agree:
23+
24+
| Field | Who reads it |
25+
|---|---|
26+
| `raw_data` | you / your agent, when deciding whether to sign |
27+
| `raw_data_hex` | the node, when executing the transaction |
28+
| `txID` | the signature — this hash, and only this hash, is what gets signed |
29+
30+
So a transaction whose `raw_data` reads "1 TRX" can carry the `txID` and `raw_data_hex` of a
31+
1000 TRX transfer, and the signature would be perfectly valid for what actually executes.
32+
`tx sign` therefore refuses (`tx_integrity`) unless:
33+
34+
1. `txID` is the sha256 of `raw_data_hex` — always enforced, for every contract type; and
35+
2. `raw_data` re-encodes to exactly those bytes — enforced wherever the contract type can be
36+
decoded. A handful of types (`MarketSellAssetContract`, `MarketCancelOrderContract`,
37+
`ShieldedTransferContract`) cannot be re-encoded; those are still bound by check 1.
38+
39+
Neither check rejects anything a correct transaction builder produces.
40+
41+
Watch-only accounts cannot sign (`watch_only_no_signer`). Ledger accounts sign on the device.
42+
43+
### Multi-sig co-signing
44+
45+
If the transaction you pass already carries a `signature` array, this command **appends** its
46+
signature rather than replacing what is there. That is what TRON multi-sig requires — each
47+
permitted key signs the same transaction in turn — so a partially signed transaction can be passed
48+
from signer to signer and broadcast once the permission threshold is met.
49+
50+
The consequence worth knowing: `data.signed.signature` may contain signatures this wallet did not
51+
produce. `data.address` always tells you which key *this* invocation used, and the text receipt
52+
numbers them so you can tell them apart:
53+
54+
```console
55+
✅ Signed transaction
56+
Address TU9Z8Ha6Xj9oLLhamrT8MC77dxsj65VYMC
57+
TxID d0157b08eb6a5ce9482d4429a481f3bca4a95914f92a6b8f2fb73fb905ff7de0
58+
Signature 1 ffffffff… ← already on the transaction
59+
Signature 2 16a2ec10… ← added by this invocation
60+
```
61+
62+
## Options
63+
64+
| Option | Description |
65+
|---|---|
66+
| `--transaction <string>` | Unsigned transaction JSON (`raw_data`, `raw_data_hex` and `txID` must agree) |
67+
| `--password-stdin` | Master password from stdin (software accounts) |
68+
69+
Plus the [global options](../index.md#global-options-every-command).
70+
71+
The payload is passed on argv, not stdin: it is not a secret, and this leaves fd 0 free for
72+
`--password-stdin`.
73+
74+
## Examples
75+
76+
In the examples, `$PW` is your master password (fed on stdin via `--password-stdin`) and `$TX`
77+
holds the unsigned transaction JSON.
78+
79+
```bash
80+
echo "$PW" | wallet-cli tx sign --transaction "$TX" --password-stdin
81+
```
82+
83+
```console
84+
✅ Signed transaction
85+
Address TU9Z8Ha6Xj9oLLhamrT8MC77dxsj65VYMC
86+
TxID d0157b08eb6a5ce9482d4429a481f3bca4a95914f92a6b8f2fb73fb905ff7de0
87+
Signature 16a2ec107591827046bcd77e9ae71a5fc415e2f69b9eebe5ad0fe6e3e39cfed16e7dad3e35bc36031ddd5bc47e22de63a925bf65a82e0e1e69508735bc3fd6271C
88+
```
89+
90+
The signature is printed in full — it is the product of the command and you have to copy it
91+
somewhere. `Fee` is omitted because nothing was estimated: the transaction was built elsewhere.
92+
93+
Sign now, broadcast later:
94+
95+
```bash
96+
echo "$PW" | wallet-cli tx sign --transaction "$TX" --password-stdin -o json \
97+
| jq -c .data.signed > signed.json
98+
wallet-cli tx broadcast --network tron:nile --tx-stdin < signed.json
99+
```
100+
101+
A transaction whose fields disagree is refused rather than signed:
102+
103+
```console
104+
{"success":false,"command":"tx.sign","error":{"code":"tx_integrity","message":"TRON transaction raw_data does not match its raw_data_hex; refusing to sign"}}
105+
```
106+
107+
## Output
108+
109+
| Field | Type | Meaning |
110+
|---|---|---|
111+
| `kind` | string | `"sign"` |
112+
| `mode` | string | `"sign-only"` — same shape `tx send --sign-only` emits, so consumers need no branch |
113+
| `address` | string | Address that produced the signature |
114+
| `txId` | string | Transaction id (TRON: `txID`) |
115+
| `signed` | object | The signed transaction — exactly what `tx broadcast` accepts |
116+
117+
No `fee` is reported: nothing was estimated, because the transaction was not built here.
118+
119+
## Exit status
120+
121+
`0` signed · `1` execution failure (`tx_integrity`, `watch_only_no_signer`, `auth_failed`,
122+
`signing_rejected`) · `2` usage error (missing or malformed `--transaction``missing_option` /
123+
`invalid_value`).
124+
125+
## See also
126+
127+
[`tx broadcast`](broadcast.md) · [`typed-data sign`](../typed-data/sign.md) ·
128+
[Security model](../../concepts/security.md)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# wallet-cli typed-data
2+
3+
Sign EIP-712 / TIP-712 structured data.
4+
5+
## Synopsis
6+
7+
```
8+
wallet-cli typed-data COMMAND
9+
```
10+
11+
## Subcommands
12+
13+
| Command | Page | Description |
14+
|---|---|---|
15+
| `typed-data sign` | [sign.md](sign.md) | Sign EIP-712 / TIP-712 structured data |
16+
17+
## See also
18+
19+
[`message`](../message/index.md) — arbitrary text signing (TIP-191/V2 · EIP-191) ·
20+
[Security model](../../concepts/security.md)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# wallet-cli typed-data sign
2+
3+
Sign EIP-712 / TIP-712 structured data.
4+
5+
## Synopsis
6+
7+
```
8+
wallet-cli typed-data sign --typed-data <json> [options]
9+
```
10+
11+
## Description
12+
13+
Signs structured data with the active account's key (or `--account`) using the EIP-712 / TIP-712
14+
scheme, and prints the signature together with the digest that was signed. Signing only — nothing
15+
is broadcast, and `--network` is optional since signing is offline for software accounts.
16+
17+
The payload is the standard EIP-712 JSON object:
18+
19+
```json
20+
{
21+
"domain": { "name": "SunPerp", "version": "1", "chainId": 728126428 },
22+
"types": { "Order": [{ "name": "trader", "type": "address" }, { "name": "size", "type": "uint256" }] },
23+
"message": { "trader": "TW7xMzawfuGcowC3rYN1qPnvrkrxVVMive", "size": "1000000" }
24+
}
25+
```
26+
27+
Accommodations for real-world payloads:
28+
29+
- **`EIP712Domain` is ignored** if present in `types`. Wallet-produced payloads routinely include
30+
it, but it describes `domain` rather than being a struct to hash, and the encoder rejects it.
31+
- **`value` is accepted** as an alias for `message`.
32+
- **`primaryType` is optional** — it is inferred from `types` and always echoed back in the result,
33+
so a caller can assert what was signed.
34+
- **TRON base58 addresses work** in `address`-typed fields; `T…` and `41…` hash identically.
35+
36+
`domain.chainId` is signed as given and is **not** validated against `--network`: TRON networks are
37+
identified by name (`mainnet`/`nile`/`shasta`) rather than by an EIP-155 number, so there is nothing
38+
meaningful to compare against. Make sure the domain you pass targets the chain you intend.
39+
40+
Watch-only accounts cannot sign (`watch_only_no_signer`).
41+
42+
### Ledger
43+
44+
Ledger accounts **can** sign typed data — the signature is produced on the device like any other.
45+
46+
The caveat is *blind signing*: the TRON app exposes only the hashed TIP-712 instruction, so the
47+
screen shows the domain separator and the struct hash rather than the fields you are agreeing to.
48+
There is no way to render the message on-device, so check the payload before approving.
49+
50+
**The TRON app blocks this by default.** Hash-only signing is gated behind a setting, so the first
51+
attempt fails with `ledger_setting_required`:
52+
53+
```console
54+
{"success":false,"error":{"code":"ledger_setting_required","message":"enable \"Sign by Hash\" in the Ledger TRON app settings (Settings › Sign by Hash › Allowed)"}}
55+
```
56+
57+
Enable it on the device under **Settings › Sign by Hash › Allowed**, then retry. App versions
58+
predating the TIP-712 instruction fail with `ledger_unsupported`; update the TRON app.
59+
60+
## Options
61+
62+
| Option | Description |
63+
|---|---|
64+
| `--typed-data <string>` | EIP-712/TIP-712 JSON: `{"domain":…,"types":…,"primaryType"?:…,"message":…}` |
65+
| `--password-stdin` | Master password from stdin (software accounts) |
66+
67+
Plus the [global options](../index.md#global-options-every-command).
68+
69+
The payload is passed on argv, not stdin: it is not a secret, and this leaves fd 0 free for
70+
`--password-stdin`.
71+
72+
## Examples
73+
74+
In the examples, `$PW` is your master password (fed on stdin via `--password-stdin`) and
75+
`$DATA` holds the EIP-712 JSON.
76+
77+
```bash
78+
echo "$PW" | wallet-cli typed-data sign --typed-data "$DATA" --password-stdin
79+
```
80+
81+
```console
82+
✅ Signed typed data
83+
Address TW7xMzawfuGcowC3rYN1qPnvrkrxVVMive
84+
Type Order
85+
Digest 0x0a8d555d85874979f93b7f36b3518c825c0f8af12a97c21863610509732cdadd
86+
Signature 0x8cbad926adcba9c155dc791d1412632d95c415ad5bd83e35fa71752b602db03429e2f866180802fea83268d6bac601c33ea00218e43fc42840e589200b3491fb1b
87+
```
88+
89+
Digest and signature are printed in full; nothing in this receipt is abbreviated.
90+
91+
```bash
92+
echo "$PW" | wallet-cli typed-data sign --typed-data "$DATA" --password-stdin -o json
93+
```
94+
95+
```json
96+
{"schema":"wallet-cli.result.v1","success":true,"command":"typed-data.sign","data":{"address":"TW7xMzawfuGcowC3rYN1qPnvrkrxVVMive","signature":"0x8cbad926adcba9c155dc791d1412632d95c415ad5bd83e35fa71752b602db03429e2f866180802fea83268d6bac601c33ea00218e43fc42840e589200b3491fb1b","digest":"0x0a8d555d85874979f93b7f36b3518c825c0f8af12a97c21863610509732cdadd","primaryType":"Order"},"meta":{"durationMs":955,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}}
97+
```
98+
99+
## Output
100+
101+
| Field | Type | Meaning |
102+
|---|---|---|
103+
| `address` | string | Signer's base58 address |
104+
| `primaryType` | string | The struct that was signed (inferred when omitted) |
105+
| `digest` | string | The EIP-712/TIP-712 hash that was signed, 0x-prefixed |
106+
| `signature` | string | Signature, 0x-prefixed hex (`r‖s‖v`) |
107+
108+
## Exit status
109+
110+
`0` signed · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `signing_rejected`,
111+
`ledger_setting_required`, `ledger_unsupported`) · `2` usage error (missing or malformed `--typed-data``missing_option` /
112+
`invalid_value`).
113+
114+
## See also
115+
116+
[`message sign`](../message/sign.md) · [`tx sign`](../tx/sign.md) ·
117+
[Security model](../../concepts/security.md)

ts/docs/typescript-wallet-cli-architecture-source-of-truth.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ interface FamilyPlugin<F extends ChainFamily> {
277277
| `path` | Neutral commands use the full path; chain commands use a cross-family logical path. |
278278
| `family` | Omitted for neutral commands; when present, the resolved network selects the family implementation. |
279279
| `stdin` | A dedicated **command-scoped** stdin channel, one of `tx` or `message` (signed-tx JSON / message to sign). This field does not cover the master password, which is fed by the **global** `--password-stdin` (see the CLI surface section). Wallet secrets (`mnemonic`, `privateKey`) and the master-password *change* are TTY-only and have no stdin flag — see `secretsTtyOnly`. |
280-
| `network` | `none`, `optional`, `required`; today both optional/required can fall back to the default network. |
280+
| `network` | `none` (never touches a chain) or `optional` (resolves `--network`, else `config.defaultNetwork`). There is no `required`: the default-network fallback always applies, so nothing can demand an explicit `--network`. |
281281
| `wallet` | `none` or `optional`; optional can override the active account with `--account`. |
282282
| `auth` | An unlock declaration for help/catalog; actual software signing uses lazy decrypt. |
283283
| `broadcasts` | Controls whether help reveals `--wait`. |
@@ -493,7 +493,7 @@ interface TronNetworkDescriptor {
493493
| `tron:nile` | `nile` | `https://nile.trongrid.io` |
494494
| `tron:shasta` | `shasta` | `https://api.shasta.trongrid.io` |
495495

496-
Canonical-id resolution is case-insensitive. Aliases remain descriptor metadata but are not accepted as network selectors. Both `network: optional/required` adopt `config.defaultNetwork` when not specified, and that value must be a canonical id. Ledger/watch pin a single family, and a family mismatch must fail before any RPC.
496+
Canonical-id resolution is case-insensitive. Aliases remain descriptor metadata but are not accepted as network selectors. `network: optional` adopts `config.defaultNetwork` when `--network` is not specified, and that value must be a canonical id. Ledger/watch pin a single family, and a family mismatch must fail before any RPC.
497497

498498
`ChainGatewayRegistry` is injected with the family factory by Bootstrap and caches the client by network id. Its generic `client()` may only use the truly common minimal capabilities; a family use case obtains the `TronGateway` via the guarded `get(net, "tron")`. TRON staking and the future EVM gas/nonce must not be forced into a universal gateway.
499499

ts/src/adapters/inbound/cli/commands/contract.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const sendFields = z.object({
7575

7676
export const contractSendSpec: ChainSpec = {
7777
path: ["contract", "send"],
78-
network: "required", wallet: "optional", auth: "required",
78+
network: "optional", wallet: "optional", auth: "required",
7979
broadcasts: true,
8080
capability: "contract.call",
8181
summary: "State-changing call (triggerSmartContract)",
@@ -104,7 +104,7 @@ const deployFields = z.object({
104104

105105
export const contractDeploySpec: ChainSpec = {
106106
path: ["contract", "deploy"],
107-
network: "required", wallet: "optional", auth: "required",
107+
network: "optional", wallet: "optional", auth: "required",
108108
broadcasts: true,
109109
capability: "contract.deploy",
110110
summary: "Deploy a smart contract",

ts/src/adapters/inbound/cli/commands/stake.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function stakeCommand(
3838
return {
3939
spec: {
4040
path: ["stake", action],
41-
network: "required", wallet: "optional", auth: "required",
41+
network: "optional", wallet: "optional", auth: "required",
4242
broadcasts: true,
4343
capability: options.capability ?? "staking.freeze",
4444
summary,

0 commit comments

Comments
 (0)