diff --git a/.gitignore b/.gitignore index bfa1474f6..648c86304 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ qa/.verify.lock/ # graphify graphify-out/ +.vscode/ +docs/superpowers diff --git a/docs/plan-standard-cli-ledger.md b/docs/plan-standard-cli-ledger.md new file mode 100644 index 000000000..b6a23a358 --- /dev/null +++ b/docs/plan-standard-cli-ledger.md @@ -0,0 +1,627 @@ +# Plan: Ledger support in Standard CLI + +**Status:** Ready to implement +**Spike basis:** `docs/spike-standard-cli-ledger.md` +**Approach:** Option A — reuse keystore + password auth, narrow `LedgerSigner` +abstraction injected into the existing sign sites. +**Estimated effort:** 1.5–2 working days net coding (excluding reviewer-run +hardware smoke). + +## 1. Goal + +All standard CLI commands that currently produce a signed transaction must +work when the resolved wallet is a Ledger keystore — without prompts on +stdin/stdout, with structured JSON output, with deterministic exit codes, +and with explicit error codes for every failure mode. + +The user authenticates exactly as for software wallets: +`MASTER_PASSWORD` env var or `--password-stdin`. The Ledger device must be +connected at sign time and the user must press the on-device confirmation +button. The 60-second device timeout that the existing REPL path enforces +applies unchanged. + +### Reach: standard-CLI signing commands through one sign exit + +The change converges on `WalletApi.signTransactionForCli` — the single sign +exit shared by every standard-CLI signing command. After this plan ships, +every `*ForCli` method that goes through that exit and whose transaction type +is supported by the existing Ledger allowlist (e.g. `sendCoinForCli`, +`triggerContractForCli`, `freezeBalanceForCli`, `voteWitnessForCli`, +`accountPermissionUpdateForCli`, …) gains Ledger support simultaneously, plus +the gasfree path through `WalletApiWrapper`. + +This high reach-per-effort ratio is the central justification for the work. + +## 2. Non-goals + +- **Ledger import / pairing in standard CLI.** Path selection requires a + human in the loop; users run `importwalletbyledger` once in REPL. +- **Password-less Ledger signing** (a future `--ledger-path` direct mode). +- **Refactoring REPL Ledger paths.** The two REPL sign sites + (`WalletApi.signTransaction(...)` overloads) are not touched. +- **Configurable timeout.** REPL hard-codes 60 seconds; standard CLI + inherits the same constant. `--ledger-timeout` is deferred. +- **Multi-device disambiguation flag.** Documented behavior on multi-device + setups; `--ledger-device` is deferred. +- **Unifying `wf.getName().contains("Ledger")` vs `isLedgerUser()` detection + inconsistency.** Out of scope. + +## 3. Mental model + +- Ledger keystores share the WalletFile JSON shape with software keystores. + Their encrypted payload is a UTF-8 BIP44 path string instead of a 32-byte + private key. The address is plaintext. +- Standard CLI's existing `authenticate()` flow loads the keystore and + verifies the password identically for both wallet types. +- The two diverge at the **signing call**: software wallets sign locally + with the decrypted private key; Ledger wallets send an APDU and wait for + the on-device confirmation button. +- The keystore password's role for Ledger wallets is format consistency, + not security. Funds are protected by the device, not the password. + +## 4. Architecture + +### 4.1 The `LedgerSigner` interface + +``` +package org.tron.walletcli.cli.ledger; + +interface LedgerSigner { + LedgerSignOutcome sign(Chain.Transaction transaction, + String bip44Path, + String address, + boolean gasfree); +} +``` + +`LedgerSignOutcome` is a value type: + +``` +final class LedgerSignOutcome { + enum Status { + OK, + NOT_CONNECTED, + APP_NOT_OPEN, + SIGN_BY_HASH_DISABLED, + ALREADY_SIGNING, + USER_REJECTED, + TIMEOUT, + SIGN_FAILED, + } + Status status; + String message; // human-readable detail, never user-prompt-style + Chain.Transaction signedTransaction; // populated when status == OK and not gasfree + String gasfreeSignature; // populated when status == OK and gasfree == true +} +``` + +The interface lives in the standard-CLI package because both implementations +exist for the standard CLI use case (production + test fake). REPL is not a +client. + +### 4.2 The single implementation: `NonInteractiveLedgerSigner` + +``` +package org.tron.walletcli.cli.ledger; + +final class NonInteractiveLedgerSigner implements LedgerSigner { + private final OutputFormatter formatter; + private final SystemOutSuppressor suppressor; + NonInteractiveLedgerSigner(OutputFormatter formatter, + SystemOutSuppressor suppressor) { ... } + + @Override + public LedgerSignOutcome sign(...) { ... } +} +``` + +Internal flow (derived from spike F1–F8): + +1. `HidServicesWrapper.getInstance().getHidDevice(address, path)` → + `null` ⇒ `NOT_CONNECTED`; throws ⇒ `NOT_CONNECTED` (with caught message). +2. Pre-check `LedgerSignResult.getLastTransactionState(devicePath)` → if + `SIGN_RESULT_SIGNING`, return `ALREADY_SIGNING` (mirrors REPL line-58 + check, but typed instead of printed). +3. Defensively reset `TransactionSignManager.setTransaction(null)` and + `setGasfreeSignature(null)`. +4. Emit one stderr info line via the formatter: + `"Please confirm transaction on Ledger device for " + address`. +5. Open the suppressor (redirects `System.out` for the duration of the HID + call) and invoke + `LedgerEventListener.getInstance().executeSignListen(device, tx, path, gasfree)`. +6. Close the suppressor regardless of outcome (try/finally). +7. Inspect the listener's recorded last APDU response (see §4.4 patch): + `0x6511` ⇒ `APP_NOT_OPEN`; `0x6a8c` ⇒ `SIGN_BY_HASH_DISABLED`; other + non-empty bytes ⇒ `SIGN_FAILED` with hex in `message`. +8. Otherwise derive outcome from post-sign state: + - signature present in `TransactionSignManager` ⇒ `OK` + - `LedgerSignResult.getLastTransactionState` == + `SIGN_RESULT_REJECTED` ⇒ `USER_REJECTED` + - else ⇒ `TIMEOUT` +9. `finally`: always reset `TransactionSignManager` transaction + + signature fields and close the HID device. + +The implementation is roughly 180 LOC including imports and Javadoc. + +### 4.3 Wire-up: inject signer into `WalletApi` and `WalletApiWrapper` + +No back-reference interface, no hook indirection. Both classes get a +nullable `LedgerSigner` field with a setter: + +``` +class WalletApi { + private LedgerSigner ledgerSigner; // null in REPL; set in standard CLI + public void setLedgerSigner(LedgerSigner s) { this.ledgerSigner = s; } + public LedgerSigner getLedgerSigner() { return ledgerSigner; } +} + +class WalletApiWrapper { + public void setLedgerSigner(LedgerSigner s) { + if (wallet != null) wallet.setLedgerSigner(s); + } +} +``` + +`StandardCliRunner.authenticate()`, after constructing the `WalletApi`, +calls `wrapper.setLedgerSigner(new NonInteractiveLedgerSigner(...))` +unconditionally. The signer is cheap to construct and idle when no Ledger +sign happens. + +REPL never calls these setters; the field stays `null`; existing REPL +paths continue to call `LedgerSignUtil.requestLedgerSignLogic` directly. +**Zero REPL behavior change.** + +### 4.4 Sign-site changes + +Two edits, plus a 5-line additive patch. + +#### 4.4.1 `WalletApi.signTransactionForCli` (line 1064-1093) + +Existing Ledger branch: + +```java +if (isLedgerFile) { + boolean result = LedgerSignUtil.requestLedgerSignLogic(transaction, ledgerPath, wf.getAddress(), false); + if (!result) { recordLastCliOperationError(...); return null; } + transaction = TransactionSignManager.getInstance().getTransaction(); + Response.TransactionSignWeight weight = getTransactionSignWeight(transaction); + if (ENOUGH_PERMISSION) { ...return transaction; } + HidDevice hidDevice = HidServicesWrapper.getInstance().getHidDevice(...); + if (hidDevice == null) { ...return null; } + Optional state = LedgerSignResult.getLastTransactionState(hidDevice.getPath()); + boolean confirmed = state.isPresent() && SUCCESS.equals(state.get()); + if (NOT_ENOUGH_PERMISSION && confirmed && multi) { return transaction; } + throw new CancelException(weight.getResult().getMessage()); +} +``` + +New branch: + +```java +if (isLedgerFile) { + if (this.ledgerSigner != null) { + LedgerSignOutcome r = this.ledgerSigner.sign(transaction, ledgerPath, wf.getAddress(), false); + if (r.status != OK) { + recordLastCliOperationError(r.errorCode() + ": " + r.message); + throw new CommandErrorException(r.errorCode(), r.message); + } + transaction = r.signedTransaction; // signer extracts from TransactionSignManager + Response.TransactionSignWeight weight = getTransactionSignWeight(transaction); + if (ENOUGH_PERMISSION) { return transaction; } + if (NOT_ENOUGH_PERMISSION && multi) { return transaction; } + throw new CancelException(weight.getResult().getMessage()); + } + // Legacy path retained as safety net; unreachable when signer is injected. + boolean result = LedgerSignUtil.requestLedgerSignLogic(...); + /* existing 25 lines unchanged */ +} +``` + +The post-sign permission-weight verification (lines 1073-1093) stays in +`WalletApi`. The signer's job ends at "got a signature back"; the +multi-permission semantics belong to `WalletApi`. + +When `ledgerSigner != null` (standard CLI), the legacy 25-line block is +unreachable. Kept as a safety net for the (currently impossible) case +where a non-standard-CLI caller reaches this method. + +#### 4.4.2 `WalletApiWrapper.gasFreeTransferInternal` (line 3268-3286) + +The method already takes a `boolean standardCli` parameter. Branch +explicitly: + +```java +if (isLedgerFile) { + Chain.Transaction transaction = ...; + String signature = null; + if (standardCli) { + if (this.wallet.getLedgerSigner() == null) { + throw new CommandErrorException("execution_error", + "Standard CLI Ledger signer not initialized"); + } + LedgerSignOutcome r = this.wallet.getLedgerSigner().sign(transaction, ledgerPath, wf.getAddress(), true); + if (r.status != OK) { + throw new CommandErrorException(r.errorCode(), r.message); + } + signature = r.gasfreeSignature; + } else { + // REPL path: existing behavior, byte-for-byte + boolean ledgerResult = LedgerSignUtil.requestLedgerSignLogic(transaction, ledgerPath, wf.getAddress(), true); + if (ledgerResult) signature = TransactionSignManager.getInstance().getGasfreeSignature(); + if (signature == null) { + TransactionSignManager.getInstance().setTransaction(null); + TransactionSignManager.getInstance().setGasfreeSignature(null); + System.out.println("Listening ledger did not obtain signature."); + return false; + } + TransactionSignManager.getInstance().setTransaction(null); + TransactionSignManager.getInstance().setGasfreeSignature(null); + } + /* rest of method unchanged: signature validation + submit */ +} +``` + +REPL path is preserved literally; standard-CLI path uses the signer. + +#### 4.4.3 `LedgerEventListener` 5-line additive patch + +`NonInteractiveLedgerSigner` needs to read the last APDU response after +calling `executeSignListen`. The cheapest seam is to record it as a field: + +```java +private byte[] lastSendResult; +public byte[] getLastSendResultBytes() { return lastSendResult; } + +// inside executeSignListen, line 81: +this.lastSendResult = handleTransSign(hidDevice, transaction, path, gasfree); +byte[] sendResult = this.lastSendResult; +``` + +Pure addition; no existing caller reads this; REPL is unaffected. +`LedgerEventListener` is a process-wide singleton and is single-threaded +in practice (REPL and standard CLI never run concurrently in the same JVM). + +### 4.5 Stdout suppression + +REPL prints inside `LedgerEventListener` and the unchanged-for-REPL +`LedgerSignUtil` would pollute JSON output if they reach stdout during a +standard-CLI sign. The bridge wraps the HID-call section in a +`SystemOutSuppressor` (try-with-resources): + +``` +final class SystemOutSuppressor implements AutoCloseable { + static SystemOutSuppressor capture(); // saves System.out, swaps for sink + String drained(); // captured bytes (for --verbose echo) + @Override public void close(); // restores System.out +} +``` + +Phase 0 must grep for an existing equivalent before we write a new one. +If nothing exists, we write it (~50 LOC). + +In `--verbose` mode the captured content is replayed to stderr, prefixed +with `[ledger-noise]`. In other modes it is discarded. + +## 5. Files touched + +| File | Change | LOC | +|------|--------|-----| +| **NEW** `cli/ledger/LedgerSigner.java` | Interface | 15 | +| **NEW** `cli/ledger/LedgerSignOutcome.java` | Value type + Status enum | 60 | +| **NEW** `cli/ledger/NonInteractiveLedgerSigner.java` | Implementation | 180 | +| **NEW** `cli/ledger/SystemOutSuppressor.java` | Stdout capture util (Phase 0 may make this reuse) | 50 | +| `cli/StandardCliRunner.java` | Construct + inject signer in `authenticate()` | +8 | +| `walletcli/WalletApiWrapper.java` | Add `setLedgerSigner` delegating to `WalletApi`; replace 1 sign branch (gasfree) under `if (standardCli)` | +30, -10 | +| `walletserver/WalletApi.java` | Add `ledgerSigner` field/setter/getter; replace Ledger branch in `signTransactionForCli` | +25, -15 | +| `ledger/listener/LedgerEventListener.java` | Add `lastSendResult` field + accessor | +5 | +| **NEW** `cli/ledger/NonInteractiveLedgerSignerTest.java` | Bridge unit tests (12) | 250 | +| **NEW** `cli/ledger/LedgerSignOutcomeTest.java` | Trivial coverage | 30 | +| `cli/StandardCliRunnerTest.java` | Five integration tests with `FakeLedgerSigner` | +120 | +| **NEW** `docs/qa-ledger-smoke.md` | Manual QA runbook | 80 | +| `docs/standard-cli-contract-spec.md` | Additive subsection on Ledger error codes | +40 | +| `docs/standard-cli-user-manual.md` | "Using Ledger" section | +60 | +| `docs/release-notes-wallet-cli-*.md` | Bullet point | +3 | + +**Net: ~810 LOC added, ~25 LOC removed.** ~400 LOC of that is tests. + +## 6. Behavior specification + +### 6.1 Discovery + +- Exactly one connected Ledger whose Tron-app-derived address at the + keystore's path matches the keystore's address ⇒ proceed. +- Zero matching devices ⇒ `ledger_not_connected` (exit 1). +- Multiple connected devices ⇒ the standard CLI validates the derived address + at the keystore path and uses the matching device. If no connected device + derives the keystore address at that path, return `ledger_not_connected`. + A future `--ledger-device` flag may make multi-device selection explicit. + +### 6.2 Stderr output + +Exactly one info line per sign attempt, on stderr: + +``` +Please confirm transaction on Ledger device for TXxx... +``` + +No further progress output. On failure, the structured error message +appears in stderr (text mode) or in the JSON envelope's `message` field +(JSON mode). + +### 6.3 Stdout + +- `--output json`: stdout contains exactly one JSON envelope. +- `--output text`: stdout contains exactly the result string the command + produces (transaction id on success, nothing on failure). +- The suppressor guarantees no listener prints reach stdout. + +### 6.4 Error code → exit code + +All Ledger errors are execution errors (exit 1). + +| Error code | Trigger | +|-----------|---------| +| `ledger_not_connected` | No matching device, or HID transport failure | +| `ledger_app_not_open` | APDU `0x6511` | +| `ledger_sign_by_hash_disabled` | APDU `0x6a8c` | +| `ledger_unsupported_contract` | Transaction type is outside the Ledger allowlist | +| `ledger_already_signing` | `LedgerSignResult` indicates a prior sign is still `SIGNING` | +| `ledger_user_rejected` | `LedgerSignResult` is `SIGN_RESULT_REJECTED` after wait | +| `ledger_timeout` | 60-second wait elapsed without confirm or reject state | +| `ledger_sign_failed` | Any other failure | + +All codes start with `ledger_` for prefix matching by agents. + +### 6.5 Singleton state hygiene + +`NonInteractiveLedgerSigner.sign(...)` invariants: + +- Always reset `TransactionSignManager` transaction + signature fields in + a `finally`. +- Always close the HID device in a `finally`. +- Never throws. Always returns an outcome; the caller (sign-site code) + translates non-`OK` to `CommandErrorException`. + +## 7. Phased delivery (~1.5–2 days net coding) + +### Phase 0 — confirmation grep (≤ 1 hour, code-only) + +- Verify no `SystemOutSuppressor`-equivalent already exists (grep + `System.setOut`, look for utility classes). +- Confirm `OutputFormatter.info(...)` writes to stderr in both text and + JSON modes (it should, per existing usage). +- Confirm singletons `HidServicesWrapper.getInstance()` and + `LedgerEventListener.getInstance()` have a testable seam (existing + pattern in the codebase, or PowerMock setup). + +If any answer surprises, update §4.5 / §8.1 before Phase 1. + +### Phase 1 — full signer + tests (~½–1 day) + +Deliverables: + +- `LedgerSigner` interface +- `LedgerSignOutcome` value type +- `NonInteractiveLedgerSigner` with **all 8 Status values reachable** + (no half-baked stubs) +- `SystemOutSuppressor` (or reuse if Phase 0 found one) +- `LedgerEventListener` 5-line additive patch +- `NonInteractiveLedgerSignerTest` — 12 unit tests covering each + enum value + state-cleanup invariants + stderr message shape +- `LedgerSignOutcomeTest` — trivial coverage + +Acceptance: tests green; `NonInteractiveLedgerSigner.sign(...)` is +callable in isolation with mock collaborators. + +### Phase 2 — wire to both sign sites + integration tests (~½ day) + +Deliverables: + +- `WalletApi.setLedgerSigner` field/setter/getter +- `WalletApiWrapper.setLedgerSigner` delegation +- `signTransactionForCli` Ledger branch routes through `ledgerSigner` + when injected; legacy block kept as safety net +- `gasFreeTransferInternal` Ledger branch splits on `standardCli` +- `StandardCliRunner.authenticate()` constructs and injects + `NonInteractiveLedgerSigner` +- `StandardCliRunnerTest` — 5 integration tests: + 1. `gasFreeTransferSucceedsWithFakeLedgerSigner` + 2. `gasFreeTransferReportsLedgerUserRejected` + 3. `sendCoinSucceedsWithFakeLedgerSigner` + 4. `sendCoinReportsLedgerNotConnected` + 5. `nonLedgerCommandsUnaffectedByInjectedSigner` + +Acceptance: gasfree and one mainline command both flow through the +signer end-to-end with a `FakeLedgerSigner`; software-wallet sign paths +are unchanged. + +### Phase 3 — documentation (~2 hours) + +Deliverables: + +- `docs/qa-ledger-smoke.md` — 5-step manual runbook (§8.3) +- `docs/standard-cli-user-manual.md` "Using Ledger" section +- `docs/standard-cli-contract-spec.md` additive subsection on Ledger + error codes +- Release notes bullet + +Acceptance: docs reviewed. + +### Phase 4 — merge gate (reviewer-driven, not author time) + +PR description explicitly states: + +> Author has no physical Ledger. The following items are unverified by +> the author and require a reviewer-driven smoke test (see +> `docs/qa-ledger-smoke.md`): +> +> - All 5 steps of the runbook +> - One REPL Ledger sign (regression check on the additive listener +> patch) +> +> All other behavior is verified by unit and integration tests with +> mocked HID and listener state. + +Merge requires: + +- All automated tests green +- A reviewer with a Ledger device runs `qa-ledger-smoke.md` and confirms + all 5 steps +- A reviewer runs **one** Ledger sign in REPL and confirms output is + visually identical to before this PR + +## 8. Test strategy + +### 8.1 Unit tests (`NonInteractiveLedgerSignerTest`) + +12 tests, each ~20 LOC. Mock collaborators: `HidServicesWrapper`, +`LedgerEventListener`, `LedgerSignResult`, `TransactionSignManager`. + +| Test | Setup | Asserts | +|------|-------|---------| +| `signSucceedsWhenUserConfirms` | mock device valid; signature set in TSM; state SUCCESS | outcome `OK`, signature populated | +| `returnsNotConnectedWhenDeviceMissing` | wrapper returns null | outcome `NOT_CONNECTED` | +| `returnsNotConnectedWhenWrapperThrows` | wrapper throws `IllegalStateException` | outcome `NOT_CONNECTED` | +| `returnsAppNotOpenOn0x6511` | `lastSendResult = [0x65, 0x11]` | outcome `APP_NOT_OPEN` | +| `returnsSignByHashDisabledOn0x6a8c` | `lastSendResult = [0x6a, 0x8c]` | outcome `SIGN_BY_HASH_DISABLED` | +| `returnsSignFailedOnUnknownApduResponse` | `lastSendResult = [0xff, 0xff]` | outcome `SIGN_FAILED`, message contains hex | +| `returnsAlreadySigningWhenStateIsSigning` | LedgerSignResult returns `SIGNING` before sign | outcome `ALREADY_SIGNING`, listener never called | +| `returnsUserRejectedWhenStateIsRejected` | post-sign state `SIGN_RESULT_REJECTED` | outcome `USER_REJECTED` | +| `returnsTimeoutWhenNeitherStateNorSignaturePresent` | post-sign neither | outcome `TIMEOUT` | +| `clearsTransactionSignManagerOnEveryExitPath` | parameterized by every outcome | TSM cleared afterwards | +| `closesHidDeviceOnEveryExitPath` | parameterized | mock HidDevice.close() invoked | +| `emitsExactlyOneStderrInfoLine` | success path | formatter recorded one info call, message contains address | + +### 8.2 Integration tests (`StandardCliRunnerTest`) + +5 tests, each ~25 LOC, using `FakeLedgerSigner` (test-package class that +records calls and returns programmable outcomes). + +(Listed in Phase 2 deliverables.) + +### 8.3 Manual QA (hardware, reviewer) + +`docs/qa-ledger-smoke.md`: + +``` +Manual smoke (requires Ledger Nano S/X with Tron app installed) + +Pre-req: importwalletbyledger via REPL, set local password P, note address A. + +1. Normal sign: + echo "P" | wallet-cli --password-stdin --output json \ + --wallet ledger-alpha send-coin --to --amount 1 + → confirm on device → expect {"success": true, "data": {...}} + → stderr contains "Please confirm transaction on Ledger device for A" + +2. User rejects: + same command → press REJECT on device + → expect exit 1, JSON: {"success": false, "error": "ledger_user_rejected"} + +3. Device disconnected: + unplug Ledger, run same command + → expect exit 1, error: "ledger_not_connected" + +4. Tron app not open: + plug device, leave at home screen (don't open Tron app) + → expect exit 1, error: "ledger_app_not_open" + +5. REPL regression (independent of standard CLI): + ./gradlew run → login as ledger wallet → SendCoin one transaction + → confirm on device → success message identical to pre-PR output +``` + +5 minutes total with a connected device. + +### 8.4 What is **not** automatically tested + +- Real APDU exchange timing +- Real disconnect-mid-sign behavior +- Real 60s timeout wall clock +- Signature cryptographic validity + +Covered by manual runbook. + +## 9. Risk register + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| `LedgerEventListener` singleton state leaks between two sequential signs in same JVM | Low | Medium | Bridge resets state in `finally`. Unit-tested. | +| `SystemOutSuppressor` interferes with logging that uses `System.out` underneath | Medium | Low | Suppress only around HID-call section; verbose mode replays to stderr. | +| Reviewer with Ledger device unavailable | Low | High (PR cannot merge) | Confirm reviewer assignment before Phase 1. Runbook is 5 minutes. | +| `executeSignListen` blocks longer than 60s under JVM load | Low | Low | Acceptable; matches REPL behavior. | +| Disconnect mid-sign produces unanticipated state | Medium | Medium | Catch all in bridge → `SIGN_FAILED`. Manual QA step 3 verifies. | +| `wf.getName().contains("Ledger")` rule fails for renamed wallets | Low | Low | Out of scope; existing REPL has the same limitation. | +| Singleton mocking turns out harder than expected (Phase 0 finds no seam) | Low | Medium | Either add a thin testable seam (~50 LOC) or use PowerMock. Decide in Phase 0. | + +## 10. REPL impact summary + +The change is 95% additive + standard-CLI-isolated. **REPL paths that +exist before this PR do exactly the same thing after.** + +| REPL scenario | Affected? | +|---|---| +| REPL + software wallet sign | No — code path entirely untouched | +| REPL + Ledger sign (REPL `signTransaction` overloads) | No — still calls `LedgerSignUtil` directly | +| REPL using gasfree transfer | No — `if (standardCli)` branch leaves the `else` path byte-for-byte | +| REPL invoking `LedgerEventListener` | Only sees an additive 5-line patch (one new field, one getter, one assignment) | + +REPL regression scope is therefore **one** smoke test: a single REPL +Ledger sign confirms the listener patch did not perturb behavior. Step 5 +of the QA runbook covers this. + +## 11. Success criteria + +- `gas-free-transfer` and at least one mainline sign command (e.g. + `send-coin`) work end-to-end against a Ledger keystore via standard + CLI, verified by reviewer-run smoke runbook. +- All eight `ledger_*` error codes are produced by at least one + automated test. +- JSON-mode stdout contains exactly one envelope per command; no + ledger-related noise leaks through. +- REPL Ledger flow output is visually identical to before (verified by + step 5 of QA runbook). +- Test coverage: unit tests reach every enum value; integration tests + reach OK + at least two error paths. +- Documentation: user manual updated, contract spec subsection added, + QA runbook present, release notes updated. +- No new dependency on a physical device for unit/CI tests. + +## 12. User-facing documentation outline + +`docs/standard-cli-user-manual.md` will gain: + +``` +### Using Ledger + +1. **Pair the device once via the REPL**: + ./gradlew run + > importwalletbyledger + Choose a path, set a local password (this password unlocks the + keystore that points at your Ledger account; it does not unlock the + device itself). + +2. **Sign from standard CLI**: + echo "$LEDGER_KEYSTORE_PASSWORD" | wallet-cli \ + --password-stdin --output json --wallet ledger-alpha \ + send-coin --to TXxx... --amount 1000000 + + - The Ledger must be connected, unlocked, with the Tron app open. + - You will see one stderr line: "Please confirm transaction on + Ledger device for ...". + - Press the confirm button on the device. + - On success, stdout contains a JSON envelope with the transaction id. + +**About the password**: the keystore password protects the BIP44 path +metadata, not your funds. Your private key never leaves the device. A +Ledger keystore without the device connected cannot sign even with the +correct password. + +**Error codes** (in JSON envelope `error` field): `ledger_not_connected`, +`ledger_app_not_open`, `ledger_sign_by_hash_disabled`, +`ledger_unsupported_contract`, `ledger_already_signing`, +`ledger_user_rejected`, `ledger_timeout`, `ledger_sign_failed`. +``` + +`docs/standard-cli-contract-spec.md` will gain an additive subsection +under Auth/Errors documenting the eight `ledger_*` codes. diff --git a/docs/qa-ledger-smoke.md b/docs/qa-ledger-smoke.md new file mode 100644 index 000000000..b1a012110 --- /dev/null +++ b/docs/qa-ledger-smoke.md @@ -0,0 +1,125 @@ +# QA: Ledger smoke test (Standard CLI) + +This runbook is the merge gate for the Standard CLI Ledger feature when the +PR author does not have a physical Ledger device. A reviewer with hardware +runs all 5 steps and confirms each outcome before approving the PR. + +**Time:** ~5 minutes with a connected device. + +## Prerequisites + +- A Ledger Nano S or Nano X +- Tron app installed on the device, "Sign By Hash" set to **Allowed** in + the app's settings +- A wallet imported via REPL once: + ``` + ./gradlew run + > importwalletbyledger + ``` + Choose a default path, set a local password, note the wallet name (the + file name will start with `Ledger-`). + +For brevity, the rest of this runbook uses: + +- `P` = the local keystore password from the import step +- `A` = the Tron address shown after import +- `W` = the wallet name (e.g. `ledger-alpha`) +- `R` = a destination address (any valid Tron address; doesn't need to be + funded — broadcast may fail downstream, but the sign outcome is what we + are verifying) + +## Build + +``` +./gradlew shadowJar +``` + +Output: `build/libs/wallet-cli.jar`. + +## Step 1 — Normal sign (success path) + +``` +echo "$P" | java -jar build/libs/wallet-cli.jar \ + --password-stdin --output json \ + --wallet $W \ + send-coin --to $R --amount 1 +``` + +Press the **confirm** button on the device when prompted. + +**Expected:** + +- stderr contains exactly one line: `Please confirm transaction on Ledger device for A` +- stdout contains a single JSON envelope with `"success": true` +- Exit code `0` +- No other text on stdout + +## Step 2 — User rejects + +Run the same command as Step 1. Press **reject** on the device instead. + +**Expected:** + +- stdout JSON: `"success": false`, `"error": "ledger_user_rejected"` +- Exit code `1` +- stderr contains the confirmation notice + the error message + +## Step 3 — Device disconnected + +Unplug the Ledger. Run the same command. + +**Expected:** + +- stdout JSON: `"success": false`, `"error": "ledger_not_connected"` +- Exit code `1` + +## Step 4 — Tron app not open + +Reconnect the device. Leave it on the home screen — do **not** open the +Tron app. Run the same command. + +**Expected:** + +- stdout JSON: `"success": false`, `"error": "ledger_app_not_open"` +- Exit code `1` + +(Some Ledger firmware versions surface this as `ledger_not_connected` +instead — accept either.) + +## Step 5 — REPL regression check + +This step is independent of the Standard CLI changes. It verifies the +5-line additive patch to `LedgerEventListener` did not perturb the REPL +sign path. + +``` +./gradlew run +> login +[enter password P] +> sendcoin $R 1 +``` + +Press confirm on the device when prompted. + +**Expected:** + +- The REPL produces output visually identical to the pre-PR REPL behavior. + Specifically: the prompts, color codes, and final `Send 1 to R successful !!` + line all appear as before. + +## Sign-off template + +``` +- [ ] Step 1 (success) passed +- [ ] Step 2 (reject) passed +- [ ] Step 3 (disconnected) passed +- [ ] Step 4 (app not open) passed +- [ ] Step 5 (REPL regression) passed + +Tested on: +- Ledger model: ___________________ +- Ledger firmware: ________________ +- Tron app version: _______________ +- Date: __________________________ +- Reviewer: ______________________ +``` diff --git a/docs/spike-standard-cli-ledger.md b/docs/spike-standard-cli-ledger.md new file mode 100644 index 000000000..f958c6ab4 --- /dev/null +++ b/docs/spike-standard-cli-ledger.md @@ -0,0 +1,266 @@ +# Spike: Ledger support in Standard CLI — offline source-code findings + +## Purpose + +Resolve the open assumptions in `docs/plan-standard-cli-ledger.md` so the +implementation plan can be promoted from "pragmatic / hand-wavy" to +"ready-to-implement" without requiring a physical Ledger device. + +This spike is **source-code only**. It does not run anything against a real +device. Items that genuinely require hardware are isolated in §"Items that +still need hardware verification" at the bottom. + +## Method + +Read the existing REPL Ledger sign path end-to-end and document its +behavior. Where REPL already encodes a behavior, treat that as authoritative +(it has been shipped against real devices for a long time). + +Files inspected: + +- `org.tron.ledger.LedgerSignUtil` +- `org.tron.ledger.listener.LedgerEventListener` +- `org.tron.ledger.listener.BaseListener` +- `org.tron.ledger.listener.TransactionSignManager` +- `org.tron.ledger.LedgerSignResult` (referenced; behavior inferred from call sites) +- `org.tron.ledger.wrapper.HidServicesWrapper` (referenced) +- `org.tron.walletserver.WalletApi` — sign sites and `signTransactionForCli` +- `org.tron.walletcli.WalletApiWrapper` — gasfree sign site +- `org.tron.walletcli.cli.StandardCliRunner` — auth path + +## Findings + +### F1: Standard CLI uses exactly **two** Ledger sign sites, not four + +Standard CLI's process pipeline only touches two of the four sign sites that +the original plan listed: + +| Site | File / line | Triggered by | +|------|-------------|--------------| +| `signTransactionForCli(...)` Ledger branch | `WalletApi.java:1064-1093` | All standard-CLI sign commands (transfer, vote, freeze, …) via `processTransactionExtentionForCli` and `processTransactionForCli` | +| GasFree sign branch | `WalletApiWrapper.java:3268-3286` | Standard-CLI `gas-free-transfer` only | + +The other two sites (`signTransaction(Chain.Transaction)` at line 904 and +`signTransaction(Chain.Transaction, boolean multi)` at line 956) are +**REPL-exclusive**. They are reached via `processTransactionExtention` / +`processTransaction`, which standard CLI does not call. + +This shrinks the standard-CLI risk surface to two sites. + +### F2: REPL's "60-second timeout" is a polling loop with cooperative early exit, not a blocking wait + +`LedgerEventListener.executeSignListen` (`LedgerEventListener.java:78`) calls +`waitAndShutdownWithInput()` (line 45), which: + +1. Spawns a background thread that runs `sleepNoInterruption(60)`. +2. `BaseListener.sleepNoInterruption` (BaseListener.java:23) sleeps in 100ms + chunks, checking `LedgerEventListener.getInstance().getLedgerSignEnd()` on + each wake-up. If the flag is set, it exits early. +3. Main thread `join()`s on this background thread. + +When the user presses confirm or reject, `hidDataReceived` (line 150) calls +`doLedgerSignEnd()` (line 213), which sets `ledgerSignEnd = true`. The +sleeping thread sees this within 100ms and returns. + +**Implication**: cancellation is already cooperative. We do **not** need +`Future.cancel(true)` to work on the underlying HID call. To enforce a +shorter timeout from outside, we set the same flag (or its replacement) and +the existing loop exits. + +The constant `TRANSACTION_SIGN_TIMEOUT = 60` is hard-coded at +`LedgerEventListener.java:27`. + +### F3: APDU error codes are already pattern-matched in REPL — they just print, they do not return + +`LedgerEventListener.handleTransSign` (line 104-148) hard-codes two APDU +status words: + +| APDU | Constant in source | Existing REPL behavior | +|------|--------------------|------------------------| +| `0x6a8c` | `SIGN_BY_HASH` | Print "Please first set 'Sign By Hash' to 'Allowed' in Ledger TRON Settings" | +| `0x6511` | `APP_IS_OPEN` | Print "Please ensure The Tron app is open in your Ledger device" | +| Other non-empty response | (unhandled) | (no message) | +| `null`/empty response | (success path) | Submitted; wait for button | + +The function returns the raw response bytes. Callers currently only check +`response == null` (= submitted, wait). We can map the same bytes to typed +error codes without changing the underlying APDU exchange logic. + +### F4: Confirm vs reject vs timeout outcomes are recorded in two static stores + +After `executeSignListen` returns, the outcome is determined by inspecting: + +1. **`TransactionSignManager` (singleton)** — `getTransaction()` and + `getTransactionSignList()`/`getGasfreeSignature()`. If a signature is + present here, the user pressed confirm. +2. **`LedgerSignResult` (file-backed state)** — + `getLastTransactionState(devicePath)` returns a string enum: + - `SIGN_RESULT_SIGNING` (still in progress) + - `SIGN_RESULT_SUCCESS` (user confirmed) + - `SIGN_RESULT_REJECTED` (user rejected) — set via `updateAllSigningToReject` in the cancel branch (line 166 / 178) + - `SIGN_RESULT_CANCEL` (timed out after device responded) — set when `isTimeOutShutdown` is true at the moment of HID response (line 205) + +REPL's existing `executeSignListen` collapses all four into a single +`boolean ret = true`, which is why surface-level it looks like REPL "loses +information." It does not — the information is in the two stores; REPL just +does not consult them at the call site. + +A non-interactive bridge can poll both stores after `executeSignListen` +returns and emit a precise outcome. + +### F5: The pre-sign HID device discovery is already non-interactive + +`LedgerSignUtil.requestLedgerSignLogic` (`LedgerSignUtil.java:21`) reaches +the device via `HidServicesWrapper.getInstance().getHidDevice(address, path)` +(line 37). That call: + +- Returns the unique device whose Tron-app-derived address at `path` matches + the requested `address`. +- Returns `null` if no match is found. +- Throws `IllegalStateException` on transport-layer failures (the existing + call site catches this and treats it as `null`). + +There is no `selectDevice()` prompt, no menu, no `lineReader`. The +discovery code is reusable as-is for standard CLI. + +### F6: REPL's interactive noise on the sign path is concentrated in `LedgerSignUtil` and the listener + +The pollution sources (in standard-CLI terms) on the sign path are: + +| Where | What | +|-------|------| +| `LedgerSignUtil` | 8 × `System.out.println`, 4 × ANSI color escape, on every reachable branch | +| `LedgerEventListener.handleTransSign` | 2 × `System.out.println` for APDU error codes, 1 × ANSI | +| `LedgerEventListener.waitAndShutdownWithInput` | 2 × `System.out.printf` (timeout banner) | +| `LedgerEventListener.hidDataReceived` | 4 × `System.out.println` on confirm / cancel | +| `LedgerEventListener.executeSignListen` | 1 × `System.out.println` ("Transaction sign request is sent to Ledger") | + +None of these go through any abstracted output channel. They are all direct +`System.out` writes. The standard-CLI bridge must: + +1. Replace the `LedgerSignUtil` wrapper entirely (it is the highest-volume + noise source and provides nothing standard CLI needs). +2. Either (a) refactor the listener's prints into a callback / sink, or (b) + leave them in place and rely on the existing standard-CLI stream + suppressor. **Recommendation: (b) for MVP**, because `LedgerEventListener` + is a singleton shared with REPL and refactoring its output channel ripples + into REPL output. Suppressing during the bridge call is sufficient. + +### F7: `HidServicesWrapper.getHidDevice` is silent on stdout + +By inspection of the call shape and how REPL uses it (no surrounding +"discovering devices…" banner around the call), this function does not +print. The standard-CLI bridge can call it without suppressors. (Confirmed +from REPL behavior: pre-sign device lookup happens silently.) + +### F8: Singleton state lifecycles + +| Singleton | Lifetime | Risk for standard CLI | +|-----------|----------|------------------------| +| `LedgerEventListener.INSTANCE` | Process | Holds `isTimeOutShutdown` and `ledgerSignEnd` `AtomicBoolean`s; both are reset on each `executeSignListen` call (lines 85, 73). One-shot CLI invocations are safe. Within a single process, two consecutive sign operations are also safe because each call resets. | +| `TransactionSignManager.INSTANCE` | Process | Holds the in-flight transaction and signature. REPL clears `setTransaction(null)` after consumption. The bridge must do the same on every exit path (success, reject, timeout, exception). | +| `LedgerSignResult` (file-backed) | Disk | Records last state per device path. Bridge must check this **after** `executeSignListen` to derive outcome. The file accumulates entries; existing REPL code does not prune it. Not a correctness concern. | + +For standard CLI's typical "one process per command" usage, the singleton +risk is minimal. The defensive pattern is: reset `TransactionSignManager` +state in a `finally` block. + +### F9: Standard CLI's Ledger detection rule is `wf.getName().contains("Ledger")` + +All three Ledger sign branches in `WalletApi.java` (lines 910, 973, 1064) +test `wf.getName().contains("Ledger")` rather than the +`WalletApi.isLedgerUser()` boolean. The boolean is set in the wrapper's +login paths and used in `WalletApi.removeWallet(...)` (line 3670), but **not** +on the sign path. + +The naming convention is enforced by `WalletApi.java:4652-4654`, which +auto-prefixes `Ledger-` to any wallet that started with that prefix. So: + +- **Source of truth on the sign path: filename prefix `Ledger-`** +- **Source of truth on the cleanup path: `isLedgerUser` boolean** + +This is a latent inconsistency. For this plan we **do not** unify it (out of +scope and risky); we follow the existing sign-path convention (filename) so +behavior is identical to REPL. + +### F10: GasFree path uses `gasfree=true` which short-circuits contract-type validation + +`LedgerSignUtil.requestLedgerSignLogic(transaction, path, address, gasfree)` +takes a `gasfree` boolean. When `true`, line 23-26 skips the +`ContractTypeChecker.canUseLedgerSign(...)` precheck. The bridge's sign +method therefore needs the same parameter / a sibling method. + +## Implications for design + +### Outcome enum is fully derivable + +``` +NO_DEVICE ← getHidDevice returned null +APP_NOT_OPEN ← handleTransSign returned 0x6511 +SIGN_BY_HASH_DISABLED ← handleTransSign returned 0x6a8c +SUBMIT_FAILED ← handleTransSign returned other non-empty bytes +ALREADY_SIGNING ← LedgerSignResult.getLastTransactionState was SIGN_RESULT_SIGNING before we started +USER_CONFIRMED ← signature found in TransactionSignManager after wait +USER_REJECTED ← LedgerSignResult.getLastTransactionState became SIGN_RESULT_REJECTED +TIMEOUT ← wait returned but neither signature nor reject state +``` + +Every transition above is derivable from existing public state. No hardware +needed to design this. + +### The bridge can polls the same state REPL writes + +REPL writes `LedgerSignResult` and `TransactionSignManager` from the HID +callback thread. The bridge reads the same state on the calling thread +after `executeSignListen` returns. This is the cleanest possible coupling +that avoids forking the shared listener. + +### Stdout suppression scope + +The bridge wraps `LedgerSignUtil`-equivalent operations. The wrapping must +suppress stdout because: + +- `LedgerEventListener.handleTransSign` will still print on APDU errors. +- `LedgerEventListener.waitAndShutdownWithInput` will still print the timeout + banner. +- `LedgerEventListener.hidDataReceived` will still print on confirm / cancel. + +These are not on our refactor target (shared with REPL). The bridge must +redirect `System.out` for the duration of the call. The runner already has +`OutputFormatter` machinery for stream suppression; the bridge reuses it. + +## Items that still need hardware verification + +These remain as Phase-end manual-QA gates, not blockers for design: + +| Item | Manual test | +|------|-------------| +| Real timing of `0x6a8c` and `0x6511` responses (synchronous vs delayed) | Try with "Sign By Hash" disabled / Tron app closed | +| Disconnect mid-sign: does `hidDataReceived` fire with a special code, or does the timeout simply elapse? | Pull USB while waiting for confirmation | +| Does the device reset its signing state when disconnected/reconnected? | Disconnect, reconnect, retry sign | +| 60-second wall-clock accuracy of `sleepNoInterruption` under JVM contention | Run with high CPU load | + +The bridge's defensive design (catch all exceptions → `SUBMIT_FAILED`, +clean `TransactionSignManager` in `finally`) covers all the above without +requiring us to know the exact answer. + +## Conclusions for the plan + +1. **Refactor target shrinks to two sign sites for standard CLI MVP** + (`WalletApi.signTransactionForCli` Ledger branch + `WalletApiWrapper` + gasfree branch). The other two sign sites stay REPL-only. + +2. **The `LedgerSigner` abstraction the elegant version called for is still + right** — but it can be applied just to the two standard-CLI sites, + leaving REPL's two sites untouched. This is a smaller refactor than + "introduce signer for all four sites." + +3. **No `Future.cancel(true)` needed.** Cooperative cancellation via the + existing `ledgerSignEnd` flag is sufficient. + +4. **No "minimum viable error codes" compromise.** All seven outcome enum + values are derivable from existing state; the plan can ship the full + taxonomy from day one. + +5. **Manual QA gates remain unchanged.** A reviewer with a real Ledger runs + a runbook to verify the four hardware-verifiable items before merge. diff --git a/docs/standard-cli-contract-spec.md b/docs/standard-cli-contract-spec.md index 43190f43c..c8a41b2cf 100644 --- a/docs/standard-cli-contract-spec.md +++ b/docs/standard-cli-contract-spec.md @@ -558,13 +558,68 @@ For invocations whose resolved auth policy is `REQUIRE`: - missing wallet directory is an execution error unless an explicit `--wallet` path resolves successfully - missing keystore is an execution error -- missing `MASTER_PASSWORD` is an execution error +- a missing master password (no `MASTER_PASSWORD` env var and no `--password-stdin` input) is an execution error - invalid password is an execution error - unreadable wallet metadata or keystore content is an execution error - the standard CLI must not fall back to interactive password prompts, wallet-selection prompts, permission prompts, confirmation prompts, or other interactive auth flows - "skip auto-login and let the handler fail later" is not allowed +### Password Source Resolution + +The standard CLI accepts the master password from two explicit sources. Both are non-interactive — neither involves +prompting the user for input. + +Sources, in precedence order: + +1. `--password-stdin` global flag — the runner reads the entire `System.in` once, strips a single trailing `\r?\n`, + and uses the result as the password. Internal whitespace is preserved verbatim. +2. `MASTER_PASSWORD` environment variable. + +Rules: + +- when both sources are present, `--password-stdin` wins; the runner may emit a text-mode info notice that the env + var was overridden, but JSON mode behavior is unaffected +- `--password-stdin` reads `System.in` exactly once; the read result is cached so wallet-authenticated commands that + consult the password more than once observe a stable value +- `--password-stdin` with empty input is an execution error with a message that explicitly identifies the empty-stdin + cause (distinct from "neither source set") +- `--password-stdin` invoked when stdin is detected to be an interactive terminal (TTY) is a usage error — reading + `System.in` would block on a prompt, which violates the non-interactive contract; this detection is best-effort, + may produce false negatives, and must not be relied on by callers as a hard guarantee +- `--password-stdin` is recognized regardless of position relative to the command token, consistent with other known + globals +- `--password-stdin` must not introduce a third source by way of fallback (e.g. reading `System.in` opportunistically + when the flag was not passed); the flag is the only trigger +- neither source may be substituted by an interactive prompt, a keychain lookup, or any other implicit channel that is + not declared by this contract + +### Ledger Hardware Wallet Sign Outcomes + +When the selected wallet is a Ledger keystore, the standard CLI signs through a connected Ledger device. The keystore +auth flow above applies unchanged — the password unlocks the BIP44 path metadata stored in the keystore, not the +device. The device is a separate auth boundary requiring on-device user confirmation; the funds are protected by the +device, not the password. + +Rules: + +- a Ledger sign must be non-interactive on the CLI side: no prompts on stdin or stdout, no menus, no `selectDevice` + call paths +- exactly one stderr notice may be emitted before the sign blocks on the device, indicating which address the user + must confirm; this notice must not be suppressed by JSON mode (it is the only signal that a human action is + required) but may be suppressed by `--quiet` +- prints from shared Ledger code (listener, HID wrapper) must not reach stdout in standard CLI mode; the runner is + responsible for capturing those during the sign +- the device discovery, sign request, and result polling must all complete without re-prompting the user; the selected + device must derive the keystore address at the stored path +- Ledger-specific failures must surface as execution errors with one of the documented `ledger_*` codes: + `ledger_not_connected`, `ledger_app_not_open`, `ledger_sign_by_hash_disabled`, + `ledger_unsupported_contract`, `ledger_already_signing`, `ledger_user_rejected`, `ledger_timeout`, + `ledger_sign_failed` +- all Ledger-specific error codes share the `ledger_` prefix for stable programmatic matching by agents +- the standard CLI must not introduce a Ledger sign path that bypasses keystore auth (e.g. a `--ledger-path` direct + mode); if such a path is added in the future it requires its own contract subsection + ### Handler Boundary - handlers must not re-decide runner auth policy ad hoc @@ -831,7 +886,8 @@ The standard CLI is not a thin alias for the legacy REPL path. - If a legacy path cannot satisfy the standard CLI contract cleanly, either adapt it explicitly or exclude it from the standard CLI guarantees. - Hidden stdin scripting, prompt auto-confirmation, or injected prompt answers are not allowed as standard CLI - behavior. + behavior. Explicit, opt-in stdin consumption that is part of a documented contract (e.g. `--password-stdin`) is + not "hidden" and is allowed. ### Interface Identity @@ -868,7 +924,8 @@ Rules: - if a command capability currently depends on interactive legacy flow, it should be adapted explicitly before being considered fully standard-CLI-compliant - hidden stdin feeding, prompt auto-confirmation, prompt suppression, or interactive fallback are not valid standard - CLI implementation techniques + CLI implementation techniques; explicit opt-in stdin consumption declared by a contract (e.g. `--password-stdin`) + is exempt from this prohibition ### Adaptation Strategy @@ -995,6 +1052,29 @@ Review feedback in this area has consistently pushed toward: - one reliable success/failure model - QA that validates supported behavior instead of historical implementation details +## Unified Address Book + +Standard CLI supports a per-network alias book layered over built-in token aliases. + +Rules: + +- built-in aliases are token-only and are loaded before user aliases +- user alias files live at `Wallet/aliases/.json` +- built-in names cannot be overridden or removed by standard CLI commands +- if a user manually edits a file to collide with a built-in name, the built-in entry remains authoritative +- account-position options resolve only account aliases +- contract-position options resolve token aliases, including `--contract` and `get-contract* --address` +- raw Base58Check and hex TRON addresses are accepted directly and are not recorded as alias resolutions +- alias hits are auditable: text mode emits a stderr resolution line, JSON mode includes `meta.resolved` +- aliases are not resolved inside packed string arguments such as `vote-witness --votes "address count ..."` + +Commands: + +- `alias-add --name --type --address
[--decimals n] [--note text]` +- `alias-remove --name ` +- `alias-list [--type ]` +- `alias-resolve --name [--type ]` + ## Change Management When changing behavior covered by this spec: diff --git a/docs/standard-cli-user-manual.md b/docs/standard-cli-user-manual.md index cdeb03b57..b770c6b0c 100644 --- a/docs/standard-cli-user-manual.md +++ b/docs/standard-cli-user-manual.md @@ -2880,3 +2880,78 @@ To set up multi-sig, use `update-account-permission` to configure the account's \* `register-wallet` requires `MASTER_PASSWORD` to be set (for keystore encryption) but does not authenticate against an existing wallet. **Auth legend:** Yes = always required | No = never required | Conditional = depends on options provided + +## Using a Ledger hardware wallet + +Standard CLI signs transactions through a connected Ledger device when the +selected wallet is a Ledger keystore. Authentication still uses +`MASTER_PASSWORD` / `--password-stdin` to unlock the keystore; the device +itself is the funds-protecting boundary. + +### One-time pairing (REPL) + +Path selection requires a human in the loop, so import is not exposed to +standard CLI. Pair once via the REPL: + +``` +./gradlew run +> importwalletbyledger +``` + +Choose a derivation path, set a local password, and note the resulting +wallet name (it will be prefixed with `Ledger-`). + +### Signing from standard CLI + +``` +echo "$LEDGER_KEYSTORE_PASSWORD" | java -jar build/libs/wallet-cli.jar \ + --password-stdin --output json \ + --wallet ledger-alpha \ + send-coin --to TXxx... --amount 1000000 +``` + +Requirements: + +- The Ledger must be connected, unlocked, and have the Tron app open. +- "Sign By Hash" must be set to **Allowed** in the Tron app's settings. + +Behavior: + +- One stderr notice appears: `Please confirm transaction on Ledger device for TXxx...`. +- Press the confirm button on the device. +- On success, stdout contains a JSON envelope with the transaction id. + +The same flow applies to every Ledger-supported signing command in standard CLI +(`send-coin`, `vote-witness`, `freeze-balance`, `trigger-contract`, +`gas-free-transfer`, etc.) — there is no Ledger-specific command. + +### About the keystore password + +The keystore password protects the BIP44 path metadata, not your funds. +Your private key never leaves the device. A Ledger keystore without the +device connected cannot sign even with the correct password. + +### Error codes + +All errors are returned as execution errors (exit code `1`) with one of +the following codes in the JSON envelope's `error` field: + +| Error code | Meaning | +|------------|---------| +| `ledger_not_connected` | No matching device found, or HID transport failure | +| `ledger_app_not_open` | The Tron app is not open on the device | +| `ledger_sign_by_hash_disabled` | "Sign By Hash" is not enabled in the Tron app's settings | +| `ledger_unsupported_contract` | The transaction type is not supported by Ledger signing | +| `ledger_already_signing` | A previous sign operation is still in progress on the device | +| `ledger_user_rejected` | The user pressed reject on the device | +| `ledger_timeout` | 60 seconds elapsed without confirmation or rejection | +| `ledger_sign_failed` | Other failure (unknown APDU, transport exception) | + +All Ledger-specific codes share the `ledger_` prefix for programmatic +matching by agent code. + +### Multi-device caveat + +The connected Ledger must derive the keystore address at the stored path. +If no connected device matches, the command returns `ledger_not_connected`. +This case is rare; a future flag may make multi-device selection explicit. diff --git a/qa/commands/alias.sh b/qa/commands/alias.sh new file mode 100755 index 000000000..be84c9594 --- /dev/null +++ b/qa/commands/alias.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +CLI="${CLI:-./gradlew -q run}" + +run_cli() { + # shellcheck disable=SC2206 + local cli_parts=($CLI) + "${cli_parts[@]}" --args="$*" +} + +run_cli "--output json --network main alias-resolve --name USDT --type TOKEN" \ + | grep -q '"address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"' + +run_cli "--network main alias-list --type TOKEN" \ + | grep -q 'USDT' + +if run_cli "--network main alias-add --name USDT --type TOKEN --address TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" >/tmp/wallet-cli-alias.out 2>&1; then + echo "expected built-in alias override to fail" >&2 + exit 1 +fi +grep -q 'built in' /tmp/wallet-cli-alias.out + +echo "alias QA passed" diff --git a/src/main/java/org/tron/common/utils/Utils.java b/src/main/java/org/tron/common/utils/Utils.java index a4e5f47f5..a6e8ac4f6 100644 --- a/src/main/java/org/tron/common/utils/Utils.java +++ b/src/main/java/org/tron/common/utils/Utils.java @@ -138,7 +138,7 @@ public class Utils { public static final int MIN_LENGTH = 2; public static final int MAX_LENGTH = 14; - public static final String VERSION = " v4.9.5"; + public static final String VERSION = " v4.9.6"; public static final String TRANSFER_METHOD_ID = "a9059cbb"; private static SecureRandom random = new SecureRandom(); diff --git a/src/main/java/org/tron/ledger/LedgerAddressUtil.java b/src/main/java/org/tron/ledger/LedgerAddressUtil.java index 4676eed24..8a7db7a6e 100644 --- a/src/main/java/org/tron/ledger/LedgerAddressUtil.java +++ b/src/main/java/org/tron/ledger/LedgerAddressUtil.java @@ -73,7 +73,12 @@ public static Map getMultiImportAddress(List paths, HidD return addressMap; } - public static String getTronAddress(String path, HidDevice hidDevice) { + /** + * Sends the "get address" APDU and returns the raw response bytes without parsing. + * Returns {@code null} on transport failure. Callers can inspect error status words + * (e.g. {@code 0x6511} = Tron app not open) before falling through to address parsing. + */ + public static byte[] getRawAddressResponse(String path, HidDevice hidDevice) { try { byte[] apdu = ApduMessageBuilder.buildTronAddressApduMessage(path); if (DebugConfig.isDebugEnabled()) { @@ -83,11 +88,25 @@ public static String getTronAddress(String path, HidDevice hidDevice) { if (DebugConfig.isDebugEnabled()) { System.out.println("Get Address Response: " + CommonUtil.bytesToHex(result)); } - if (LedgerConstant.LEDGER_LOCK.equalsIgnoreCase(CommonUtil.bytesToHex(result))) { - System.out.println(ANSI_RED + "Ledger is locked, please unlock it first"+ ANSI_RESET); - return EMPTY; + return result; + } catch (Exception e) { + if (DebugConfig.isDebugEnabled()) { + e.printStackTrace(); } + return null; + } + } + /** Parses a Tron Base58 address from a raw "get address" APDU response. Returns {@code ""} on any parse failure. */ + public static String parseTronAddress(byte[] result) { + if (result == null || result.length < 2) { + return EMPTY; + } + if (LedgerConstant.LEDGER_LOCK.equalsIgnoreCase(CommonUtil.bytesToHex(result))) { + System.out.println(ANSI_RED + "Ledger is locked, please unlock it first" + ANSI_RESET); + return EMPTY; + } + try { int offset = 0; int publicKeyLength = result[offset++] & 0xFF; byte[] publicKey = new byte[publicKeyLength]; @@ -98,6 +117,18 @@ public static String getTronAddress(String path, HidDevice hidDevice) { byte[] addressBytes = new byte[addressLength]; System.arraycopy(result, offset, addressBytes, 0, addressLength); return new String(addressBytes); + } catch (Exception e) { + if (DebugConfig.isDebugEnabled()) { + e.printStackTrace(); + } + return EMPTY; + } + } + + public static String getTronAddress(String path, HidDevice hidDevice) { + try { + byte[] result = getRawAddressResponse(path, hidDevice); + return parseTronAddress(result); } catch (Exception e) { System.err.println("Error: " + e.getMessage()); if (DebugConfig.isDebugEnabled()) { diff --git a/src/main/java/org/tron/ledger/listener/LedgerEventListener.java b/src/main/java/org/tron/ledger/listener/LedgerEventListener.java index bffe3156c..e12a8c9ff 100644 --- a/src/main/java/org/tron/ledger/listener/LedgerEventListener.java +++ b/src/main/java/org/tron/ledger/listener/LedgerEventListener.java @@ -28,6 +28,19 @@ public class LedgerEventListener extends BaseListener { @Getter private AtomicBoolean isTimeOutShutdown = new AtomicBoolean(false); + private volatile byte[] lastSendResult; + @Setter + private volatile boolean standardCliQuiet; + + /** + * Bytes returned by the most recent {@link #handleTransSign} call. May be {@code null} if no + * sign has been attempted yet, or if the call threw before assigning. Standard CLI's + * non-interactive Ledger bridge reads this to map APDU error codes (0x6511, 0x6a8c, …) to + * structured outcomes. REPL does not consult this field. + */ + public byte[] getLastSendResultBytes() { + return lastSendResult; + } @Getter @Setter private AtomicBoolean ledgerSignEnd = new AtomicBoolean(false); @@ -44,12 +57,12 @@ public static LedgerEventListener getInstance() { public boolean waitAndShutdownWithInput() { Thread shutdownThread = new Thread(() -> { - System.out.printf(ANSI_YELLOW + "If the Ledger confirms the signature, the transaction will be broadcast.\n" + ANSI_RESET); - System.out.printf(ANSI_YELLOW + "Current transaction sign will be closed after %ds.\n" + ANSI_RESET, TRANSACTION_SIGN_TIMEOUT); + print(ANSI_YELLOW + "If the Ledger confirms the signature, the transaction will be broadcast.\n" + ANSI_RESET); + printf(ANSI_YELLOW + "Current transaction sign will be closed after %ds.\n" + ANSI_RESET, TRANSACTION_SIGN_TIMEOUT); sleepNoInterruption(TRANSACTION_SIGN_TIMEOUT); shutdownHidServices(); if (DebugConfig.isDebugEnabled()) { - System.out.printf(ANSI_RED + "Shutdown thread finished\n" + ANSI_RESET); + print(ANSI_RED + "Shutdown thread finished\n" + ANSI_RESET); } }); @@ -67,7 +80,7 @@ public boolean waitAndShutdownWithInput() { private synchronized void shutdownHidServices() { if (!isTimeOutShutdown.get()) { if (DebugConfig.isDebugEnabled()) { - System.out.printf(ANSI_YELLOW + "Ledger sign shutdown...%n" + ANSI_RESET); + printf(ANSI_YELLOW + "Ledger sign shutdown...%n" + ANSI_RESET); } ledgerSignEnd.compareAndSet(false, true); isTimeOutShutdown.compareAndSet(false,true); @@ -77,10 +90,14 @@ private synchronized void shutdownHidServices() { public boolean executeSignListen(HidDevice hidDevice, Chain.Transaction transaction, String path, boolean gasfree) { boolean ret = false; + // Reset before each sign so a prior call's APDU bytes never leak into the next sign's + // outcome computation if handleTransSign throws on this invocation. + this.lastSendResult = null; try { byte[] sendResult = handleTransSign(hidDevice, transaction, path, gasfree); + this.lastSendResult = sendResult; if (sendResult == null) { - System.out.println("Transaction sign request is sent to Ledger"); + println("Transaction sign request is sent to Ledger"); TransactionSignManager.getInstance().setHidDevice(hidDevice); isTimeOutShutdown.compareAndSet(true,false); String transactionId = getTransactionId(transaction).toString(); @@ -131,17 +148,17 @@ public byte[] handleTransSign(HidDevice hidDevice, Chain.Transaction transaction if (ArrayUtils.isNotEmpty(response)) { if (SIGN_BY_HASH.equals(bytesToHex(response))) { - System.out.println(ANSI_RED + "Please first set 'Sign By Hash' to 'Allowed' in Ledger TRON Settings." + ANSI_RESET); + println(ANSI_RED + "Please first set 'Sign By Hash' to 'Allowed' in Ledger TRON Settings." + ANSI_RESET); } if (APP_IS_OPEN.equals(bytesToHex(response))) { - System.out.println(ANSI_RED + "Please ensure The Tron app is open in your Ledger device. Usually, 'Application is ready' will be displayed on your ledger device." + ANSI_RESET); + println(ANSI_RED + "Please ensure The Tron app is open in your Ledger device. Usually, 'Application is ready' will be displayed on your ledger device." + ANSI_RESET); } if (DebugConfig.isDebugEnabled()) { - System.out.println("HandleTransSign response: " + bytesToHex(response)); + println("HandleTransSign response: " + bytesToHex(response)); } } else { if (DebugConfig.isDebugEnabled()) { - System.out.println("HandleTransSign response is null"); + println("HandleTransSign response is null"); } } return response; @@ -158,38 +175,39 @@ public void hidDataReceived(HidServicesEvent event) { byte[] unwrappedResponse = LedgerProtocol.unwrapResponseAPDU( LedgerConstant.CHANNEL, response, LedgerConstant.PACKET_SIZE, false); if (DebugConfig.isDebugEnabled()) { - System.out.println("Received unwrappedResponse: " + CommonUtil.bytesToHex(unwrappedResponse)); + println("Received unwrappedResponse: " + CommonUtil.bytesToHex(unwrappedResponse)); } if (LEDGER_SIGN_CANCEL.equalsIgnoreCase(CommonUtil.bytesToHex(unwrappedResponse))) { HidDevice hidDevice = TransactionSignManager.getInstance().getHidDevice(); LedgerSignResult.updateAllSigningToReject(hidDevice.getPath()); - System.out.println("\nCancel sign from Ledger"); + println("\nCancel sign from Ledger"); doLedgerSignEnd(); hidDevice.close(); } else { Chain.Transaction transaction = TransactionSignManager.getInstance().getTransaction(); if (transaction == null) { if (DebugConfig.isDebugEnabled()) { - System.out.println("Transaction is null"); + println("Transaction is null"); } HidDevice hidDevice = TransactionSignManager.getInstance().getHidDevice(); LedgerSignResult.updateAllSigningToReject(hidDevice.getPath()); if (DebugConfig.isDebugEnabled()) { - System.out.println("Do updateAllSigningToReject"); + println("Do updateAllSigningToReject"); } hidDevice.close(); + standardCliQuiet = false; } else { if (DebugConfig.isDebugEnabled()) { - System.out.println("Transaction is not null"); + println("Transaction is not null"); } String transactionId = getTransactionId(transaction).toString(); if (!isTimeOutShutdown.get()) { - System.out.println("\nConfirm sign from Ledger"); + println("\nConfirm sign from Ledger"); byte[] signature = Arrays.copyOfRange(unwrappedResponse, 0, 65); if (DebugConfig.isDebugEnabled()) { - System.out.println("Signature: " + CommonUtil.bytesToHex(signature)); + println("Signature: " + CommonUtil.bytesToHex(signature)); } TransactionSignManager.getInstance().generateGasFreeSignature(signature); TransactionSignManager.getInstance().addTransactionSign(signature); @@ -198,11 +216,11 @@ public void hidDataReceived(HidServicesEvent event) { , transactionId, LedgerSignResult.SIGN_RESULT_SUCCESS ); } else { - System.out.println("TransactionId: " + transactionId); - System.out.println("This transaction has expired, please resign and submit again."); + println("TransactionId: " + transactionId); + println("This transaction has expired, please resign and submit again."); LedgerSignResult.updateState( TransactionSignManager.getInstance().getHidDevice().getPath() - , transactionId, LedgerSignResult.SIGN_RESULT_CANCEL + , transactionId, LedgerSignResult.SIGN_RESULT_TIMEOUT ); } doLedgerSignEnd(); @@ -216,5 +234,24 @@ private void doLedgerSignEnd() { TransactionSignManager.getInstance().getHidDevice().close(); TransactionSignManager.getInstance().setHidDevice(null); } + standardCliQuiet = false; + } + + private void println(String message) { + if (!standardCliQuiet) { + System.out.println(message); + } + } + + private void print(String message) { + if (!standardCliQuiet) { + System.out.print(message); + } + } + + private void printf(String format, Object... args) { + if (!standardCliQuiet) { + System.out.printf(format, args); + } } } diff --git a/src/main/java/org/tron/ledger/wrapper/HidServicesWrapper.java b/src/main/java/org/tron/ledger/wrapper/HidServicesWrapper.java index dccce0216..aae497e92 100644 --- a/src/main/java/org/tron/ledger/wrapper/HidServicesWrapper.java +++ b/src/main/java/org/tron/ledger/wrapper/HidServicesWrapper.java @@ -62,6 +62,11 @@ public HidServices initHidServices() { return hs; } + public boolean hasAnyLedgerAttached() { + return getHidServices().getAttachedHidDevices().stream() + .anyMatch(d -> d.getVendorId() == LEDGER_VENDOR_ID); + } + public static HidDevice getLedgerHidDevice(HidServices hidServices, String address, String path) { List hidDeviceList = new ArrayList<>(); HidDevice fidoDevice = null; diff --git a/src/main/java/org/tron/ledger/wrapper/LedgerSignResult.java b/src/main/java/org/tron/ledger/wrapper/LedgerSignResult.java index 1fae95e06..f17924253 100644 --- a/src/main/java/org/tron/ledger/wrapper/LedgerSignResult.java +++ b/src/main/java/org/tron/ledger/wrapper/LedgerSignResult.java @@ -17,6 +17,7 @@ public class LedgerSignResult { public static final String SIGN_RESULT_SIGNING = "signing"; public static final String SIGN_RESULT_SUCCESS = "confirmed"; public static final String SIGN_RESULT_CANCEL = "cancel"; + public static final String SIGN_RESULT_TIMEOUT = "timeout"; private static final ReadWriteLock lock = new ReentrantReadWriteLock(); private static final String DIRECTORY = "Ledger"; @@ -109,6 +110,35 @@ public static void appendLineIfNotExists(String devicePath, String txid, String } } + public static void upsertState(String devicePath, String txid, String state) { + lock.writeLock().lock(); + try { + List lines = readAllLines(devicePath); + List updatedLines = new ArrayList<>(); + boolean updated = false; + for (String line : lines) { + if (line.startsWith(txid + ":")) { + updatedLines.add(txid + ":" + state); + updated = true; + } else { + updatedLines.add(line); + } + } + if (!updated) { + updatedLines.add(txid + ":" + state); + } + Path path = getFilePath(devicePath); + Files.createDirectories(path.getParent()); + writeAllLines(devicePath, updatedLines); + } catch (IOException e) { + if (DebugConfig.isDebugEnabled()) { + System.err.println("Error upserting sign state: " + e.getMessage()); + } + } finally { + lock.writeLock().unlock(); + } + } + // Update the state for a specific txid public static void updateState(String devicePath, String txid, String newState) { lock.writeLock().lock(); diff --git a/src/main/java/org/tron/walletcli/Client.java b/src/main/java/org/tron/walletcli/Client.java index dc5d1266c..717a6723b 100755 --- a/src/main/java/org/tron/walletcli/Client.java +++ b/src/main/java/org/tron/walletcli/Client.java @@ -4820,6 +4820,7 @@ private static CommandRegistry initRegistry() { org.tron.walletcli.cli.commands.WitnessCommands.register(registry); org.tron.walletcli.cli.commands.ProposalCommands.register(registry); org.tron.walletcli.cli.commands.ExchangeCommands.register(registry); + org.tron.walletcli.cli.commands.AliasCommands.register(registry); org.tron.walletcli.cli.commands.WalletCommands.register(registry); org.tron.walletcli.cli.commands.MiscCommands.register(registry); return registry; diff --git a/src/main/java/org/tron/walletcli/WalletApiWrapper.java b/src/main/java/org/tron/walletcli/WalletApiWrapper.java index 61f3863a1..ac4eecc9d 100644 --- a/src/main/java/org/tron/walletcli/WalletApiWrapper.java +++ b/src/main/java/org/tron/walletcli/WalletApiWrapper.java @@ -125,6 +125,12 @@ public class WalletApiWrapper { @Getter @Setter private WalletApi wallet; + + public void setLedgerSigner(org.tron.walletcli.cli.ledger.LedgerSigner ledgerSigner) { + if (wallet != null) { + wallet.setLedgerSigner(ledgerSigner); + } + } private String lastGasFreeId; private static final String MnemonicFilePath = "Mnemonic"; private static final String GAS_FREE_SUPPORT_NETWORK_TIP = "Gas free currently only supports the " + blueBoldHighlight("MAIN") + " network and " + blueBoldHighlight("NILE") + " test network, and does not support other networks at the moment."; @@ -3268,22 +3274,32 @@ private boolean gasFreeTransferInternal(String receiver, long value, boolean sta if (isLedgerFile) { Chain.Transaction transaction = Chain.Transaction.newBuilder().setRawData( Chain.Transaction.raw.newBuilder().setData(ByteString.copyFrom(keccak256(concat)))).build(); - boolean ledgerResult = LedgerSignUtil.requestLedgerSignLogic(transaction, ledgerPath, wf.getAddress(), true); - if (ledgerResult) { - signature = TransactionSignManager.getInstance().getGasfreeSignature(); - } - if (Objects.isNull(signature)) { - TransactionSignManager.getInstance().setTransaction(null); - TransactionSignManager.getInstance().setGasfreeSignature(null); - if (standardCli) { + if (standardCli) { + org.tron.walletcli.cli.ledger.LedgerSigner ledgerSigner = wallet.getLedgerSigner(); + if (ledgerSigner == null) { throw new CommandErrorException("execution_error", - "Listening ledger did not obtain signature."); + "Standard CLI Ledger signer not initialized"); } - System.out.println("Listening ledger did not obtain signature."); - return false; + org.tron.walletcli.cli.ledger.LedgerSignOutcome r = + ledgerSigner.sign(transaction, ledgerPath, wf.getAddress(), true); + if (r.getStatus() != org.tron.walletcli.cli.ledger.LedgerSignOutcome.Status.OK) { + throw new CommandErrorException(r.errorCode(), r.getMessage()); + } + signature = r.getGasfreeSignature(); + } else { + boolean ledgerResult = LedgerSignUtil.requestLedgerSignLogic(transaction, ledgerPath, wf.getAddress(), true); + if (ledgerResult) { + signature = TransactionSignManager.getInstance().getGasfreeSignature(); + } + if (Objects.isNull(signature)) { + TransactionSignManager.getInstance().setTransaction(null); + TransactionSignManager.getInstance().setGasfreeSignature(null); + System.out.println("Listening ledger did not obtain signature."); + return false; + } + TransactionSignManager.getInstance().setTransaction(null); + TransactionSignManager.getInstance().setGasfreeSignature(null); } - TransactionSignManager.getInstance().setTransaction(null); - TransactionSignManager.getInstance().setGasfreeSignature(null); } else { privateKeyBytes = credentials.getPair().getPrivKeyBytes(); signature = signOffChain(keccak256(concat), privateKeyBytes); diff --git a/src/main/java/org/tron/walletcli/cli/CommandContext.java b/src/main/java/org/tron/walletcli/cli/CommandContext.java index 11ece7427..1d3c8dc09 100644 --- a/src/main/java/org/tron/walletcli/cli/CommandContext.java +++ b/src/main/java/org/tron/walletcli/cli/CommandContext.java @@ -4,25 +4,33 @@ public class CommandContext { - private static final CommandContext EMPTY = new CommandContext(null, null, null); + private static final CommandContext EMPTY = new CommandContext(null, null, null, null); private final String walletOverride; private final File resolvedAuthWalletFile; private final StandardCliRunner.MasterPasswordProvider masterPasswordProvider; + private final org.tron.walletcli.cli.aliases.AliasResolver aliasResolver; public CommandContext(String walletOverride) { - this(walletOverride, null, null); + this(walletOverride, null, null, null); } public CommandContext(String walletOverride, File resolvedAuthWalletFile) { - this(walletOverride, resolvedAuthWalletFile, null); + this(walletOverride, resolvedAuthWalletFile, null, null); } public CommandContext(String walletOverride, File resolvedAuthWalletFile, StandardCliRunner.MasterPasswordProvider masterPasswordProvider) { + this(walletOverride, resolvedAuthWalletFile, masterPasswordProvider, null); + } + + public CommandContext(String walletOverride, File resolvedAuthWalletFile, + StandardCliRunner.MasterPasswordProvider masterPasswordProvider, + org.tron.walletcli.cli.aliases.AliasResolver aliasResolver) { this.walletOverride = walletOverride; this.resolvedAuthWalletFile = resolvedAuthWalletFile; this.masterPasswordProvider = masterPasswordProvider; + this.aliasResolver = aliasResolver; } public static CommandContext empty() { @@ -34,7 +42,18 @@ public static CommandContext fromGlobalOptions(GlobalOptions globalOptions, return new CommandContext( globalOptions != null ? globalOptions.getWallet() : null, null, - masterPasswordProvider); + masterPasswordProvider, + null); + } + + public static CommandContext fromGlobalOptions(GlobalOptions globalOptions, + StandardCliRunner.MasterPasswordProvider masterPasswordProvider, + org.tron.walletcli.cli.aliases.AliasResolver aliasResolver) { + return new CommandContext( + globalOptions != null ? globalOptions.getWallet() : null, + null, + masterPasswordProvider, + aliasResolver); } public String getWalletOverride() { @@ -49,7 +68,11 @@ public String getMasterPassword() { return masterPasswordProvider != null ? masterPasswordProvider.get() : null; } + public org.tron.walletcli.cli.aliases.AliasResolver getAliasResolver() { + return aliasResolver; + } + public CommandContext withResolvedAuthWalletFile(File file) { - return new CommandContext(walletOverride, file, masterPasswordProvider); + return new CommandContext(walletOverride, file, masterPasswordProvider, aliasResolver); } } diff --git a/src/main/java/org/tron/walletcli/cli/CommandDefinition.java b/src/main/java/org/tron/walletcli/cli/CommandDefinition.java index d09f1531a..fbeb33642 100644 --- a/src/main/java/org/tron/walletcli/cli/CommandDefinition.java +++ b/src/main/java/org/tron/walletcli/cli/CommandDefinition.java @@ -6,6 +6,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.tron.walletcli.cli.aliases.AliasResolver; /** * Immutable metadata for a single CLI command: name, aliases, description, @@ -90,6 +91,10 @@ public AuthPolicy resolveAuthPolicy(ParsedOptions opts) { * @throws IllegalArgumentException if required options are missing or args are malformed */ public ParsedOptions parseArgs(String[] args) { + return parseArgs(args, null); + } + + public ParsedOptions parseArgs(String[] args, AliasResolver aliasResolver) { Map values = new LinkedHashMap(); Map optionsByName = new LinkedHashMap(); @@ -171,7 +176,7 @@ public ParsedOptions parseArgs(String[] args) { throw new CliUsageException(sb.toString()); } - return new ParsedOptions(values); + return new ParsedOptions(values, aliasResolver); } private static String requireNonEmptyValue(String optionName, String rawValue) { diff --git a/src/main/java/org/tron/walletcli/cli/CommandRegistry.java b/src/main/java/org/tron/walletcli/cli/CommandRegistry.java index d8d8883bb..7506578df 100644 --- a/src/main/java/org/tron/walletcli/cli/CommandRegistry.java +++ b/src/main/java/org/tron/walletcli/cli/CommandRegistry.java @@ -65,7 +65,8 @@ public String formatGlobalHelp(String version) { sb.append(" --wallet Select wallet file\n"); sb.append(" --grpc-endpoint Custom gRPC endpoint\n"); sb.append(" --quiet Suppress non-essential output\n"); - sb.append(" --verbose Debug logging\n\n"); + sb.append(" --verbose Debug logging\n"); + sb.append(" --password-stdin Read MASTER_PASSWORD from stdin (overrides env)\n\n"); sb.append("Commands:\n"); int maxLen = 0; diff --git a/src/main/java/org/tron/walletcli/cli/GlobalOptions.java b/src/main/java/org/tron/walletcli/cli/GlobalOptions.java index 67fd3dcb0..34adf8d54 100644 --- a/src/main/java/org/tron/walletcli/cli/GlobalOptions.java +++ b/src/main/java/org/tron/walletcli/cli/GlobalOptions.java @@ -15,6 +15,7 @@ public class GlobalOptions { private String grpcEndpoint = null; private boolean quiet = false; private boolean verbose = false; + private boolean passwordStdin = false; private String command = null; private String[] commandArgs = new String[0]; @@ -27,6 +28,7 @@ public class GlobalOptions { public String getGrpcEndpoint() { return grpcEndpoint; } public boolean isQuiet() { return quiet; } public boolean isVerbose() { return verbose; } + public boolean isPasswordStdin() { return passwordStdin; } public String getCommand() { return command; } public String[] getCommandArgs() { return java.util.Arrays.copyOf(commandArgs, commandArgs.length); } @@ -114,6 +116,10 @@ public static GlobalOptions parse(String[] args) { } opts.verbose = true; break; + case "password-stdin": + ensureNoInlineValue(parsed, "--password-stdin"); + opts.passwordStdin = true; + break; case "output": ensureNotRepeated(outputSeen, "--output"); outputSeen = true; diff --git a/src/main/java/org/tron/walletcli/cli/OutputFormatter.java b/src/main/java/org/tron/walletcli/cli/OutputFormatter.java index 55416fe51..2518f14a1 100644 --- a/src/main/java/org/tron/walletcli/cli/OutputFormatter.java +++ b/src/main/java/org/tron/walletcli/cli/OutputFormatter.java @@ -11,6 +11,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.tron.walletcli.cli.aliases.ResolutionResult; public class OutputFormatter { @@ -24,6 +25,7 @@ public enum OutputMode { TEXT, JSON } private final PrintStream out; private final PrintStream err; private Outcome outcome; + private final List> resolvedAliases = new ArrayList>(); private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); public OutputFormatter(OutputMode mode, boolean quiet) { @@ -45,6 +47,7 @@ private void emitJsonSuccess(Object data) { Map envelope = new LinkedHashMap(); envelope.put("success", true); envelope.put("data", data != null ? data : new LinkedHashMap()); + addResolvedMeta(envelope); out.println(gson.toJson(envelope)); } @@ -53,9 +56,19 @@ private void emitJsonError(String code, String message) { envelope.put("success", false); envelope.put("error", code); envelope.put("message", message); + addResolvedMeta(envelope); out.println(gson.toJson(envelope)); } + private void addResolvedMeta(Map envelope) { + if (resolvedAliases.isEmpty()) { + return; + } + Map meta = new LinkedHashMap(); + meta.put("resolved", new ArrayList>(resolvedAliases)); + envelope.put("meta", meta); + } + private Map wrapMessage(String text) { Map data = new LinkedHashMap(); data.put("message", text); @@ -147,6 +160,7 @@ public void flush() { renderMetadata(current.jsonData); } } + resolvedAliases.clear(); return; } @@ -159,6 +173,19 @@ public void flush() { err.println(current.usageHelp); } } + resolvedAliases.clear(); + } + + public void resolved(ResolutionResult result) { + if (result == null || !result.isAliasHit()) { + return; + } + resolvedAliases.add(result.toJsonMap()); + if (!quiet && mode == OutputMode.TEXT) { + err.println("Resolved --" + result.getOption() + " " + + result.getInput() + " -> " + result.getAddressBase58() + + " (" + result.getType().name() + ", " + result.getSource() + ")"); + } } /** Print a successful result with a text message and optional JSON data. */ @@ -326,6 +353,17 @@ public void info(String message) { } } + /** + * Print a notice to stderr that must reach the user even in JSON mode (e.g. "press the button + * on your Ledger now"). Suppressed only by --quiet. Stderr keeps the JSON envelope on stdout + * undisturbed, so agents parsing stdout are unaffected. + */ + public void notice(String message) { + if (!quiet) { + err.println(message); + } + } + private static final class Outcome { private final boolean success; private final String textMessage; diff --git a/src/main/java/org/tron/walletcli/cli/ParsedOptions.java b/src/main/java/org/tron/walletcli/cli/ParsedOptions.java index b8f9a3d9f..f5004a3ef 100644 --- a/src/main/java/org/tron/walletcli/cli/ParsedOptions.java +++ b/src/main/java/org/tron/walletcli/cli/ParsedOptions.java @@ -1,9 +1,14 @@ package org.tron.walletcli.cli; import org.tron.walletserver.WalletApi; +import org.tron.walletcli.cli.aliases.AliasResolver; +import org.tron.walletcli.cli.aliases.AliasType; +import org.tron.walletcli.cli.aliases.ResolutionResult; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -13,11 +18,17 @@ public class ParsedOptions { private final Map values; + private final AliasResolver aliasResolver; public ParsedOptions(Map values) { + this(values, null); + } + + public ParsedOptions(Map values, AliasResolver aliasResolver) { this.values = values == null ? Collections.emptyMap() : new LinkedHashMap(values); + this.aliasResolver = aliasResolver; } /** Returns {@code true} if the option was supplied on the command line. */ @@ -83,10 +94,35 @@ public boolean getBoolean(String key) { * @throws IllegalArgumentException if the key is absent or the address is invalid */ public byte[] getAddress(String key) { + String raw = requireValue(key); + return decodeBase58Address(key, raw); + } + + public byte[] getAccountAddress(String key) { + return getResolvedAddress(key, AliasType.ACCOUNT); + } + + public byte[] getContractAddress(String key) { + return getResolvedAddress(key, AliasType.TOKEN); + } + + private byte[] getResolvedAddress(String key, AliasType expectedType) { + String raw = requireValue(key); + if (aliasResolver != null) { + return aliasResolver.resolve(key, raw, expectedType).getAddress(); + } + return decodeBase58Address(key, raw); + } + + private String requireValue(String key) { String raw = values.get(key); if (raw == null) { throw new IllegalArgumentException("Missing required option: --" + key); } + return raw; + } + + private byte[] decodeBase58Address(String key, String raw) { byte[] decoded = WalletApi.decodeFromBase58Check(raw); if (decoded == null) { throw new IllegalArgumentException( @@ -95,6 +131,12 @@ public byte[] getAddress(String key) { return decoded; } + public List getResolutionLog() { + if (aliasResolver == null) { + return Collections.unmodifiableList(new ArrayList()); + } + return aliasResolver.getResolved(); + } /** Returns an unmodifiable view of all parsed values. */ public Map asMap() { diff --git a/src/main/java/org/tron/walletcli/cli/StandardCliRunner.java b/src/main/java/org/tron/walletcli/cli/StandardCliRunner.java index 0504f727b..52ec5d619 100644 --- a/src/main/java/org/tron/walletcli/cli/StandardCliRunner.java +++ b/src/main/java/org/tron/walletcli/cli/StandardCliRunner.java @@ -6,10 +6,14 @@ import org.tron.keystore.WalletFile; import org.tron.walletserver.ApiClient; import org.tron.walletcli.WalletApiWrapper; +import org.tron.walletcli.cli.aliases.AliasResolver; +import org.tron.walletcli.cli.aliases.AliasStore; +import org.tron.walletcli.cli.aliases.AliasStoreLoader; import org.tron.walletserver.WalletApi; import org.apache.commons.lang3.tuple.Pair; import java.io.File; +import java.io.InputStream; import java.io.PrintStream; import java.util.Arrays; @@ -30,7 +34,7 @@ public StandardCliRunner(CommandRegistry registry, GlobalOptions globalOpts) { StandardCliRunner(CommandRegistry registry, GlobalOptions globalOpts, PrintStream out, PrintStream err) { - this(registry, globalOpts, out, err, () -> System.getenv("MASTER_PASSWORD")); + this(registry, globalOpts, out, err, defaultProvider(globalOpts, System.in)); } StandardCliRunner(CommandRegistry registry, GlobalOptions globalOpts, @@ -47,6 +51,20 @@ public StandardCliRunner(CommandRegistry registry, GlobalOptions globalOpts) { this.masterPasswordProvider = masterPasswordProvider; } + /** + * Builds the default password provider for the runner. {@code --password-stdin} takes + * precedence over the {@code MASTER_PASSWORD} env var (matches the docker convention) so a + * caller piping in a fresh credential always wins over a stale exported env. The stdin reader + * is memoized — wallet-authenticated commands may consult the password more than once and + * stdin can only be drained once. + */ + static MasterPasswordProvider defaultProvider(GlobalOptions globalOpts, InputStream stdin) { + if (globalOpts.isPasswordStdin()) { + return new StdinPasswordReader(stdin); + } + return () -> System.getenv("MASTER_PASSWORD"); + } + public int execute() { try { return executeInternal(); @@ -82,16 +100,18 @@ private int executeInternal() { formatter.flush(); return 0; } + AliasResolver aliasResolver = buildAliasResolver(); // Parse command options ParsedOptions opts; try { - opts = cmd.parseArgs(cmdArgs); + opts = cmd.parseArgs(cmdArgs, aliasResolver); } catch (IllegalArgumentException e) { formatter.usageError(e.getMessage(), cmd); return 2; // unreachable after usageError() } - CommandContext ctx = CommandContext.fromGlobalOptions(globalOpts, masterPasswordProvider); + CommandContext ctx = CommandContext.fromGlobalOptions( + globalOpts, masterPasswordProvider, aliasResolver); // Create wrapper and authenticate WalletApiWrapper wrapper = new WalletApiWrapper(); @@ -135,14 +155,37 @@ static boolean requiresAutoAuth(CommandDefinition cmd, ParsedOptions opts) { return cmd.resolveAuthPolicy(opts) == CommandDefinition.AuthPolicy.REQUIRE; } + private AliasResolver buildAliasResolver() throws Exception { + AliasStore store = new AliasStoreLoader().loadLayered(WalletApi.getCurrentNetwork()); + return new AliasResolver(store, formatter::resolved); + } + /** * Auto-login from the resolved keystore target for wallet-authenticated standard CLI commands. */ private File authenticate(WalletApiWrapper wrapper) throws Exception { File targetFile = resolveAuthenticationWalletFile(); + if (globalOpts.isPasswordStdin()) { + // System.console() is non-null only when both stdin and stdout are TTYs. That's a + // strong signal the user forgot to pipe — reading System.in would block on a prompt. + // Imperfect (false negative when stdout is redirected), but catches the common + // "ran --password-stdin in an interactive shell without `echo pw |` prefix" footgun. + if (System.console() != null) { + throw new CliUsageException( + "--password-stdin requires piped input on stdin (e.g. `echo \"$pw\" | wallet-cli --password-stdin ...`)"); + } + if (System.getenv("MASTER_PASSWORD") != null + && !System.getenv("MASTER_PASSWORD").isEmpty()) { + formatter.info("--password-stdin overrides MASTER_PASSWORD env var"); + } + } String envPwd = masterPasswordProvider.get(); if (envPwd == null || envPwd.isEmpty()) { - throw new IllegalStateException("MASTER_PASSWORD is required for wallet-authenticated commands"); + throw new IllegalStateException( + globalOpts.isPasswordStdin() + ? "MASTER_PASSWORD is required: --password-stdin produced no input" + : "MASTER_PASSWORD is required for wallet-authenticated commands" + + " (set the env var or pass --password-stdin)"); } // Load specific wallet file and authenticate @@ -170,6 +213,8 @@ private File authenticate(WalletApiWrapper wrapper) throws Exception { // copy there and only clear this temporary buffer locally. walletApi.setUnifiedPassword(Arrays.copyOf(password, password.length)); wrapper.setWallet(walletApi); + wrapper.setLedgerSigner( + org.tron.walletcli.cli.ledger.ProductionLedgerPorts.buildSigner(formatter)); formatter.info("Authenticated with wallet: " + wf.getAddress()); return targetFile; } finally { diff --git a/src/main/java/org/tron/walletcli/cli/StdinPasswordReader.java b/src/main/java/org/tron/walletcli/cli/StdinPasswordReader.java new file mode 100644 index 000000000..2126f387e --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/StdinPasswordReader.java @@ -0,0 +1,71 @@ +package org.tron.walletcli.cli; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** + * Reads MASTER_PASSWORD from an {@link InputStream} (typically {@code System.in}) once and caches + * the result. Designed for the {@code --password-stdin} flow where a password is piped in from + * a credential helper such as {@code op read} or {@code printf}. + * + *

Trailing single {@code \n} or {@code \r\n} is stripped (so {@code echo "$pw"} works), but + * internal whitespace and other characters are preserved verbatim — passwords may legitimately + * contain spaces. + */ +final class StdinPasswordReader implements StandardCliRunner.MasterPasswordProvider { + + private final InputStream in; + private boolean read; + private String value; + + StdinPasswordReader(InputStream in) { + this.in = in; + } + + @Override + public synchronized String get() { + if (read) { + return value; + } + read = true; + value = readAll(); + return value; + } + + private String readAll() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] chunk = new byte[256]; + byte[] bytes = null; + try { + int n; + while ((n = in.read(chunk)) != -1) { + buf.write(chunk, 0, n); + } + if (buf.size() == 0) { + return null; + } + bytes = buf.toByteArray(); + int len = bytes.length; + if (len > 0 && bytes[len - 1] == '\n') { + len--; + if (len > 0 && bytes[len - 1] == '\r') { + len--; + } + } + if (len == 0) { + return null; + } + return new String(bytes, 0, len, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("Failed to read password from stdin: " + e.getMessage(), e); + } finally { + Arrays.fill(chunk, (byte) 0); + if (bytes != null) { + Arrays.fill(bytes, (byte) 0); + } + } + } +} diff --git a/src/main/java/org/tron/walletcli/cli/aliases/AliasEntry.java b/src/main/java/org/tron/walletcli/cli/aliases/AliasEntry.java new file mode 100644 index 000000000..a9b0d7183 --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/aliases/AliasEntry.java @@ -0,0 +1,77 @@ +package org.tron.walletcli.cli.aliases; + +import java.util.Locale; + +public final class AliasEntry { + + private final String name; + private final AliasType type; + private final byte[] address; + private final int decimals; + private final String source; + private final String note; + + private AliasEntry(String name, AliasType type, byte[] address, + int decimals, String source, String note) { + if (name == null) { + throw new IllegalArgumentException("name must not be null"); + } + String n = name.trim().toUpperCase(Locale.ROOT); + if (n.isEmpty()) { + throw new IllegalArgumentException("name must not be blank"); + } + if (type == null) { + throw new IllegalArgumentException("type must not be null"); + } + if (address == null) { + throw new IllegalArgumentException("address must not be null"); + } + if (address.length != 21) { + throw new IllegalArgumentException("address must be 21 bytes, got " + address.length); + } + if (source == null || source.trim().isEmpty()) { + throw new IllegalArgumentException("source must not be blank"); + } + if (type == AliasType.TOKEN && (decimals < 0 || decimals > 18)) { + throw new IllegalArgumentException("token decimals must be between 0 and 18"); + } + this.name = n; + this.type = type; + this.address = address.clone(); + this.decimals = decimals; + this.source = source; + this.note = note; + } + + public static AliasEntry token(String name, byte[] address, int decimals, String source) { + return new AliasEntry(name, AliasType.TOKEN, address, decimals, source, null); + } + + public static AliasEntry account(String name, byte[] address, String source, String note) { + return new AliasEntry(name, AliasType.ACCOUNT, address, 0, source, note); + } + + public String getName() { + return name; + } + + public AliasType getType() { + return type; + } + + public byte[] getAddress() { + return address.clone(); + } + + public int getDecimals() { + return decimals; + } + + public String getSource() { + return source; + } + + public String getNote() { + return note; + } +} diff --git a/src/main/java/org/tron/walletcli/cli/aliases/AliasResolutionException.java b/src/main/java/org/tron/walletcli/cli/aliases/AliasResolutionException.java new file mode 100644 index 000000000..9b75314ea --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/aliases/AliasResolutionException.java @@ -0,0 +1,12 @@ +package org.tron.walletcli.cli.aliases; + +public class AliasResolutionException extends IllegalArgumentException { + public AliasResolutionException(String message) { + super(message); + } + + @Override + public Throwable fillInStackTrace() { + return this; + } +} diff --git a/src/main/java/org/tron/walletcli/cli/aliases/AliasResolver.java b/src/main/java/org/tron/walletcli/cli/aliases/AliasResolver.java new file mode 100644 index 000000000..5b6d7601f --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/aliases/AliasResolver.java @@ -0,0 +1,95 @@ +package org.tron.walletcli.cli.aliases; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.bouncycastle.util.encoders.Hex; +import org.tron.walletserver.WalletApi; + +public final class AliasResolver { + + public interface Listener { + void resolved(ResolutionResult result); + } + + private final AliasStore store; + private final Listener listener; + private final List resolved = new ArrayList(); + + public AliasResolver(AliasStore store) { + this(store, null); + } + + public AliasResolver(AliasStore store, Listener listener) { + this.store = store == null ? AliasStore.empty() : store; + this.listener = listener; + } + + public ResolutionResult resolve(String option, String input, AliasType expectedType) { + if (input == null) { + throw new AliasResolutionException("Missing required option: --" + option); + } + byte[] direct = decodeAddress(input); + if (direct != null) { + return new ResolutionResult(option, input, direct, null, null, null); + } + + AliasEntry entry = store.find(input, expectedType); + if (entry == null) { + throw new AliasResolutionException("Invalid TRON address or unknown " + + expectedTypeName(expectedType) + " alias for --" + option + ": " + input); + } + ResolutionResult result = new ResolutionResult( + option, input, entry.getAddress(), entry.getName(), entry.getType(), entry.getSource()); + resolved.add(result); + if (listener != null) { + listener.resolved(result); + } + return result; + } + + public List getResolved() { + return Collections.unmodifiableList(resolved); + } + + public AliasStore getStore() { + return store; + } + + public static byte[] decodeAddress(String input) { + String t = input == null ? null : input.trim(); + if (t == null || t.isEmpty()) { + return null; + } + byte[] base58; + try { + base58 = WalletApi.decodeFromBase58Check(t); + } catch (RuntimeException e) { + base58 = null; + } + if (base58 != null) { + return base58; + } + String hex = null; + if (t.matches("41[0-9a-fA-F]{40}")) { + hex = t; + } else if (t.matches("0x[0-9a-fA-F]{40}")) { + hex = "41" + t.substring(2); + } + if (hex == null) { + return null; + } + byte[] decoded = Hex.decode(hex); + return decoded.length == 21 ? decoded : null; + } + + private static String expectedTypeName(AliasType expectedType) { + if (expectedType == AliasType.TOKEN) { + return "token"; + } + if (expectedType == AliasType.ACCOUNT) { + return "account"; + } + return "address"; + } +} diff --git a/src/main/java/org/tron/walletcli/cli/aliases/AliasStore.java b/src/main/java/org/tron/walletcli/cli/aliases/AliasStore.java new file mode 100644 index 000000000..5054aa9f3 --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/aliases/AliasStore.java @@ -0,0 +1,73 @@ +package org.tron.walletcli.cli.aliases; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class AliasStore { + + private final Map entries; + + public AliasStore(Collection entries) { + Map map = new LinkedHashMap(); + if (entries != null) { + for (AliasEntry entry : entries) { + if (entry != null) { + map.put(key(entry.getName()), entry); + } + } + } + this.entries = Collections.unmodifiableMap(map); + } + + public static AliasStore empty() { + return new AliasStore(Collections.emptyList()); + } + + public static AliasStore layered(AliasStore builtin, AliasStore user) { + List result = new ArrayList(); + AliasStore builtins = builtin == null ? empty() : builtin; + AliasStore users = user == null ? empty() : user; + result.addAll(builtins.listAll()); + for (AliasEntry entry : users.listAll()) { + if (!builtins.containsName(entry.getName())) { + result.add(entry); + } + } + return new AliasStore(result); + } + + public AliasEntry find(String name, AliasType type) { + AliasEntry entry = entries.get(key(name)); + if (entry == null) { + return null; + } + return type == null || entry.getType() == type ? entry : null; + } + + public boolean containsName(String name) { + return entries.containsKey(key(name)); + } + + public List listAll() { + return new ArrayList(entries.values()); + } + + public List listByType(AliasType type) { + List result = new ArrayList(); + for (AliasEntry entry : entries.values()) { + if (type == null || entry.getType() == type) { + result.add(entry); + } + } + return result; + } + + private static String key(String name) { + return name == null ? "" : name.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/src/main/java/org/tron/walletcli/cli/aliases/AliasStoreLoader.java b/src/main/java/org/tron/walletcli/cli/aliases/AliasStoreLoader.java new file mode 100644 index 000000000..cf085b1a8 --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/aliases/AliasStoreLoader.java @@ -0,0 +1,220 @@ +package org.tron.walletcli.cli.aliases; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.tron.common.enums.NetType; +import org.tron.common.utils.FilePermissionUtils; +import org.tron.walletcli.cli.ActiveWalletConfig; +import org.tron.walletserver.WalletApi; + +public final class AliasStoreLoader { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public AliasStore loadLayered(NetType network) throws IOException { + AliasStore builtin = loadBuiltin(network); + AliasStore user = loadUser(network); + return AliasStore.layered(builtin, user); + } + + public AliasStore loadBuiltin(NetType network) throws IOException { + String resource = "/aliases/" + networkName(network) + ".json"; + InputStream in = null; + try { + in = AliasStoreLoader.class.getResourceAsStream(resource); + if (in == null) { + return AliasStore.empty(); + } + return readStore(new InputStreamReader(in, StandardCharsets.UTF_8), "builtin"); + } catch (IOException e) { + warnFailed("load builtin alias list " + resource, e); + return AliasStore.empty(); + } catch (RuntimeException e) { + warnFailed("load builtin alias list " + resource, e); + return AliasStore.empty(); + } finally { + if (in != null) { + closeQuietly(in, "close builtin alias list " + resource); + } + } + } + + public AliasStore loadUser(NetType network) throws IOException { + try { + return loadUserOrThrow(network); + } catch (IOException e) { + warnFailed("read user alias file " + userFile(network).getPath(), e); + return AliasStore.empty(); + } catch (RuntimeException e) { + warnFailed("read user alias file " + userFile(network).getPath(), e); + return AliasStore.empty(); + } + } + + /** + * Loads the user alias file and propagates read/parse failures. Mutating commands use this + * path so a malformed existing alias file is never treated as an empty store and overwritten. + */ + public AliasStore loadUserOrThrow(NetType network) throws IOException { + File file = userFile(network); + if (!file.exists()) { + return AliasStore.empty(); + } + Reader reader = null; + try { + reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8); + return readStore(reader, "user"); + } finally { + if (reader != null) { + closeQuietly(reader, "close user alias file " + file.getPath()); + } + } + } + + public void saveUser(NetType network, List entries) throws IOException { + File file = userFile(network); + File dir = file.getParentFile(); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Could not create alias directory: " + dir.getPath()); + } + FilePermissionUtils.setOwnerOnlyDirectory(dir.toPath()); + AliasFile aliasFile = new AliasFile(); + aliasFile.entries = new ArrayList(); + for (AliasEntry entry : entries) { + aliasFile.entries.add(AliasJsonEntry.from(entry)); + } + + File tmp = new File(dir, file.getName() + ".tmp"); + Writer writer = new OutputStreamWriter(new FileOutputStream(tmp), StandardCharsets.UTF_8); + try { + GSON.toJson(aliasFile, writer); + } finally { + writer.close(); + } + try { + Files.move(tmp.toPath(), file.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + FilePermissionUtils.setOwnerOnlyFile(file.toPath()); + } + + public File userFile(NetType network) { + return new File(new File(ActiveWalletConfig.getWalletDir(), "aliases"), + networkName(network) + ".json"); + } + + public String networkName(NetType network) { + NetType net = network == null ? WalletApi.getCurrentNetwork() : network; + if (net == null) { + net = NetType.MAIN; + } + return net.name().toLowerCase(Locale.ROOT); + } + + private AliasStore readStore(Reader reader, String source) throws IOException { + AliasFile file; + try { + file = GSON.fromJson(reader, AliasFile.class); + } catch (JsonSyntaxException e) { + throw new IOException("Could not read " + source + " aliases: " + e.getMessage(), e); + } + + List entries = new ArrayList(); + if (file != null && file.entries != null) { + for (AliasJsonEntry json : file.entries) { + AliasEntry entry = parseEntry(json, source); + if (entry != null) { + entries.add(entry); + } + } + } + return new AliasStore(entries); + } + + private AliasEntry parseEntry(AliasJsonEntry json, String source) { + if (json == null) { + return null; + } + if (json.name == null || json.type == null || json.address == null) { + warnSkipping("alias entry missing name/type/address"); + return null; + } + try { + AliasValidation.requireValidName(json.name); + AliasType type = AliasType.parse(json.type); + byte[] address = AliasResolver.decodeAddress(json.address); + if (address == null) { + warnSkipping("alias " + json.name + " - invalid address: " + json.address); + return null; + } + if (type == AliasType.TOKEN) { + return AliasEntry.token(json.name, address, json.decimals, source); + } + return AliasEntry.account(json.name, address, source, json.note); + } catch (IllegalArgumentException e) { + warnSkipping("alias " + json.name + " - " + e.getMessage()); + return null; + } + } + + private void warnSkipping(String message) { + System.err.println("warn: skipping " + message); + } + + private void warnFailed(String action, Exception e) { + System.err.println("warn: failed to " + action + ": " + e.getMessage()); + } + + private void closeQuietly(java.io.Closeable closeable, String action) { + try { + closeable.close(); + } catch (IOException e) { + warnFailed(action, e); + } + } + + static final class AliasFile { + List entries; + } + + static final class AliasJsonEntry { + String name; + String type; + String address; + int decimals; + String note; + + static AliasJsonEntry from(AliasEntry entry) { + AliasJsonEntry json = new AliasJsonEntry(); + json.name = entry.getName(); + json.type = entry.getType().name(); + json.address = WalletApi.encode58Check(entry.getAddress()); + if (entry.getType() == AliasType.TOKEN) { + json.decimals = entry.getDecimals(); + } + if (entry.getNote() != null) { + json.note = entry.getNote(); + } + return json; + } + } +} diff --git a/src/main/java/org/tron/walletcli/cli/aliases/AliasType.java b/src/main/java/org/tron/walletcli/cli/aliases/AliasType.java new file mode 100644 index 000000000..68fee36cc --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/aliases/AliasType.java @@ -0,0 +1,20 @@ +package org.tron.walletcli.cli.aliases; + +import java.util.Locale; + +public enum AliasType { + ACCOUNT, + TOKEN; + + public static AliasType parse(String s) { + if (s == null) { + throw new IllegalArgumentException("type must not be null"); + } + String upper = s.trim().toUpperCase(Locale.ROOT); + try { + return AliasType.valueOf(upper); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("type must be ACCOUNT or TOKEN, got: " + s); + } + } +} diff --git a/src/main/java/org/tron/walletcli/cli/aliases/AliasValidation.java b/src/main/java/org/tron/walletcli/cli/aliases/AliasValidation.java new file mode 100644 index 000000000..f1f02699a --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/aliases/AliasValidation.java @@ -0,0 +1,53 @@ +package org.tron.walletcli.cli.aliases; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.tron.walletserver.WalletApi; + +public final class AliasValidation { + private AliasValidation() { + } + + private static final Pattern NAME = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]{0,31}$"); + private static final Pattern HEX = Pattern.compile("^(0x|41)[0-9a-fA-F]{40}$"); + private static final Set RESERVED = new HashSet(Arrays.asList( + "me", "self", "main", "mainnet", "nile", "shasta", "custom", "trx", "default")); + + public static boolean looksLikeAddress(String input) { + if (input == null) { + return false; + } + String t = input.trim(); + if (t.isEmpty()) { + return false; + } + if (HEX.matcher(t).matches()) { + return true; + } + try { + return WalletApi.decodeFromBase58Check(t) != null; + } catch (RuntimeException e) { + return false; + } + } + + public static void requireValidName(String name) { + if (name == null) { + throw new IllegalArgumentException("alias name must not be null"); + } + String t = name.trim(); + if (!NAME.matcher(t).matches()) { + throw new IllegalArgumentException("invalid alias name: " + name + + " (must match ^[A-Za-z][A-Za-z0-9_.-]{0,31}$)"); + } + if (RESERVED.contains(t.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("alias name is reserved: " + t); + } + if (looksLikeAddress(t)) { + throw new IllegalArgumentException("alias name must not look like a TRON address: " + t); + } + } +} diff --git a/src/main/java/org/tron/walletcli/cli/aliases/ResolutionResult.java b/src/main/java/org/tron/walletcli/cli/aliases/ResolutionResult.java new file mode 100644 index 000000000..bb8c46f58 --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/aliases/ResolutionResult.java @@ -0,0 +1,74 @@ +package org.tron.walletcli.cli.aliases; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.tron.walletserver.WalletApi; + +public final class ResolutionResult { + + private final String option; + private final String input; + private final byte[] address; + private final String name; + private final AliasType type; + private final String source; + + public ResolutionResult(String option, String input, byte[] address, + String name, AliasType type, String source) { + this.option = option; + this.input = input; + this.address = address == null ? null : address.clone(); + this.name = name; + this.type = type; + this.source = source; + } + + public String getOption() { + return option; + } + + public String getInput() { + return input; + } + + public byte[] getAddress() { + return address == null ? null : address.clone(); + } + + public String getAddressBase58() { + return address == null ? null : WalletApi.encode58Check(address); + } + + public String getName() { + return name; + } + + public AliasType getType() { + return type; + } + + public String getSource() { + return source; + } + + public boolean isAliasHit() { + return name != null; + } + + public Map toJsonMap() { + Map map = new LinkedHashMap(); + map.put("option", option); + map.put("input", input); + map.put("address", getAddressBase58()); + if (name != null) { + map.put("name", name); + } + if (type != null) { + map.put("type", type.name()); + } + if (source != null) { + map.put("source", source); + } + return map; + } +} diff --git a/src/main/java/org/tron/walletcli/cli/commands/AliasCommands.java b/src/main/java/org/tron/walletcli/cli/commands/AliasCommands.java new file mode 100644 index 000000000..a8c251ce2 --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/commands/AliasCommands.java @@ -0,0 +1,183 @@ +package org.tron.walletcli.cli.commands; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.tron.common.enums.NetType; +import org.tron.walletcli.cli.CommandDefinition; +import org.tron.walletcli.cli.CommandRegistry; +import org.tron.walletcli.cli.OptionDef; +import org.tron.walletcli.cli.aliases.AliasEntry; +import org.tron.walletcli.cli.aliases.AliasResolver; +import org.tron.walletcli.cli.aliases.AliasStore; +import org.tron.walletcli.cli.aliases.AliasStoreLoader; +import org.tron.walletcli.cli.aliases.AliasType; +import org.tron.walletcli.cli.aliases.AliasValidation; +import org.tron.walletcli.cli.aliases.ResolutionResult; +import org.tron.walletserver.WalletApi; + +public class AliasCommands { + + public static void register(CommandRegistry registry) { + registerAdd(registry); + registerRemove(registry); + registerList(registry); + registerResolve(registry); + } + + private static CommandDefinition.Builder noAuthCommand() { + return CommandDefinition.builder().authPolicy(CommandDefinition.AuthPolicy.NEVER); + } + + private static void registerAdd(CommandRegistry registry) { + registry.add(noAuthCommand() + .name("alias-add") + .description("Add an account or token alias") + .option("name", "Alias name", true) + .option("type", "Alias type: ACCOUNT or TOKEN", true) + .option("address", "TRON address", true) + .option("decimals", "Token decimals", false, OptionDef.Type.LONG) + .option("note", "Account note", false) + .handler((ctx, opts, wrapper, out) -> { + AliasStoreLoader loader = new AliasStoreLoader(); + NetType network = WalletApi.getCurrentNetwork(); + String name = opts.getString("name"); + AliasValidation.requireValidName(name); + AliasType type = AliasType.parse(opts.getString("type")); + byte[] address = AliasResolver.decodeAddress(opts.getString("address")); + if (address == null) { + out.usageError("Invalid TRON address for --address: " + + opts.getString("address"), null); + return; + } + if (type == AliasType.TOKEN && opts.has("note")) { + out.error("invalid_option", + "--note is only valid for ACCOUNT aliases (got --type TOKEN)"); + return; + } + if (type == AliasType.ACCOUNT && opts.has("decimals")) { + out.error("invalid_option", + "--decimals is only valid for TOKEN aliases (got --type ACCOUNT)"); + return; + } + + AliasStore builtin = loader.loadBuiltin(network); + if (builtin.containsName(name)) { + out.usageError("Alias '" + name + "' is built in on " + + loader.networkName(network) + " and cannot be overridden", null); + return; + } + AliasStore user = loader.loadUserOrThrow(network); + if (user.containsName(name)) { + out.usageError("Alias already exists: " + name + + ". Use alias-remove first to replace it.", null); + return; + } + + List entries = user.listAll(); + AliasEntry entry = type == AliasType.TOKEN + ? AliasEntry.token(name, address, + opts.has("decimals") ? opts.getInt("decimals") : 0, "user") + : AliasEntry.account(name, address, "user", opts.getString("note")); + entries.add(entry); + loader.saveUser(network, entries); + out.success("Alias added: " + entry.getName(), aliasData(entry)); + }) + .build()); + } + + private static void registerRemove(CommandRegistry registry) { + registry.add(noAuthCommand() + .name("alias-remove") + .description("Remove a user alias") + .option("name", "Alias name", true) + .handler((ctx, opts, wrapper, out) -> { + AliasStoreLoader loader = new AliasStoreLoader(); + NetType network = WalletApi.getCurrentNetwork(); + String name = opts.getString("name"); + AliasStore builtin = loader.loadBuiltin(network); + if (builtin.containsName(name)) { + out.usageError("Alias '" + name + "' is built in on " + + loader.networkName(network) + " and cannot be removed", null); + return; + } + + AliasStore user = loader.loadUserOrThrow(network); + List kept = new ArrayList(); + boolean removed = false; + for (AliasEntry entry : user.listAll()) { + if (entry.getName().equalsIgnoreCase(name)) { + removed = true; + } else { + kept.add(entry); + } + } + if (!removed) { + out.error("not_found", "Alias not found: " + name); + return; + } + loader.saveUser(network, kept); + out.successMessage("Alias removed: " + name); + }) + .build()); + } + + private static void registerList(CommandRegistry registry) { + registry.add(noAuthCommand() + .name("alias-list") + .description("List aliases for the current network") + .option("type", "Filter type: ACCOUNT or TOKEN", false) + .handler((ctx, opts, wrapper, out) -> { + AliasStoreLoader loader = new AliasStoreLoader(); + AliasType type = opts.has("type") ? AliasType.parse(opts.getString("type")) : null; + AliasStore store = loader.loadLayered(WalletApi.getCurrentNetwork()); + List entries = store.listByType(type); + List> rows = new ArrayList>(); + StringBuilder text = new StringBuilder(); + text.append(String.format("%-32s %-8s %-42s %-8s", "Name", "Type", "Address", "Source")); + for (AliasEntry entry : entries) { + rows.add(aliasData(entry)); + text.append("\n").append(String.format("%-32s %-8s %-42s %-8s", + entry.getName(), entry.getType().name(), + WalletApi.encode58Check(entry.getAddress()), entry.getSource())); + } + Map json = new LinkedHashMap(); + json.put("aliases", rows); + out.success(entries.isEmpty() ? "No aliases found." : text.toString(), json); + }) + .build()); + } + + private static void registerResolve(CommandRegistry registry) { + registry.add(noAuthCommand() + .name("alias-resolve") + .description("Resolve an alias or address") + .option("name", "Alias name or address", true) + .option("type", "Expected type: ACCOUNT or TOKEN", false) + .handler((ctx, opts, wrapper, out) -> { + AliasType type = opts.has("type") ? AliasType.parse(opts.getString("type")) : null; + AliasResolver resolver = ctx.getAliasResolver() != null + ? ctx.getAliasResolver() + : new AliasResolver(new AliasStoreLoader().loadLayered(WalletApi.getCurrentNetwork())); + ResolutionResult result = resolver.resolve("name", opts.getString("name"), type); + out.success("Resolved: " + result.getAddressBase58(), result.toJsonMap()); + }) + .build()); + } + + private static Map aliasData(AliasEntry entry) { + Map data = new LinkedHashMap(); + data.put("name", entry.getName()); + data.put("type", entry.getType().name()); + data.put("address", WalletApi.encode58Check(entry.getAddress())); + data.put("source", entry.getSource()); + if (entry.getType() == AliasType.TOKEN) { + data.put("decimals", entry.getDecimals()); + } + if (entry.getNote() != null) { + data.put("note", entry.getNote()); + } + return data; + } +} diff --git a/src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java b/src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java index 69b8328f8..0ada7b817 100644 --- a/src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java +++ b/src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java @@ -48,7 +48,7 @@ private static void registerDeployContract(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; String name = opts.getString("name"); String abi = opts.getString("abi"); try { @@ -146,8 +146,8 @@ private static void registerTriggerContract(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] contractAddress = opts.getAddress("contract"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] contractAddress = opts.getContractAddress("contract"); String method = opts.getString("method"); String params = opts.has("params") ? opts.getString("params") : ""; long feeLimit = opts.getLong("fee-limit"); @@ -209,8 +209,8 @@ private static void registerTriggerConstantContract(CommandRegistry registry) { .option("params", "Method parameters", false) .option("owner", "Caller address", false) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] contractAddress = opts.getAddress("contract"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] contractAddress = opts.getContractAddress("contract"); String method = opts.getString("method"); String params = opts.has("params") ? opts.getString("params") : ""; @@ -267,8 +267,8 @@ private static void registerEstimateEnergy(CommandRegistry registry) { .option("token-id", "Token ID", false) .option("owner", "Caller address", false) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] contractAddress = opts.getAddress("contract"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] contractAddress = opts.getContractAddress("contract"); String method = opts.getString("method"); String params = opts.has("params") ? opts.getString("params") : ""; long callValue = opts.has("value") ? opts.getLong("value") : 0; @@ -319,8 +319,8 @@ private static void registerClearContractABI(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] contractAddress = opts.getAddress("contract"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] contractAddress = opts.getContractAddress("contract"); boolean multi = opts.getBoolean("multi"); String txid = wrapper.clearContractAbiForCli(owner, contractAddress, multi); CommandSupport.emitSuccess(out, @@ -342,8 +342,8 @@ private static void registerUpdateSetting(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] contractAddress = opts.getAddress("contract"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] contractAddress = opts.getContractAddress("contract"); long percent = opts.getLong("consume-user-resource-percent"); if (percent < 0 || percent > 100) { out.usageError("consume-user-resource-percent should be between 0 and 100", null); @@ -370,8 +370,8 @@ private static void registerUpdateEnergyLimit(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] contractAddress = opts.getAddress("contract"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] contractAddress = opts.getContractAddress("contract"); long limit = opts.getLong("origin-energy-limit"); CommandSupport.requirePositive(out, "origin-energy-limit", limit); boolean multi = opts.getBoolean("multi"); diff --git a/src/main/java/org/tron/walletcli/cli/commands/ExchangeCommands.java b/src/main/java/org/tron/walletcli/cli/commands/ExchangeCommands.java index 14fd2d62f..5309898ea 100644 --- a/src/main/java/org/tron/walletcli/cli/commands/ExchangeCommands.java +++ b/src/main/java/org/tron/walletcli/cli/commands/ExchangeCommands.java @@ -30,7 +30,7 @@ private static void registerExchangeCreate(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; byte[] firstToken = opts.getString("first-token").getBytes(StandardCharsets.UTF_8); long firstBalance = opts.getLong("first-balance"); CommandSupport.requirePositive(out, "first-balance", firstBalance); @@ -60,7 +60,7 @@ private static void registerExchangeInject(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; long exchangeId = opts.getLong("exchange-id"); CommandSupport.requirePositive(out, "exchange-id", exchangeId); String tokenIdStr = opts.getString("token-id"); @@ -90,7 +90,7 @@ private static void registerExchangeWithdraw(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; long exchangeId = opts.getLong("exchange-id"); CommandSupport.requirePositive(out, "exchange-id", exchangeId); String tokenIdStr = opts.getString("token-id"); @@ -121,7 +121,7 @@ private static void registerMarketSellAsset(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; byte[] sellToken = opts.getString("sell-token").getBytes(StandardCharsets.UTF_8); long sellQuantity = opts.getLong("sell-quantity"); CommandSupport.requirePositive(out, "sell-quantity", sellQuantity); @@ -149,7 +149,7 @@ private static void registerMarketCancelOrder(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; byte[] orderId = CommandSupport.requireHex(out, "order-id", opts.getString("order-id")); boolean multi = opts.getBoolean("multi"); String txid = wrapper.marketCancelOrderForCli(owner, orderId, multi); diff --git a/src/main/java/org/tron/walletcli/cli/commands/ProposalCommands.java b/src/main/java/org/tron/walletcli/cli/commands/ProposalCommands.java index 992ef6605..0253d5d4d 100644 --- a/src/main/java/org/tron/walletcli/cli/commands/ProposalCommands.java +++ b/src/main/java/org/tron/walletcli/cli/commands/ProposalCommands.java @@ -25,7 +25,7 @@ private static void registerCreateProposal(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; String paramsStr = opts.getString("parameters"); String[] parts = paramsStr.trim().split("\\s+"); if (parts.length % 2 != 0) { @@ -73,7 +73,7 @@ private static void registerApproveProposal(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; long id = opts.getLong("id"); CommandSupport.requirePositive(out, "id", id); boolean approve = opts.getBoolean("approve"); @@ -97,7 +97,7 @@ private static void registerDeleteProposal(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; long id = opts.getLong("id"); CommandSupport.requirePositive(out, "id", id); boolean multi = opts.getBoolean("multi"); diff --git a/src/main/java/org/tron/walletcli/cli/commands/QueryCommands.java b/src/main/java/org/tron/walletcli/cli/commands/QueryCommands.java index c5f446f8b..b03aa74ce 100644 --- a/src/main/java/org/tron/walletcli/cli/commands/QueryCommands.java +++ b/src/main/java/org/tron/walletcli/cli/commands/QueryCommands.java @@ -110,7 +110,7 @@ private static void registerGetBalance(CommandRegistry registry) { .handler((ctx, opts, wrapper, out) -> { Response.Account account; if (opts.has("address")) { - byte[] addressBytes = opts.getAddress("address"); + byte[] addressBytes = opts.getAccountAddress("address"); account = WalletApi.queryAccount(addressBytes); } else { account = wrapper.queryAccount(); @@ -137,7 +137,7 @@ private static void registerGetAccount(CommandRegistry registry) { .description("Get account information by address") .option("address", "Address to query", true) .handler((ctx, opts, wrapper, out) -> { - byte[] addressBytes = opts.getAddress("address"); + byte[] addressBytes = opts.getAccountAddress("address"); Response.Account account = WalletApi.queryAccount(addressBytes); if (account == null) { out.error("query_failed", "GetAccount failed"); @@ -172,7 +172,7 @@ private static void registerGetAccountNet(CommandRegistry registry) { .description("Get account net (bandwidth) information") .option("address", "Address to query", true) .handler((ctx, opts, wrapper, out) -> { - byte[] addressBytes = opts.getAddress("address"); + byte[] addressBytes = opts.getAccountAddress("address"); Response.AccountNetMessage accountNet = wrapper.getAccountNetForCli(addressBytes); out.queryResult("GetAccountNet successful !!", Utils.formatMessageString(accountNet)); }) @@ -186,7 +186,7 @@ private static void registerGetAccountResource(CommandRegistry registry) { .description("Get account resource information") .option("address", "Address to query", true) .handler((ctx, opts, wrapper, out) -> { - byte[] addressBytes = opts.getAddress("address"); + byte[] addressBytes = opts.getAccountAddress("address"); Response.AccountResourceMessage accountResource = wrapper.getAccountResourceForCli(addressBytes); out.queryResult("GetAccountResource successful !!", Utils.formatMessageString(accountResource)); }) @@ -203,7 +203,7 @@ private static void registerGetUsdtBalance(CommandRegistry registry) { .description("Get USDT balance of an address") .option("address", "Address to query (default: current wallet)", false) .handler((ctx, opts, wrapper, out) -> { - byte[] ownerAddress = opts.has("address") ? opts.getAddress("address") : null; + byte[] ownerAddress = opts.has("address") ? opts.getAccountAddress("address") : null; String balance = wrapper.getUSDTBalanceExact(ownerAddress); if (balance != null) { Map json = new LinkedHashMap(); @@ -397,7 +397,7 @@ private static void registerGetAssetIssueByAccount(CommandRegistry registry) { .description("Get asset issues by account address") .option("address", "Account address", true) .handler((ctx, opts, wrapper, out) -> { - Response.AssetIssueList result = WalletApi.getAssetIssueByAccount(opts.getAddress("address")); + Response.AssetIssueList result = WalletApi.getAssetIssueByAccount(opts.getAccountAddress("address")); if (result == null) { out.error("query_failed", "GetAssetIssueByAccount failed"); } else { @@ -539,7 +539,7 @@ private static void registerGetContract(CommandRegistry registry) { .description("Get smart contract by address") .option("address", "Contract address", true) .handler((ctx, opts, wrapper, out) -> { - Common.SmartContract contract = WalletApi.getContract(opts.getAddress("address")); + Common.SmartContract contract = WalletApi.getContract(opts.getContractAddress("address")); if (contract == null) { out.error("query_failed", "GetContract failed"); } else { @@ -556,7 +556,7 @@ private static void registerGetContractInfo(CommandRegistry registry) { .description("Get smart contract info by address") .option("address", "Contract address", true) .handler((ctx, opts, wrapper, out) -> { - Response.SmartContractDataWrapper contractInfo = WalletApi.getContractInfo(opts.getAddress("address")); + Response.SmartContractDataWrapper contractInfo = WalletApi.getContractInfo(opts.getContractAddress("address")); if (contractInfo == null) { out.error("query_failed", "GetContractInfo failed"); } else { @@ -574,8 +574,8 @@ private static void registerGetDelegatedResource(CommandRegistry registry) { .option("from", "From address", true) .option("to", "To address", true) .handler((ctx, opts, wrapper, out) -> { - String from = WalletApi.encode58Check(opts.getAddress("from")); - String to = WalletApi.encode58Check(opts.getAddress("to")); + String from = WalletApi.encode58Check(opts.getAccountAddress("from")); + String to = WalletApi.encode58Check(opts.getAccountAddress("to")); Response.DelegatedResourceList result = WalletApi.getDelegatedResource(from, to); if (result == null) { out.error("query_failed", "GetDelegatedResource failed"); @@ -594,8 +594,8 @@ private static void registerGetDelegatedResourceV2(CommandRegistry registry) { .option("from", "From address", true) .option("to", "To address", true) .handler((ctx, opts, wrapper, out) -> { - String from = WalletApi.encode58Check(opts.getAddress("from")); - String to = WalletApi.encode58Check(opts.getAddress("to")); + String from = WalletApi.encode58Check(opts.getAccountAddress("from")); + String to = WalletApi.encode58Check(opts.getAccountAddress("to")); Response.DelegatedResourceList result = WalletApi.getDelegatedResourceV2(from, to); if (result == null) { out.error("query_failed", "GetDelegatedResourceV2 failed"); @@ -613,7 +613,7 @@ private static void registerGetDelegatedResourceAccountIndex(CommandRegistry reg .description("Get delegated resource account index") .option("address", "Address", true) .handler((ctx, opts, wrapper, out) -> { - String address = WalletApi.encode58Check(opts.getAddress("address")); + String address = WalletApi.encode58Check(opts.getAccountAddress("address")); Response.DelegatedResourceAccountIndex result = WalletApi.getDelegatedResourceAccountIndex(address); if (result == null) { @@ -633,7 +633,7 @@ private static void registerGetDelegatedResourceAccountIndexV2(CommandRegistry r .description("Get delegated resource account index V2") .option("address", "Address", true) .handler((ctx, opts, wrapper, out) -> { - String address = WalletApi.encode58Check(opts.getAddress("address")); + String address = WalletApi.encode58Check(opts.getAccountAddress("address")); Response.DelegatedResourceAccountIndex result = WalletApi.getDelegatedResourceAccountIndexV2(address); if (result == null) { @@ -657,7 +657,7 @@ private static void registerGetCanDelegatedMaxSize(CommandRegistry registry) { int type = opts.getInt("type"); CommandSupport.requireResourceCode(out, "type", type); long maxSize = WalletApi.getCanDelegatedMaxSize( - opts.getAddress("owner"), type); + opts.getAccountAddress("owner"), type); Map json = new LinkedHashMap(); json.put("max_size", maxSize); out.success("Max delegatable size: " + maxSize, json); @@ -672,7 +672,7 @@ private static void registerGetAvailableUnfreezeCount(CommandRegistry registry) .description("Get available unfreeze count") .option("address", "Address", true) .handler((ctx, opts, wrapper, out) -> { - long count = WalletApi.getAvailableUnfreezeCount(opts.getAddress("address")); + long count = WalletApi.getAvailableUnfreezeCount(opts.getAccountAddress("address")); Map json = new LinkedHashMap(); json.put("count", count); out.success("Available unfreeze count: " + count, json); @@ -689,7 +689,7 @@ private static void registerGetCanWithdrawUnfreezeAmount(CommandRegistry registr .option("timestamp", "Timestamp in milliseconds (default: now)", false, OptionDef.Type.LONG) .handler((ctx, opts, wrapper, out) -> { long ts = opts.has("timestamp") ? opts.getLong("timestamp") : System.currentTimeMillis(); - long amount = WalletApi.getCanWithdrawUnfreezeAmount(opts.getAddress("address"), ts); + long amount = WalletApi.getCanWithdrawUnfreezeAmount(opts.getAccountAddress("address"), ts); Map json = new LinkedHashMap(); json.put("amount", amount); out.success("Can withdraw unfreeze amount: " + amount, json); @@ -704,7 +704,7 @@ private static void registerGetBrokerage(CommandRegistry registry) { .description("Get witness brokerage ratio") .option("address", "Witness address", true) .handler((ctx, opts, wrapper, out) -> { - long brokerage = wrapper.getBrokerage(opts.getAddress("address")); + long brokerage = wrapper.getBrokerage(opts.getAccountAddress("address")); Map json = new LinkedHashMap(); json.put("brokerage", brokerage); out.success("Brokerage: " + brokerage, json); @@ -720,7 +720,7 @@ private static void registerGetReward(CommandRegistry registry) { .option("address", "Address", true) .handler((ctx, opts, wrapper, out) -> { org.tron.trident.api.GrpcAPI.NumberMessage result = - wrapper.getReward(opts.getAddress("address")); + wrapper.getReward(opts.getAccountAddress("address")); if (result == null) { out.error("query_failed", "GetReward failed"); } else { @@ -917,7 +917,7 @@ private static void registerGetMarketOrderByAccount(CommandRegistry registry) { .description("Get market orders by account") .option("address", "Account address", true) .handler((ctx, opts, wrapper, out) -> { - Response.MarketOrderList result = WalletApi.getMarketOrderByAccount(opts.getAddress("address")); + Response.MarketOrderList result = WalletApi.getMarketOrderByAccount(opts.getAccountAddress("address")); if (result == null) { out.error("query_failed", "GetMarketOrderByAccount failed"); } else { @@ -1016,7 +1016,7 @@ private static void registerGasFreeInfo(CommandRegistry registry) { .option("address", "Address to query (default: current wallet)", false) .handler((ctx, opts, wrapper, out) -> { String address = opts.has("address") - ? WalletApi.encode58Check(opts.getAddress("address")) : null; + ? WalletApi.encode58Check(opts.getAccountAddress("address")) : null; String rendered = JSON.toJSONString(wrapper.getGasFreeInfoDataForCli(address), true); out.queryResult("GetGasFreeInfo successful !!", rendered); }) diff --git a/src/main/java/org/tron/walletcli/cli/commands/StakingCommands.java b/src/main/java/org/tron/walletcli/cli/commands/StakingCommands.java index 8d1dc20cc..cbbd7fc6e 100644 --- a/src/main/java/org/tron/walletcli/cli/commands/StakingCommands.java +++ b/src/main/java/org/tron/walletcli/cli/commands/StakingCommands.java @@ -34,14 +34,14 @@ private static void registerFreezeBalance(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; long amount = opts.getLong("amount"); CommandSupport.requirePositive(out, "amount", amount); long duration = opts.getLong("duration"); CommandSupport.requirePositive(out, "duration", duration); int resource = opts.has("resource") ? opts.getInt("resource") : 0; CommandSupport.requireResourceCode(out, "resource", resource); - byte[] receiver = opts.has("receiver") ? opts.getAddress("receiver") : null; + byte[] receiver = opts.has("receiver") ? opts.getAccountAddress("receiver") : null; boolean multi = opts.getBoolean("multi"); String txid = wrapper.freezeBalanceForCli(owner, amount, duration, resource, receiver, multi); CommandSupport.emitSuccess(out, @@ -64,7 +64,7 @@ private static void registerFreezeBalanceV2(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; long amount = opts.getLong("amount"); CommandSupport.requirePositive(out, "amount", amount); int resource = opts.has("resource") ? opts.getInt("resource") : 0; @@ -98,10 +98,10 @@ private static void registerUnfreezeBalance(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; int resource = opts.has("resource") ? opts.getInt("resource") : 0; CommandSupport.requireResourceCode(out, "resource", resource); - byte[] receiver = opts.has("receiver") ? opts.getAddress("receiver") : null; + byte[] receiver = opts.has("receiver") ? opts.getAccountAddress("receiver") : null; boolean multi = opts.getBoolean("multi"); String txid = wrapper.unfreezeBalanceForCli(owner, resource, receiver, multi); CommandSupport.emitSuccess(out, @@ -124,7 +124,7 @@ private static void registerUnfreezeBalanceV2(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; long amount = opts.getLong("amount"); CommandSupport.requirePositive(out, "amount", amount); int resource = opts.has("resource") ? opts.getInt("resource") : 0; @@ -156,7 +156,7 @@ private static void registerWithdrawExpireUnfreeze(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; boolean multi = opts.getBoolean("multi"); String txid = wrapper.withdrawExpireUnfreezeForCli(owner, multi); CommandSupport.emitSuccess(out, @@ -181,12 +181,12 @@ private static void registerDelegateResource(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; long amount = opts.getLong("amount"); CommandSupport.requirePositive(out, "amount", amount); int resource = opts.getInt("resource"); CommandSupport.requireResourceCode(out, "resource", resource); - byte[] receiver = opts.getAddress("receiver"); + byte[] receiver = opts.getAccountAddress("receiver"); boolean lock = opts.getBoolean("lock"); long lockPeriod = opts.has("lock-period") ? opts.getLong("lock-period") : 0; if (opts.has("lock-period")) CommandSupport.requireNonNegative(out, "lock-period", lockPeriod); @@ -213,12 +213,12 @@ private static void registerUndelegateResource(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; long amount = opts.getLong("amount"); CommandSupport.requirePositive(out, "amount", amount); int resource = opts.getInt("resource"); CommandSupport.requireResourceCode(out, "resource", resource); - byte[] receiver = opts.getAddress("receiver"); + byte[] receiver = opts.getAccountAddress("receiver"); boolean multi = opts.getBoolean("multi"); String txid = wrapper.undelegateResourceForCli(owner, amount, resource, receiver, multi); CommandSupport.emitSuccess(out, @@ -238,7 +238,7 @@ private static void registerCancelAllUnfreezeV2(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; boolean multi = opts.getBoolean("multi"); String txid = wrapper.cancelAllUnfreezeV2ForCli(owner, multi); CommandSupport.emitSuccess(out, @@ -258,7 +258,7 @@ private static void registerWithdrawBalance(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; boolean multi = opts.getBoolean("multi"); String txid = wrapper.withdrawBalanceForCli(owner, multi); CommandSupport.emitSuccess(out, @@ -278,7 +278,7 @@ private static void registerUnfreezeAsset(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; boolean multi = opts.getBoolean("multi"); String txid = wrapper.unfreezeAssetForCli(owner, multi); CommandSupport.emitSuccess(out, diff --git a/src/main/java/org/tron/walletcli/cli/commands/TransactionCommands.java b/src/main/java/org/tron/walletcli/cli/commands/TransactionCommands.java index 9ab46e9c8..68641a782 100644 --- a/src/main/java/org/tron/walletcli/cli/commands/TransactionCommands.java +++ b/src/main/java/org/tron/walletcli/cli/commands/TransactionCommands.java @@ -48,8 +48,8 @@ private static void registerSendCoin(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] to = opts.getAddress("to"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] to = opts.getAccountAddress("to"); long amount = opts.getLong("amount"); CommandSupport.requirePositive(out, "amount", amount); int permissionId = opts.has("permission-id") ? opts.getInt("permission-id") : 0; @@ -88,8 +88,8 @@ private static void registerTransferAsset(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] to = opts.getAddress("to"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] to = opts.getAccountAddress("to"); String asset = opts.getString("asset"); CommandSupport.requireNonBlank(out, "asset", asset); long amount = opts.getLong("amount"); @@ -122,8 +122,8 @@ private static void registerTransferUsdt(CommandRegistry registry) { "transfer-usdt does not support the current network."); return; } - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] toAddress = opts.getAddress("to"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] toAddress = opts.getAccountAddress("to"); long amount = opts.getLong("amount"); CommandSupport.requirePositive(out, "amount", amount); int permissionId = opts.has("permission-id") ? opts.getInt("permission-id") : 0; @@ -198,8 +198,8 @@ private static void registerParticipateAssetIssue(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] to = opts.getAddress("to"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] to = opts.getAccountAddress("to"); String asset = opts.getString("asset"); CommandSupport.requireNonBlank(out, "asset", asset); long amount = opts.getLong("amount"); @@ -235,7 +235,7 @@ private static void registerAssetIssue(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; String name = opts.getString("name"); String abbr = opts.getString("abbr"); long totalSupply = opts.getLong("total-supply"); @@ -293,8 +293,8 @@ private static void registerCreateAccount(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; - byte[] address = opts.getAddress("address"); + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; + byte[] address = opts.getAccountAddress("address"); boolean multi = opts.getBoolean("multi"); String txid = wrapper.createAccountForCli(owner, address, multi); CommandSupport.emitSuccess(out, @@ -315,7 +315,7 @@ private static void registerUpdateAccount(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; byte[] nameBytes = opts.getString("name").getBytes(StandardCharsets.UTF_8); CommandSupport.requireMaxBytes(out, "name", nameBytes, 200); boolean multi = opts.getBoolean("multi"); @@ -337,7 +337,7 @@ private static void registerSetAccountId(CommandRegistry registry) { .option("owner", "Owner address", false) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; byte[] id = opts.getString("id").getBytes(StandardCharsets.UTF_8); CommandSupport.requireByteRange(out, "id", id, 8, 32); String txid = wrapper.setAccountIdForCli(owner, id); @@ -362,7 +362,7 @@ private static void registerUpdateAsset(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; byte[] desc = opts.getString("description").getBytes(StandardCharsets.UTF_8); CommandSupport.requireMaxBytes(out, "description", desc, 200); byte[] url = opts.getString("url").getBytes(StandardCharsets.UTF_8); @@ -418,7 +418,7 @@ private static void registerUpdateAccountPermission(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.getAddress("owner"); + byte[] owner = opts.getAccountAddress("owner"); String permissions = opts.getString("permissions"); boolean multi = opts.getBoolean("multi"); String txid = wrapper.accountPermissionUpdateForCli(owner, permissions, multi); @@ -438,7 +438,7 @@ private static void registerGasFreeTransfer(CommandRegistry registry) { .option("to", "Recipient address", true) .option("amount", "Amount", true, OptionDef.Type.LONG) .handler((ctx, opts, wrapper, out) -> { - byte[] toBytes = opts.getAddress("to"); + byte[] toBytes = opts.getAccountAddress("to"); String to = WalletApi.encode58Check(toBytes); long amount = opts.getLong("amount"); CommandSupport.requirePositive(out, "amount", amount); diff --git a/src/main/java/org/tron/walletcli/cli/commands/WalletCommands.java b/src/main/java/org/tron/walletcli/cli/commands/WalletCommands.java index 331fdd408..ff4b5c055 100644 --- a/src/main/java/org/tron/walletcli/cli/commands/WalletCommands.java +++ b/src/main/java/org/tron/walletcli/cli/commands/WalletCommands.java @@ -169,7 +169,7 @@ private static void registerSetActiveWallet(CommandRegistry registry) { String addr = opts.getString("address"); byte[] decoded; try { - decoded = WalletApi.decodeFromBase58Check(addr); + decoded = opts.getAccountAddress("address"); } catch (IllegalArgumentException e) { out.usageError("Invalid TRON address for --address: " + addr, null); return; @@ -178,6 +178,7 @@ private static void registerSetActiveWallet(CommandRegistry registry) { out.usageError("Invalid TRON address for --address: " + addr, null); return; } + addr = WalletApi.encode58Check(decoded); walletFile = ActiveWalletConfig.findWalletFileByAddress(addr); if (walletFile == null) { out.error("not_found", "No wallet found with address: " + addr); diff --git a/src/main/java/org/tron/walletcli/cli/commands/WitnessCommands.java b/src/main/java/org/tron/walletcli/cli/commands/WitnessCommands.java index 73ac82e6a..f20da2d06 100644 --- a/src/main/java/org/tron/walletcli/cli/commands/WitnessCommands.java +++ b/src/main/java/org/tron/walletcli/cli/commands/WitnessCommands.java @@ -28,7 +28,7 @@ private static void registerCreateWitness(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; String url = opts.getString("url"); if (url.trim().isEmpty()) { out.usageError("--url cannot be empty", null); @@ -58,7 +58,7 @@ private static void registerUpdateWitness(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; String url = opts.getString("url"); if (url.trim().isEmpty()) { out.usageError("--url cannot be empty", null); @@ -89,7 +89,7 @@ private static void registerVoteWitness(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; String votesStr = opts.getString("votes"); int permissionId = opts.has("permission-id") ? opts.getInt("permission-id") : 0; CommandSupport.requirePermissionId(out, "permission-id", permissionId); @@ -148,7 +148,7 @@ private static void registerUpdateBrokerage(CommandRegistry registry) { .option("multi", "Multi-signature mode", false, OptionDef.Type.BOOLEAN) .handler((ctx, opts, wrapper, out) -> { - byte[] owner = opts.has("owner") ? opts.getAddress("owner") : null; + byte[] owner = opts.has("owner") ? opts.getAccountAddress("owner") : null; int brokerage = opts.getInt("brokerage"); if (brokerage < 0 || brokerage > 100) { out.usageError("brokerage must be between 0 and 100, got: " + brokerage, null); diff --git a/src/main/java/org/tron/walletcli/cli/ledger/LedgerPorts.java b/src/main/java/org/tron/walletcli/cli/ledger/LedgerPorts.java new file mode 100644 index 000000000..04775128a --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/ledger/LedgerPorts.java @@ -0,0 +1,107 @@ +package org.tron.walletcli.cli.ledger; + +import java.util.Optional; +import org.tron.trident.proto.Chain; + +/** + * Test seams over the singleton-based Ledger collaborators. Production wiring delegates to + * {@code HidServicesWrapper.getInstance()} / {@code LedgerEventListener.getInstance()}; + * tests inject hand-written fakes since the codebase has no Mockito. + * + *

{@link DeviceHandle} hides the concrete {@code org.hid4java.HidDevice} (which has a + * complex constructor) so tests can use a trivial record-style fake. + */ +public final class LedgerPorts { + + private LedgerPorts() {} + + /** + * Opaque handle to a connected device. The signer only needs its path (for state lookups) + * and a way to close it after the sign. + */ + public interface DeviceHandle { + String path(); + boolean isClosed(); + void close(); + } + + /** Looks up the connected Ledger device whose Tron-app-derived address at {@code path} matches. */ + public interface HidDeviceFinder { + DeviceHandle find(String address, String bip44Path); + } + + /** Reads the current sign state for a device (file-backed in production via {@code LedgerSignResult}). */ + public interface SignStateReader { + /** + * @return {@code Optional.empty()} if no state recorded; otherwise one of the + * {@code SIGN_RESULT_*} constants from {@code LedgerSignResult}. + */ + Optional lastState(String devicePath); + + /** + * Reads the state for the current transaction instead of the device's last recorded line. + */ + Optional stateByTxid(String devicePath, String txid); + + /** + * Marks the current transaction as signing before the request is sent, even when the same + * txid already exists in the Ledger state file from a previous attempt. + */ + void markSigning(String devicePath, String txid); + + /** + * Marks the current transaction as canceled or aborted so it does not remain signing. + */ + void markCanceled(String devicePath, String txid); + + /** + * Marks the current transaction as timed out after the standard CLI stops waiting for + * confirmation. + */ + void markTimedOut(String devicePath, String txid); + } + + /** Drives the actual APDU exchange and waits for the on-device button. */ + public interface SignExecutor { + /** Returns true if the request was accepted by the device (regardless of user button). */ + boolean executeSignListen(DeviceHandle device, Chain.Transaction tx, + String bip44Path, boolean gasfree); + + /** Bytes returned by the listener's last {@code handleTransSign} call (may be null). */ + byte[] lastSendResultBytes(); + } + + /** Reads the signature/transaction the listener stashed after a confirmed sign. */ + public interface SignResultReader { + /** + * Prepares the singleton-backed listener to attach the returned Ledger signature to this + * transaction. This mirrors the legacy REPL setup before executeSignListen. + */ + void prepareTransaction(Chain.Transaction transaction); + + /** {@code Optional.empty()} if no signature was produced; otherwise the gasfree signature hex. */ + Optional gasfreeSignature(); + + /** {@code Optional.empty()} if no signed transaction was produced. */ + Optional signedTransaction(); + + /** Resets transaction/signature fields. Called in {@code finally}. */ + void reset(); + } + + /** Mirrors the legacy REPL Ledger contract-type allowlist. */ + public interface ContractSupport { + boolean canSign(Chain.Transaction transaction); + } + + /** + * Thrown by {@link HidDeviceFinder#find} when the Ledger device is physically connected + * but the Tron app is not open (APDU status word {@code 0x6511}). + * Distinct from a plain {@code null} return (device not found / address mismatch). + */ + public static final class AppNotOpenException extends RuntimeException { + public AppNotOpenException(String message) { + super(message); + } + } +} diff --git a/src/main/java/org/tron/walletcli/cli/ledger/LedgerSignOutcome.java b/src/main/java/org/tron/walletcli/cli/ledger/LedgerSignOutcome.java new file mode 100644 index 000000000..5a8082d5e --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/ledger/LedgerSignOutcome.java @@ -0,0 +1,79 @@ +package org.tron.walletcli.cli.ledger; + +import org.tron.trident.proto.Chain; + +/** Result of {@link LedgerSigner#sign}. Non-throwing; carries the failure cause as a status enum. */ +public final class LedgerSignOutcome { + + public enum Status { + OK, + NOT_CONNECTED, + APP_NOT_OPEN, + SIGN_BY_HASH_DISABLED, + UNSUPPORTED_CONTRACT, + ALREADY_SIGNING, + USER_REJECTED, + TIMEOUT, + SIGN_FAILED, + } + + private final Status status; + private final String message; + private final Chain.Transaction signedTransaction; + private final String gasfreeSignature; + + private LedgerSignOutcome(Status status, String message, + Chain.Transaction signedTransaction, String gasfreeSignature) { + this.status = status; + this.message = message; + this.signedTransaction = signedTransaction; + this.gasfreeSignature = gasfreeSignature; + } + + public static LedgerSignOutcome ok(Chain.Transaction signedTransaction) { + return new LedgerSignOutcome(Status.OK, null, signedTransaction, null); + } + + public static LedgerSignOutcome okGasfree(String gasfreeSignature) { + return new LedgerSignOutcome(Status.OK, null, null, gasfreeSignature); + } + + public static LedgerSignOutcome failure(Status status, String message) { + if (status == Status.OK) { + throw new IllegalArgumentException("OK is a success status; use ok() / okGasfree()"); + } + return new LedgerSignOutcome(status, message, null, null); + } + + public Status getStatus() { + return status; + } + + public String getMessage() { + return message; + } + + public Chain.Transaction getSignedTransaction() { + return signedTransaction; + } + + public String getGasfreeSignature() { + return gasfreeSignature; + } + + /** Maps non-OK statuses to the {@code ledger_*} error code used in the JSON envelope. */ + public String errorCode() { + switch (status) { + case OK: return null; + case NOT_CONNECTED: return "ledger_not_connected"; + case APP_NOT_OPEN: return "ledger_app_not_open"; + case SIGN_BY_HASH_DISABLED: return "ledger_sign_by_hash_disabled"; + case UNSUPPORTED_CONTRACT: return "ledger_unsupported_contract"; + case ALREADY_SIGNING: return "ledger_already_signing"; + case USER_REJECTED: return "ledger_user_rejected"; + case TIMEOUT: return "ledger_timeout"; + case SIGN_FAILED: + default: return "ledger_sign_failed"; + } + } +} diff --git a/src/main/java/org/tron/walletcli/cli/ledger/LedgerSigner.java b/src/main/java/org/tron/walletcli/cli/ledger/LedgerSigner.java new file mode 100644 index 000000000..08b5e42fd --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/ledger/LedgerSigner.java @@ -0,0 +1,26 @@ +package org.tron.walletcli.cli.ledger; + +import org.tron.trident.proto.Chain; + +/** + * Signs a transaction using a Ledger hardware wallet from the standard CLI. + * + *

Implementations must be non-interactive (no prompts on stdin/stdout) and must always return an + * outcome rather than throwing — sign-site code translates non-OK outcomes into structured + * command errors. + */ +public interface LedgerSigner { + + /** + * @param transaction the transaction to sign (or, for gasfree, a synthetic transaction whose + * raw data hash is the gasfree message digest) + * @param bip44Path the derivation path stored in the keystore (e.g. {@code m/44'/195'/0'/0/0}) + * @param address the Tron address that the path is expected to produce on the device + * @param gasfree {@code true} for the gasfree sign flow (digest-only signature), + * {@code false} for the regular signTransactionForCli path + */ + LedgerSignOutcome sign(Chain.Transaction transaction, + String bip44Path, + String address, + boolean gasfree); +} diff --git a/src/main/java/org/tron/walletcli/cli/ledger/NonInteractiveLedgerSigner.java b/src/main/java/org/tron/walletcli/cli/ledger/NonInteractiveLedgerSigner.java new file mode 100644 index 000000000..033898a05 --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/ledger/NonInteractiveLedgerSigner.java @@ -0,0 +1,201 @@ +package org.tron.walletcli.cli.ledger; + +import static org.tron.common.utils.TransactionUtils.getTransactionId; + +import java.util.Optional; +import org.tron.trident.proto.Chain; +import org.tron.walletcli.cli.OutputFormatter; + +/** + * Non-interactive {@link LedgerSigner} for the standard CLI. + * + *

Wires the existing Ledger machinery (HID discovery, listener, {@code TransactionSignManager}, + * {@code LedgerSignResult}) into a structured outcome without printing prompts or reading from + * stdin. Stdout output from shared Ledger code is suppressed for the duration of the sign call; + * one stderr notice is emitted via the formatter so the user knows to press the on-device button. + * + *

The signer never throws — every failure mode is reported via {@link LedgerSignOutcome}. + * The sign-site code translates non-OK outcomes into {@code CommandErrorException}. + */ +public final class NonInteractiveLedgerSigner implements LedgerSigner { + + /** + * Status strings written by {@code LedgerSignResult}. These mirror its public constants + * (kept private here to avoid forcing test fakes to depend on the listener package). + */ + static final String STATE_SIGNING = "signing"; // SIGN_RESULT_SIGNING — in progress + static final String STATE_CONFIRMED = "confirmed"; // SIGN_RESULT_SUCCESS — user confirmed + static final String STATE_CANCEL = "cancel"; // SIGN_RESULT_CANCEL — user rejected/aborted + static final String STATE_TIMEOUT = "timeout"; // SIGN_RESULT_TIMEOUT — timed out + + /** APDU status word: Tron app is not open on the device. */ + private static final byte[] APDU_APP_NOT_OPEN = new byte[] { 0x65, 0x11 }; + /** APDU status word: "Sign By Hash" setting is not enabled. */ + private static final byte[] APDU_SIGN_BY_HASH = new byte[] { 0x6a, (byte) 0x8c }; + + private final OutputFormatter formatter; + private final LedgerPorts.HidDeviceFinder finder; + private final LedgerPorts.SignStateReader stateReader; + private final LedgerPorts.SignExecutor executor; + private final LedgerPorts.SignResultReader resultReader; + private final LedgerPorts.ContractSupport contractSupport; + + public NonInteractiveLedgerSigner(OutputFormatter formatter, + LedgerPorts.HidDeviceFinder finder, + LedgerPorts.SignStateReader stateReader, + LedgerPorts.SignExecutor executor, + LedgerPorts.SignResultReader resultReader, + LedgerPorts.ContractSupport contractSupport) { + this.formatter = formatter; + this.finder = finder; + this.stateReader = stateReader; + this.executor = executor; + this.resultReader = resultReader; + this.contractSupport = contractSupport; + } + + @Override + public LedgerSignOutcome sign(Chain.Transaction transaction, + String bip44Path, + String address, + boolean gasfree) { + if (!gasfree && !contractSupport.canSign(transaction)) { + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.UNSUPPORTED_CONTRACT, + "Transaction type is not supported by Ledger signing"); + } + + LedgerPorts.DeviceHandle device; + try (SystemOutSuppressor ignored = SystemOutSuppressor.capture()) { + try { + device = finder.find(address, bip44Path); + } catch (LedgerPorts.AppNotOpenException e) { + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.APP_NOT_OPEN, + e.getMessage()); + } catch (RuntimeException e) { + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.NOT_CONNECTED, + "HID transport failure: " + e.getMessage()); + } + } + + if (device == null) { + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.NOT_CONNECTED, + "No Ledger device matching address " + address + " is connected"); + } + + boolean prepared = false; + try { + Optional preState = stateReader.lastState(device.path()); + if (preState.isPresent() && STATE_SIGNING.equals(preState.get())) { + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.ALREADY_SIGNING, + "A previous sign operation is still in progress on the Ledger device"); + } + + // Defensive reset to avoid contamination from any prior interrupted sign. + resultReader.reset(); + resultReader.prepareTransaction(transaction); + prepared = true; + + formatter.notice("Please confirm transaction on Ledger device for " + address); + String txid = getTransactionId(transaction).toString(); + stateReader.markSigning(device.path(), txid); + + try (SystemOutSuppressor ignored = SystemOutSuppressor.capture()) { + executor.executeSignListen(device, transaction, bip44Path, gasfree); + } + + byte[] apdu = executor.lastSendResultBytes(); + if (apdu != null && apdu.length > 0) { + if (matches(apdu, APDU_APP_NOT_OPEN)) { + stateReader.markCanceled(device.path(), txid); + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.APP_NOT_OPEN, + "Open the Tron app on your Ledger device and try again"); + } + if (matches(apdu, APDU_SIGN_BY_HASH)) { + stateReader.markCanceled(device.path(), txid); + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.SIGN_BY_HASH_DISABLED, + "Enable 'Sign By Hash' in the Ledger Tron app settings and try again"); + } + stateReader.markCanceled(device.path(), txid); + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.SIGN_FAILED, + "Ledger returned APDU error " + toHex(apdu)); + } + + // The state file (LedgerSignResult) is the authoritative discriminator. The + // listener stashes the *input* transaction in TransactionSignManager before waiting + // for the button, so checking signedTransaction()-non-null alone would falsely + // report success on timeout. We trust the state, then fetch the signature/transaction. + Optional postState = stateReader.stateByTxid(device.path(), txid); + if (postState.isPresent()) { + if (STATE_CONFIRMED.equals(postState.get())) { + if (gasfree) { + Optional sig = resultReader.gasfreeSignature(); + if (sig.isPresent()) { + return LedgerSignOutcome.okGasfree(sig.get()); + } + } else { + Optional signed = resultReader.signedTransaction(); + if (signed.isPresent()) { + return LedgerSignOutcome.ok(signed.get()); + } + } + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.SIGN_FAILED, + "Ledger reported confirmation but no signature was recorded"); + } + if (STATE_CANCEL.equals(postState.get())) { + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.USER_REJECTED, + "Transaction was rejected on the Ledger device"); + } + if (STATE_TIMEOUT.equals(postState.get())) { + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.TIMEOUT, + "Timed out waiting for confirmation on Ledger device." + + " If the device still shows the confirmation screen," + + " reject it on the device or quit and reopen the Tron app."); + } + if (STATE_SIGNING.equals(postState.get())) { + stateReader.markTimedOut(device.path(), txid); + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.TIMEOUT, + "Timed out waiting for confirmation on Ledger device." + + " If the device still shows the confirmation screen," + + " reject it on the device or quit and reopen the Tron app."); + } + } + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.SIGN_FAILED, + "Ledger sign ended in an unexpected state: " + + postState.orElse("(no state recorded)")); + } catch (RuntimeException e) { + return LedgerSignOutcome.failure(LedgerSignOutcome.Status.SIGN_FAILED, + "Unexpected failure during Ledger sign: " + e.getMessage()); + } finally { + if (prepared) { + resultReader.reset(); + } + try { + if (!device.isClosed()) { + device.close(); + } + } catch (RuntimeException ignored) { + // Best-effort close; primary outcome is already determined. + } + } + } + + private static boolean matches(byte[] actual, byte[] expected) { + if (actual.length != expected.length) { + return false; + } + for (int i = 0; i < expected.length; i++) { + if (actual[i] != expected[i]) { + return false; + } + } + return true; + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder("0x"); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } +} diff --git a/src/main/java/org/tron/walletcli/cli/ledger/ProductionLedgerPorts.java b/src/main/java/org/tron/walletcli/cli/ledger/ProductionLedgerPorts.java new file mode 100644 index 000000000..7746e7fe0 --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/ledger/ProductionLedgerPorts.java @@ -0,0 +1,180 @@ +package org.tron.walletcli.cli.ledger; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import org.hid4java.HidDevice; +import org.tron.ledger.LedgerAddressUtil; +import org.tron.ledger.listener.LedgerEventListener; +import org.tron.ledger.listener.TransactionSignManager; +import org.tron.ledger.wrapper.ContractTypeChecker; +import org.tron.ledger.wrapper.HidServicesWrapper; +import org.tron.ledger.wrapper.LedgerSignResult; +import org.tron.trident.proto.Chain; +import org.tron.walletcli.cli.OutputFormatter; + +/** + * Wires {@link LedgerPorts} interfaces to the existing process-wide singletons. This is the + * production composition root for {@link NonInteractiveLedgerSigner}. + */ +public final class ProductionLedgerPorts { + + private ProductionLedgerPorts() {} + + public static NonInteractiveLedgerSigner buildSigner(OutputFormatter formatter) { + LedgerPorts.HidDeviceFinder finder = (address, path) -> { + HidDevice device = HidServicesWrapper.getInstance().getHidDevice(address, path); + if (device == null) { + if (HidServicesWrapper.getInstance().hasAnyLedgerAttached()) { + throw new LedgerPorts.AppNotOpenException( + "Open the Tron app on your Ledger device and try again"); + } + return null; + } + boolean matched = false; + try { + if (device.isClosed() && !device.open()) { + throw new LedgerPorts.AppNotOpenException( + "Open the Tron app on your Ledger device and try again"); + } + byte[] rawResponse = LedgerAddressUtil.getRawAddressResponse(path, device); + // 0x6511: Tron app is not open on the device (ISO 7816-4 "conditions not satisfied"). + // Distinguish from a genuine address mismatch so the caller can surface the right error. + if (rawResponse != null && rawResponse.length == 2 + && (rawResponse[0] & 0xFF) == 0x65 && (rawResponse[1] & 0xFF) == 0x11) { + throw new LedgerPorts.AppNotOpenException( + "Open the Tron app on your Ledger device and try again"); + } + String deviceAddress = LedgerAddressUtil.parseTronAddress(rawResponse); + matched = address.equals(deviceAddress) && !deviceAddress.isEmpty(); + if (!matched) { + return null; + } + return new HidDeviceAdapter(device); + } finally { + if (!matched && !device.isClosed()) { + device.close(); + } + } + }; + + LedgerPorts.SignStateReader stateReader = + new LedgerPorts.SignStateReader() { + @Override + public Optional lastState(String devicePath) { + return LedgerSignResult.getLastTransactionState(devicePath); + } + + @Override + public Optional stateByTxid(String devicePath, String txid) { + return LedgerSignResult.getStateByTxid(devicePath, txid); + } + + @Override + public void markSigning(String devicePath, String txid) { + LedgerSignResult.upsertState( + devicePath, txid, LedgerSignResult.SIGN_RESULT_SIGNING); + } + + @Override + public void markCanceled(String devicePath, String txid) { + LedgerSignResult.updateState( + devicePath, txid, LedgerSignResult.SIGN_RESULT_CANCEL); + } + + @Override + public void markTimedOut(String devicePath, String txid) { + LedgerSignResult.updateState( + devicePath, txid, LedgerSignResult.SIGN_RESULT_TIMEOUT); + } + }; + + LedgerPorts.SignExecutor executor = new LedgerPorts.SignExecutor() { + @Override + public boolean executeSignListen(LedgerPorts.DeviceHandle device, Chain.Transaction tx, + String path, boolean gasfree) { + HidDevice raw = ((HidDeviceAdapter) device).delegate; + LedgerEventListener listener = LedgerEventListener.getInstance(); + listener.setStandardCliQuiet(true); + try { + listener.setLedgerSignEnd(new AtomicBoolean(false)); + if (raw.isClosed()) { + raw.open(); + } + return listener.executeSignListen(raw, tx, path, gasfree); + } finally { + // Always reset: executeSignListen blocks until the 60-second wait completes, + // so by the time we return, the HID callback has either already reset this flag + // or it never will (silent timeout / device disconnect). + listener.setStandardCliQuiet(false); + } + } + + @Override + public byte[] lastSendResultBytes() { + return LedgerEventListener.getInstance().getLastSendResultBytes(); + } + }; + + LedgerPorts.SignResultReader resultReader = new LedgerPorts.SignResultReader() { + @Override + public void prepareTransaction(Chain.Transaction transaction) { + TransactionSignManager.getInstance().setTransaction(transaction); + } + + @Override + public Optional gasfreeSignature() { + String sig = TransactionSignManager.getInstance().getGasfreeSignature(); + return sig == null || sig.isEmpty() ? Optional.empty() : Optional.of(sig); + } + + @Override + public Optional signedTransaction() { + Chain.Transaction tx = TransactionSignManager.getInstance().getTransaction(); + return tx == null ? Optional.empty() : Optional.of(tx); + } + + @Override + public void reset() { + TransactionSignManager.getInstance().setTransaction(null); + TransactionSignManager.getInstance().setGasfreeSignature(null); + } + }; + + LedgerPorts.ContractSupport contractSupport = transaction -> { + if (transaction.getRawData().getContractCount() == 0) { + return false; + } + String type = transaction.getRawData().getContract(0).getType().toString(); + try (SystemOutSuppressor ignored = SystemOutSuppressor.capture()) { + return ContractTypeChecker.canUseLedgerSign(type); + } + }; + + return new NonInteractiveLedgerSigner(formatter, finder, stateReader, executor, resultReader, + contractSupport); + } + + /** Thin adapter so the signer can stay free of {@code org.hid4java} types. */ + private static final class HidDeviceAdapter implements LedgerPorts.DeviceHandle { + private final HidDevice delegate; + + HidDeviceAdapter(HidDevice delegate) { + this.delegate = delegate; + } + + @Override + public String path() { + return delegate.getPath(); + } + + @Override + public boolean isClosed() { + return delegate.isClosed(); + } + + @Override + public void close() { + delegate.close(); + } + } +} diff --git a/src/main/java/org/tron/walletcli/cli/ledger/SystemOutSuppressor.java b/src/main/java/org/tron/walletcli/cli/ledger/SystemOutSuppressor.java new file mode 100644 index 000000000..67ebb47c2 --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/ledger/SystemOutSuppressor.java @@ -0,0 +1,50 @@ +package org.tron.walletcli.cli.ledger; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; + +/** + * Captures everything written to {@link System#out} for the duration of a try-with-resources scope + * and restores the original stream on close. + * + *

Used to prevent shared Ledger code (printlns inside {@code LedgerEventListener}, + * {@code HidServicesWrapper}, {@code LedgerSignUtil}) from polluting standard CLI's JSON stdout. + */ +public final class SystemOutSuppressor implements AutoCloseable { + + private final PrintStream originalOut; + private final ByteArrayOutputStream sink; + + private SystemOutSuppressor(PrintStream originalOut, ByteArrayOutputStream sink) { + this.originalOut = originalOut; + this.sink = sink; + } + + public static SystemOutSuppressor capture() { + PrintStream original = System.out; + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + PrintStream replacement = createUtf8PrintStream(sink); + System.setOut(replacement); + return new SystemOutSuppressor(original, sink); + } + + private static PrintStream createUtf8PrintStream(ByteArrayOutputStream sink) { + try { + return new PrintStream(sink, true, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new AssertionError("UTF-8 must be supported by every Java runtime", e); + } + } + + /** Returns everything captured so far as a string (for verbose-mode echo). */ + public String drained() { + return new String(sink.toByteArray(), StandardCharsets.UTF_8); + } + + @Override + public void close() { + System.setOut(originalOut); + } +} diff --git a/src/main/java/org/tron/walletserver/WalletApi.java b/src/main/java/org/tron/walletserver/WalletApi.java index 39ba6f7c9..bda40c8c0 100644 --- a/src/main/java/org/tron/walletserver/WalletApi.java +++ b/src/main/java/org/tron/walletserver/WalletApi.java @@ -177,6 +177,14 @@ public class WalletApi { @Getter @Setter private byte[] unifiedPassword; + /** + * Standard CLI's non-interactive Ledger signer. Set by {@code StandardCliRunner} after + * authenticate completes. Null in REPL mode — REPL paths continue to call + * {@code LedgerSignUtil.requestLedgerSignLogic} directly. See plan-standard-cli-ledger.md. + */ + @Getter + @Setter + private org.tron.walletcli.cli.ledger.LedgerSigner ledgerSigner; @Getter @Setter private byte[] pwdForDeploy; @@ -1065,6 +1073,27 @@ private Chain.Transaction signTransactionForCli(Chain.Transaction transaction, b byte[] passwd = getUnifiedPassword(); String ledgerPath = getLedgerPath(passwd, wf); if (isLedgerFile) { + // Standard CLI uses a non-interactive signer; REPL never reaches signTransactionForCli, + // so the legacy LedgerSignUtil branch below is effectively unreachable. It is retained + // only as a safety net for hypothetical future callers. + if (this.ledgerSigner != null) { + org.tron.walletcli.cli.ledger.LedgerSignOutcome r = + this.ledgerSigner.sign(transaction, ledgerPath, wf.getAddress(), false); + if (r.getStatus() != org.tron.walletcli.cli.ledger.LedgerSignOutcome.Status.OK) { + recordLastCliOperationError(r.errorCode() + ": " + r.getMessage()); + throw new org.tron.walletcli.cli.CommandErrorException(r.errorCode(), r.getMessage()); + } + transaction = r.getSignedTransaction(); + Response.TransactionSignWeight weight = getTransactionSignWeight(transaction); + if (weight.getResult().getCode() == Response.TransactionSignWeight.Result.response_code.ENOUGH_PERMISSION) { + return transaction; + } + if (weight.getResult().getCode() == Response.TransactionSignWeight.Result.response_code.NOT_ENOUGH_PERMISSION + && multi) { + return transaction; + } + throw new CancelException(weight.getResult().getMessage()); + } boolean result = LedgerSignUtil.requestLedgerSignLogic(transaction, ledgerPath, wf.getAddress(), false); if (!result) { recordLastCliOperationError("Ledger signing was rejected or failed"); diff --git a/src/main/resources/aliases/main.json b/src/main/resources/aliases/main.json new file mode 100644 index 000000000..05a83919b --- /dev/null +++ b/src/main/resources/aliases/main.json @@ -0,0 +1,8 @@ +{ + "entries": [ + {"name": "USDT", "type": "TOKEN", "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "decimals": 6}, + {"name": "USDC", "type": "TOKEN", "address": "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", "decimals": 6}, + {"name": "USDD", "type": "TOKEN", "address": "TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn", "decimals": 18}, + {"name": "WTRX", "type": "TOKEN", "address": "TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR", "decimals": 6} + ] +} diff --git a/src/main/resources/aliases/nile.json b/src/main/resources/aliases/nile.json new file mode 100644 index 000000000..4471d12ee --- /dev/null +++ b/src/main/resources/aliases/nile.json @@ -0,0 +1,5 @@ +{ + "entries": [ + {"name": "USDT", "type": "TOKEN", "address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", "decimals": 6} + ] +} diff --git a/src/main/resources/aliases/shasta.json b/src/main/resources/aliases/shasta.json new file mode 100644 index 000000000..5fe32080e --- /dev/null +++ b/src/main/resources/aliases/shasta.json @@ -0,0 +1 @@ +{ "entries": [] } diff --git a/src/test/java/org/tron/walletcli/cli/GlobalOptionsTest.java b/src/test/java/org/tron/walletcli/cli/GlobalOptionsTest.java index 17ff1106e..725c66e5e 100644 --- a/src/test/java/org/tron/walletcli/cli/GlobalOptionsTest.java +++ b/src/test/java/org/tron/walletcli/cli/GlobalOptionsTest.java @@ -302,6 +302,29 @@ public void getCommandArgsReturnsDefensiveCopy() { Assert.assertEquals("TXYZ", second[1]); } + @Test + public void parsePasswordStdinIsAFlagAndNotPositionDependent() { + GlobalOptions before = GlobalOptions.parse(new String[]{"--password-stdin", "send-coin"}); + Assert.assertTrue(before.isPasswordStdin()); + + GlobalOptions after = GlobalOptions.parse(new String[]{"send-coin", "--password-stdin"}); + Assert.assertTrue(after.isPasswordStdin()); + Assert.assertEquals(0, after.getCommandArgs().length); + + GlobalOptions absent = GlobalOptions.parse(new String[]{"send-coin"}); + Assert.assertFalse(absent.isPasswordStdin()); + } + + @Test + public void parsePasswordStdinRejectsInlineValue() { + try { + GlobalOptions.parse(new String[]{"--password-stdin=true", "send-coin"}); + Assert.fail("Expected --password-stdin to reject inline value"); + } catch (CliUsageException e) { + Assert.assertEquals("Option --password-stdin does not take a value", e.getMessage()); + } + } + private void assertMissingValue(String option) { try { GlobalOptions.parse(new String[]{option}); diff --git a/src/test/java/org/tron/walletcli/cli/OutputFormatterResolvedTest.java b/src/test/java/org/tron/walletcli/cli/OutputFormatterResolvedTest.java new file mode 100644 index 000000000..43d5984c8 --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/OutputFormatterResolvedTest.java @@ -0,0 +1,50 @@ +package org.tron.walletcli.cli; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import org.junit.Test; +import org.tron.walletcli.cli.aliases.AliasType; +import org.tron.walletcli.cli.aliases.ResolutionResult; +import org.tron.walletserver.WalletApi; + +import static org.junit.Assert.assertTrue; + +public class OutputFormatterResolvedTest { + + @Test + public void jsonSuccessIncludesResolvedMeta() { + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + OutputFormatter formatter = new OutputFormatter( + OutputFormatter.OutputMode.JSON, false, new PrintStream(stdout), System.err); + formatter.resolved(new ResolutionResult("contract", "USDT", + WalletApi.decodeFromBase58Check("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"), + "USDT", AliasType.TOKEN, "builtin")); + formatter.successMessage("ok"); + formatter.flush(); + String json = stdout.toString(); + assertTrue(json.contains("\"meta\"")); + assertTrue(json.contains("\"resolved\"")); + assertTrue(json.contains("\"USDT\"")); + } + + @Test + public void jsonErrorIncludesResolvedMeta() { + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + OutputFormatter formatter = new OutputFormatter( + OutputFormatter.OutputMode.JSON, false, new PrintStream(stdout), System.err); + formatter.resolved(new ResolutionResult("contract", "USDT", + WalletApi.decodeFromBase58Check("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"), + "USDT", AliasType.TOKEN, "builtin")); + try { + formatter.error("execution_error", "failed after alias resolution"); + } catch (CliAbortException expected) { + // expected; formatter records the error for flush(). + } + formatter.flush(); + String json = stdout.toString(); + assertTrue(json.contains("\"success\": false")); + assertTrue(json.contains("\"meta\"")); + assertTrue(json.contains("\"resolved\"")); + assertTrue(json.contains("\"USDT\"")); + } +} diff --git a/src/test/java/org/tron/walletcli/cli/ParsedOptionsAliasTest.java b/src/test/java/org/tron/walletcli/cli/ParsedOptionsAliasTest.java new file mode 100644 index 000000000..8d2551fa7 --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/ParsedOptionsAliasTest.java @@ -0,0 +1,66 @@ +package org.tron.walletcli.cli; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import org.tron.walletcli.cli.aliases.AliasEntry; +import org.tron.walletcli.cli.aliases.AliasResolver; +import org.tron.walletcli.cli.aliases.AliasStore; +import org.tron.walletserver.WalletApi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class ParsedOptionsAliasTest { + + @Test + public void contractAddressUsesTokenAliases() { + byte[] address = WalletApi.decodeFromBase58Check("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + AliasStore store = new AliasStore(Collections.singletonList( + AliasEntry.token("USDT", address, 6, "builtin"))); + Map values = new HashMap(); + values.put("contract", "usdt"); + ParsedOptions opts = new ParsedOptions(values, new AliasResolver(store)); + assertEquals(WalletApi.encode58Check(address), + WalletApi.encode58Check(opts.getContractAddress("contract"))); + } + + @Test + public void accountAddressUsesAccountAliases() { + byte[] address = WalletApi.decodeFromBase58Check("TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL"); + AliasStore store = new AliasStore(Collections.singletonList( + AliasEntry.account("alice", address, "user", null))); + Map values = new HashMap(); + values.put("address", "alice"); + ParsedOptions opts = new ParsedOptions(values, new AliasResolver(store)); + assertEquals(WalletApi.encode58Check(address), + WalletApi.encode58Check(opts.getAccountAddress("address"))); + assertEquals(1, opts.getResolutionLog().size()); + } + + @Test + public void legacyGetAddressDoesNotResolveAliases() { + byte[] address = WalletApi.decodeFromBase58Check("TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL"); + AliasStore store = new AliasStore(Collections.singletonList( + AliasEntry.account("alice", address, "user", null))); + Map values = new HashMap(); + values.put("address", "alice"); + ParsedOptions opts = new ParsedOptions(values, new AliasResolver(store)); + try { + opts.getAddress("address"); + fail("Expected getAddress to require a raw Base58Check address"); + } catch (IllegalArgumentException expected) { + // expected + } + } + + @Test + public void legacyGetAddressStillAcceptsRawBase58Address() { + String raw = "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL"; + Map values = new HashMap(); + values.put("address", raw); + ParsedOptions opts = new ParsedOptions(values, new AliasResolver(AliasStore.empty())); + assertEquals(raw, WalletApi.encode58Check(opts.getAddress("address"))); + } +} diff --git a/src/test/java/org/tron/walletcli/cli/StandardCliRunnerTest.java b/src/test/java/org/tron/walletcli/cli/StandardCliRunnerTest.java index ec4886356..f6eb5a658 100644 --- a/src/test/java/org/tron/walletcli/cli/StandardCliRunnerTest.java +++ b/src/test/java/org/tron/walletcli/cli/StandardCliRunnerTest.java @@ -512,6 +512,105 @@ public void requireCommandFailsWhenMasterPasswordIsMissing() throws Exception { } } + @Test + public void defaultProviderUsesStdinWhenPasswordStdinFlagIsSet() { + GlobalOptions stdinOpts = GlobalOptions.parse(new String[]{"--password-stdin", "send-coin"}); + StandardCliRunner.MasterPasswordProvider stdinProv = StandardCliRunner.defaultProvider( + stdinOpts, new java.io.ByteArrayInputStream( + "FromStdin!1\n".getBytes(StandardCharsets.UTF_8))); + Assert.assertEquals("FromStdin!1", stdinProv.get()); + + GlobalOptions plainOpts = GlobalOptions.parse(new String[]{"send-coin"}); + StandardCliRunner.MasterPasswordProvider envProv = StandardCliRunner.defaultProvider( + plainOpts, new java.io.ByteArrayInputStream( + "ShouldNotBeRead\n".getBytes(StandardCharsets.UTF_8))); + // Env-backed provider — value depends on environment; we only assert it does not consult stdin. + // Calling get() must not throw and must not return the stdin payload. + String envValue = envProv.get(); + Assert.assertNotEquals("ShouldNotBeRead", envValue); + } + + @Test + public void requireCommandAuthenticatesUsingPasswordReadFromStdin() throws Exception { + CommandRegistry registry = new CommandRegistry(); + boolean[] handlerCalled = {false}; + registry.add(CommandDefinition.builder() + .name("needs-wallet") + .description("Command requiring auth") + .handler((ctx, opts, wrapper, out) -> { + handlerCalled[0] = true; + out.raw("ok"); + }) + .build()); + + String originalUserDir = System.getProperty("user.dir"); + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + File tempDir = Files.createTempDirectory("runner-stdin-password").toFile(); + File walletDir = new File(tempDir, "Wallet"); + Assert.assertTrue(walletDir.mkdirs()); + File walletFile = createWalletFile(walletDir, "alpha", "0000000000000000000000000000000000000000000000000000000000000001"); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + + System.setProperty("user.dir", tempDir.getAbsolutePath()); + System.setOut(new PrintStream(stdout)); + System.setErr(new PrintStream(stderr)); + try { + ActiveWalletConfig.setActiveAddress(readWalletAddress(walletFile)); + GlobalOptions opts = GlobalOptions.parse(new String[]{"--password-stdin", "needs-wallet"}); + StandardCliRunner.MasterPasswordProvider provider = StandardCliRunner.defaultProvider( + opts, new java.io.ByteArrayInputStream( + "TempPass123!A\n".getBytes(StandardCharsets.UTF_8))); + int exitCode = new StandardCliRunner(registry, opts, provider).execute(); + + Assert.assertEquals(0, exitCode); + Assert.assertTrue(handlerCalled[0]); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + System.setProperty("user.dir", originalUserDir); + } + } + + @Test + public void passwordStdinFailsExplicitlyWhenStdinIsEmpty() throws Exception { + CommandRegistry registry = new CommandRegistry(); + registry.add(CommandDefinition.builder() + .name("needs-wallet") + .description("Command requiring auth") + .handler((ctx, opts, wrapper, out) -> out.raw("ok")) + .build()); + + String originalUserDir = System.getProperty("user.dir"); + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + File tempDir = Files.createTempDirectory("runner-stdin-empty").toFile(); + File walletDir = new File(tempDir, "Wallet"); + Assert.assertTrue(walletDir.mkdirs()); + File walletFile = createWalletFile(walletDir, "alpha", "0000000000000000000000000000000000000000000000000000000000000001"); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + + System.setProperty("user.dir", tempDir.getAbsolutePath()); + System.setOut(new PrintStream(stdout)); + System.setErr(new PrintStream(stderr)); + try { + ActiveWalletConfig.setActiveAddress(readWalletAddress(walletFile)); + GlobalOptions opts = GlobalOptions.parse(new String[]{"--password-stdin", "needs-wallet"}); + StandardCliRunner.MasterPasswordProvider provider = StandardCliRunner.defaultProvider( + opts, new java.io.ByteArrayInputStream(new byte[0])); + int exitCode = new StandardCliRunner(registry, opts, provider).execute(); + + Assert.assertEquals(1, exitCode); + Assert.assertTrue(stderr.toString("UTF-8").contains("--password-stdin produced no input")); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + System.setProperty("user.dir", originalUserDir); + } + } + @Test public void requireCommandFailsWhenMasterPasswordIsInvalid() throws Exception { CommandRegistry registry = new CommandRegistry(); @@ -655,6 +754,42 @@ public void neverAuthCommandIgnoresBrokenActiveWalletConfig() throws Exception { } } + @Test + public void neverAuthCommandIgnoresMalformedUserAliasFile() throws Exception { + String originalUserDir = System.getProperty("user.dir"); + NetType originalNetwork = WalletApi.getCurrentNetwork(); + PrintStream originalErr = System.err; + File tempDir = Files.createTempDirectory("runner-broken-aliases").toFile(); + File aliasesDir = new File(tempDir, "Wallet/aliases"); + File mainAliases = new File(aliasesDir, "main.json"); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + + Assert.assertTrue(aliasesDir.mkdirs()); + Files.write(mainAliases.toPath(), "{ broken json".getBytes(StandardCharsets.UTF_8)); + + CommandRegistry registry = new CommandRegistry(); + QueryCommands.register(registry); + + System.setProperty("user.dir", tempDir.getAbsolutePath()); + System.setErr(new PrintStream(stderr)); + WalletApi.setCurrentNetwork(NetType.MAIN); + try { + GlobalOptions opts = GlobalOptions.parse(new String[]{"--output", "json", "current-network"}); + int exitCode = new StandardCliRunner(registry, opts, + new PrintStream(stdout), new PrintStream(stderr)).execute(); + + Assert.assertEquals(0, exitCode); + String json = stdout.toString("UTF-8"); + Assert.assertTrue(json.contains("\"success\": true")); + Assert.assertTrue(stderr.toString("UTF-8").contains("warn: failed to read user alias file")); + } finally { + WalletApi.setCurrentNetwork(originalNetwork); + System.setErr(originalErr); + System.setProperty("user.dir", originalUserDir); + } + } + @Test public void autoAuthPolicyFollowsRegisteredCommandMetadata() { CommandRegistry queryRegistry = new CommandRegistry(); @@ -1039,6 +1174,46 @@ public void voteWitnessRejectsNonPositiveVoteCountAsUsageError() throws Exceptio } } + @Test + public void authenticateInjectsLedgerSignerIntoActiveWallet() throws Exception { + CommandRegistry registry = new CommandRegistry(); + final org.tron.walletcli.cli.ledger.LedgerSigner[] capturedSigner = {null}; + registry.add(CommandDefinition.builder() + .name("inspect-signer") + .description("Captures the ledger signer wired into the active wallet") + .handler((ctx, opts, wrapper, out) -> { + capturedSigner[0] = wrapper.getWallet().getLedgerSigner(); + out.raw("ok"); + }) + .build()); + + String originalUserDir = System.getProperty("user.dir"); + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + File tempDir = Files.createTempDirectory("runner-ledger-wiring").toFile(); + File walletDir = new File(tempDir, "Wallet"); + Assert.assertTrue(walletDir.mkdirs()); + File walletFile = createWalletFile(walletDir, "alpha", "0000000000000000000000000000000000000000000000000000000000000001"); + + System.setProperty("user.dir", tempDir.getAbsolutePath()); + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setErr(new PrintStream(new ByteArrayOutputStream())); + try { + ActiveWalletConfig.setActiveAddress(readWalletAddress(walletFile)); + GlobalOptions opts = GlobalOptions.parse(new String[]{"inspect-signer"}); + int exitCode = new StandardCliRunner(registry, opts, () -> "TempPass123!A").execute(); + + Assert.assertEquals(0, exitCode); + Assert.assertNotNull("Standard CLI authenticate must inject a LedgerSigner so that any " + + "subsequently-signed Ledger transaction routes through the non-interactive path", + capturedSigner[0]); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + System.setProperty("user.dir", originalUserDir); + } + } + @Test public void requireCommandAuthenticatesBeforeHandlerExecution() throws Exception { CommandRegistry registry = new CommandRegistry(); diff --git a/src/test/java/org/tron/walletcli/cli/StdinPasswordReaderTest.java b/src/test/java/org/tron/walletcli/cli/StdinPasswordReaderTest.java new file mode 100644 index 000000000..a042253e3 --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/StdinPasswordReaderTest.java @@ -0,0 +1,80 @@ +package org.tron.walletcli.cli; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public class StdinPasswordReaderTest { + + @Test + public void readsRawBytesAndStripsTrailingNewline() { + StdinPasswordReader r = new StdinPasswordReader(stream("Secret123!A\n")); + Assert.assertEquals("Secret123!A", r.get()); + } + + @Test + public void stripsTrailingCrlfButPreservesInternalWhitespace() { + StdinPasswordReader r = new StdinPasswordReader(stream("Secret with spaces\r\n")); + Assert.assertEquals("Secret with spaces", r.get()); + } + + @Test + public void preservesPasswordWithoutTrailingNewline() { + StdinPasswordReader r = new StdinPasswordReader(stream("NoTrailing")); + Assert.assertEquals("NoTrailing", r.get()); + } + + @Test + public void preservesInternalNewlinesButOnlyStripsLastOne() { + StdinPasswordReader r = new StdinPasswordReader(stream("line1\nline2\n")); + Assert.assertEquals("line1\nline2", r.get()); + } + + @Test + public void emptyInputReturnsNull() { + StdinPasswordReader r = new StdinPasswordReader(stream("")); + Assert.assertNull(r.get()); + } + + @Test + public void inputThatIsOnlyANewlineReturnsNull() { + StdinPasswordReader r = new StdinPasswordReader(stream("\n")); + Assert.assertNull(r.get()); + } + + @Test + public void readIsMemoizedAcrossMultipleCalls() { + // ByteArrayInputStream returns -1 on EOF and stays drained, so a second get() returning + // "OnlyOnce" rather than null proves the value is cached and the stream is not re-read. + InputStream once = new ByteArrayInputStream("OnlyOnce\n".getBytes(StandardCharsets.UTF_8)); + StdinPasswordReader r = new StdinPasswordReader(once); + Assert.assertEquals("OnlyOnce", r.get()); + Assert.assertEquals("OnlyOnce", r.get()); + // After the first get() the stream is at EOF — confirm a fresh read would yield null. + Assert.assertNull(new StdinPasswordReader(once).get()); + } + + @Test + public void ioExceptionIsWrappedAsIllegalState() { + InputStream broken = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("kaboom"); + } + }; + try { + new StdinPasswordReader(broken).get(); + Assert.fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + Assert.assertTrue(e.getMessage().contains("kaboom")); + } + } + + private static InputStream stream(String s) { + return new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/org/tron/walletcli/cli/aliases/AliasEntryTest.java b/src/test/java/org/tron/walletcli/cli/aliases/AliasEntryTest.java new file mode 100644 index 000000000..b6960b26c --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/aliases/AliasEntryTest.java @@ -0,0 +1,50 @@ +package org.tron.walletcli.cli.aliases; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class AliasEntryTest { + + private byte[] addr() { + byte[] a = new byte[21]; + a[0] = 0x41; + return a; + } + + @Test + public void nameIsUpperCasedAndTrimmed() { + AliasEntry e = AliasEntry.token(" usdt ", addr(), 6, "builtin"); + assertEquals("USDT", e.getName()); + } + + @Test + public void accountKeepsCaseFolded() { + AliasEntry e = AliasEntry.account(" Alice ", addr(), "user", "hot wallet"); + assertEquals("ALICE", e.getName()); + assertEquals("hot wallet", e.getNote()); + } + + @Test + public void addressIsCopiedDefensively() { + byte[] a = addr(); + AliasEntry e = AliasEntry.token("USDT", a, 6, "builtin"); + a[0] = 0x00; + assertEquals(0x41, e.getAddress()[0]); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsWrongAddressLength() { + AliasEntry.token("USDT", new byte[20], 6, "builtin"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNegativeTokenDecimals() { + AliasEntry.token("USDT", addr(), -1, "builtin"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsTooLargeTokenDecimals() { + AliasEntry.token("USDT", addr(), 19, "builtin"); + } +} diff --git a/src/test/java/org/tron/walletcli/cli/aliases/AliasResolverTest.java b/src/test/java/org/tron/walletcli/cli/aliases/AliasResolverTest.java new file mode 100644 index 000000000..3a371f2f2 --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/aliases/AliasResolverTest.java @@ -0,0 +1,41 @@ +package org.tron.walletcli.cli.aliases; + +import java.util.Arrays; +import org.junit.Test; +import org.tron.walletserver.WalletApi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class AliasResolverTest { + + private byte[] addr() { + return WalletApi.decodeFromBase58Check("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + } + + @Test + public void directAddressPassesThroughWithoutRecordingAlias() { + AliasResolver resolver = new AliasResolver(AliasStore.empty()); + ResolutionResult result = resolver.resolve( + "to", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", AliasType.ACCOUNT); + assertNotNull(result.getAddress()); + assertEquals(0, resolver.getResolved().size()); + } + + @Test + public void tokenAliasResolvesAndIsRecorded() { + AliasStore store = new AliasStore(Arrays.asList( + AliasEntry.token("USDT", addr(), 6, "builtin"))); + AliasResolver resolver = new AliasResolver(store); + ResolutionResult result = resolver.resolve("contract", "usdt", AliasType.TOKEN); + assertEquals("USDT", result.getName()); + assertEquals(1, resolver.getResolved().size()); + } + + @Test(expected = AliasResolutionException.class) + public void wrongTypeDoesNotResolve() { + AliasStore store = new AliasStore(Arrays.asList( + AliasEntry.token("USDT", addr(), 6, "builtin"))); + new AliasResolver(store).resolve("to", "usdt", AliasType.ACCOUNT); + } +} diff --git a/src/test/java/org/tron/walletcli/cli/aliases/AliasStoreLoaderTest.java b/src/test/java/org/tron/walletcli/cli/aliases/AliasStoreLoaderTest.java new file mode 100644 index 000000000..6a363a2f7 --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/aliases/AliasStoreLoaderTest.java @@ -0,0 +1,141 @@ +package org.tron.walletcli.cli.aliases; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Arrays; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.enums.NetType; +import org.tron.walletserver.WalletApi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class AliasStoreLoaderTest { + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + private String originalUserDir; + private AliasStoreLoader loader; + + @Before + public void setUp() { + originalUserDir = System.getProperty("user.dir"); + System.setProperty("user.dir", temp.getRoot().getAbsolutePath()); + loader = new AliasStoreLoader(); + } + + @After + public void tearDown() { + System.setProperty("user.dir", originalUserDir); + } + + @Test + public void builtinMainContainsUSDT() throws Exception { + AliasStore store = loader.loadBuiltin(NetType.MAIN); + AliasEntry usdt = store.find("USDT", AliasType.TOKEN); + assertNotNull(usdt); + assertEquals("builtin", usdt.getSource()); + assertEquals(6, usdt.getDecimals()); + } + + @Test + public void missingUserFileLoadsAsEmpty() throws Exception { + assertEquals(0, loader.loadUser(NetType.SHASTA).listAll().size()); + } + + @Test + public void saveAndLoadUserFileRoundTrips() throws Exception { + AliasEntry alice = AliasEntry.account("alice", address(), "user", "hot"); + loader.saveUser(NetType.SHASTA, Arrays.asList(alice)); + + AliasEntry loaded = loader.loadUser(NetType.SHASTA).find("ALICE", AliasType.ACCOUNT); + assertNotNull(loaded); + assertEquals("hot", loaded.getNote()); + assertEquals(WalletApi.encode58Check(address()), WalletApi.encode58Check(loaded.getAddress())); + } + + @Test + public void malformedEntriesAreSkipped() throws Exception { + writeUserFile(NetType.SHASTA, "{ \"entries\": [" + + "{\"name\":\"OK\",\"type\":\"TOKEN\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\",\"decimals\":6}," + + "{\"name\":\"1bad\",\"type\":\"TOKEN\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\"}," + + "{\"name\":\"trx\",\"type\":\"ACCOUNT\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\"}," + + "{\"name\":\"NoType\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\"}," + + "{\"name\":\"BadType\",\"type\":\"NFT\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\"}," + + "{\"name\":\"BadAddr\",\"type\":\"TOKEN\",\"address\":\"not-base58\"}" + + "] }"); + + AliasStore store = loader.loadUser(NetType.SHASTA); + + assertNotNull(store.find("OK", AliasType.TOKEN)); + assertNull(store.find("1bad", AliasType.TOKEN)); + assertNull(store.find("trx", AliasType.ACCOUNT)); + assertNull(store.find("NoType", AliasType.TOKEN)); + assertNull(store.find("BadType", AliasType.TOKEN)); + assertNull(store.find("BadAddr", AliasType.TOKEN)); + assertEquals(1, store.listAll().size()); + } + + @Test + public void malformedUserFileLoadsAsEmpty() throws Exception { + writeUserFile(NetType.SHASTA, "{ broken json"); + + AliasStore store = loader.loadUser(NetType.SHASTA); + + assertEquals(0, store.listAll().size()); + } + + @Test + public void malformedUserFileThrowsForStrictLoad() throws Exception { + writeUserFile(NetType.SHASTA, "{ broken json"); + + try { + loader.loadUserOrThrow(NetType.SHASTA); + fail("Expected malformed user alias file to fail strict load"); + } catch (IOException e) { + assertNotNull(e.getMessage()); + } + } + + @Test + public void layeredLoadKeepsBuiltinsAuthoritative() throws Exception { + writeUserFile(NetType.MAIN, "{ \"entries\": [" + + "{\"name\":\"USDT\",\"type\":\"ACCOUNT\",\"address\":\"TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL\"}," + + "{\"name\":\"ALICE\",\"type\":\"ACCOUNT\",\"address\":\"TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL\"}" + + "] }"); + + AliasStore store = loader.loadLayered(NetType.MAIN); + + AliasEntry usdt = store.find("USDT", AliasType.TOKEN); + assertNotNull(usdt); + assertEquals("builtin", usdt.getSource()); + assertNull(store.find("USDT", AliasType.ACCOUNT)); + assertNotNull(store.find("ALICE", AliasType.ACCOUNT)); + } + + private byte[] address() { + return WalletApi.decodeFromBase58Check("TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL"); + } + + private void writeUserFile(NetType network, String json) throws IOException { + File file = loader.userFile(network); + File dir = file.getParentFile(); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Could not create alias directory: " + dir); + } + FileWriter writer = new FileWriter(file); + try { + writer.write(json); + } finally { + writer.close(); + } + } +} diff --git a/src/test/java/org/tron/walletcli/cli/aliases/AliasStoreTest.java b/src/test/java/org/tron/walletcli/cli/aliases/AliasStoreTest.java new file mode 100644 index 000000000..e9fe28454 --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/aliases/AliasStoreTest.java @@ -0,0 +1,39 @@ +package org.tron.walletcli.cli.aliases; + +import java.util.Arrays; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class AliasStoreTest { + + private byte[] addr(int last) { + byte[] a = new byte[21]; + a[0] = 0x41; + a[20] = (byte) last; + return a; + } + + @Test + public void findIsCaseInsensitiveAndTypeFiltered() { + AliasStore store = new AliasStore(Arrays.asList( + AliasEntry.token("USDT", addr(1), 6, "builtin"))); + assertNotNull(store.find("usdt", AliasType.TOKEN)); + assertNull(store.find("usdt", AliasType.ACCOUNT)); + } + + @Test + public void builtinsWinWhenLayered() { + AliasStore builtin = new AliasStore(Arrays.asList( + AliasEntry.token("USDT", addr(1), 6, "builtin"))); + AliasStore user = new AliasStore(Arrays.asList( + AliasEntry.account("usdt", addr(2), "user", null), + AliasEntry.account("alice", addr(3), "user", null))); + AliasStore layered = AliasStore.layered(builtin, user); + assertEquals("builtin", layered.find("USDT", AliasType.TOKEN).getSource()); + assertNotNull(layered.find("alice", AliasType.ACCOUNT)); + assertEquals(2, layered.listAll().size()); + } +} diff --git a/src/test/java/org/tron/walletcli/cli/aliases/AliasValidationTest.java b/src/test/java/org/tron/walletcli/cli/aliases/AliasValidationTest.java new file mode 100644 index 000000000..cc557bdac --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/aliases/AliasValidationTest.java @@ -0,0 +1,38 @@ +package org.tron.walletcli.cli.aliases; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class AliasValidationTest { + + @Test + public void acceptsTypicalNames() { + AliasValidation.requireValidName("USDT"); + AliasValidation.requireValidName("alice"); + AliasValidation.requireValidName("hot-wallet"); + AliasValidation.requireValidName("v2.usdt"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsBase58() { + AliasValidation.requireValidName("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsHex() { + AliasValidation.requireValidName("41a614f803b6fd780986a42c78ec9c7f77e6ded13c"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsReserved() { + AliasValidation.requireValidName("me"); + } + + @Test + public void looksLikeAddressDistinguishesNames() { + assertTrue(AliasValidation.looksLikeAddress("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")); + assertFalse(AliasValidation.looksLikeAddress("alice")); + } +} diff --git a/src/test/java/org/tron/walletcli/cli/ledger/LedgerSignOutcomeTest.java b/src/test/java/org/tron/walletcli/cli/ledger/LedgerSignOutcomeTest.java new file mode 100644 index 000000000..3d488c79e --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/ledger/LedgerSignOutcomeTest.java @@ -0,0 +1,68 @@ +package org.tron.walletcli.cli.ledger; + +import org.junit.Assert; +import org.junit.Test; +import org.tron.trident.proto.Chain; + +public class LedgerSignOutcomeTest { + + @Test + public void okCarriesSignedTransactionAndNoErrorCode() { + Chain.Transaction tx = Chain.Transaction.newBuilder().build(); + LedgerSignOutcome r = LedgerSignOutcome.ok(tx); + Assert.assertEquals(LedgerSignOutcome.Status.OK, r.getStatus()); + Assert.assertSame(tx, r.getSignedTransaction()); + Assert.assertNull(r.getGasfreeSignature()); + Assert.assertNull(r.errorCode()); + } + + @Test + public void okGasfreeCarriesSignatureOnly() { + LedgerSignOutcome r = LedgerSignOutcome.okGasfree("deadbeef"); + Assert.assertEquals(LedgerSignOutcome.Status.OK, r.getStatus()); + Assert.assertEquals("deadbeef", r.getGasfreeSignature()); + Assert.assertNull(r.getSignedTransaction()); + Assert.assertNull(r.errorCode()); + } + + @Test + public void failureRejectsOkStatus() { + try { + LedgerSignOutcome.failure(LedgerSignOutcome.Status.OK, "x"); + Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("OK")); + } + } + + @Test + public void everyNonOkStatusMapsToALedgerErrorCode() { + for (LedgerSignOutcome.Status s : LedgerSignOutcome.Status.values()) { + if (s == LedgerSignOutcome.Status.OK) continue; + LedgerSignOutcome r = LedgerSignOutcome.failure(s, "msg"); + Assert.assertNotNull("status " + s + " should produce an error code", r.errorCode()); + Assert.assertTrue("status " + s + " error code should start with ledger_", + r.errorCode().startsWith("ledger_")); + } + } + + @Test + public void errorCodesAreStable() { + Assert.assertEquals("ledger_not_connected", + LedgerSignOutcome.failure(LedgerSignOutcome.Status.NOT_CONNECTED, "x").errorCode()); + Assert.assertEquals("ledger_app_not_open", + LedgerSignOutcome.failure(LedgerSignOutcome.Status.APP_NOT_OPEN, "x").errorCode()); + Assert.assertEquals("ledger_sign_by_hash_disabled", + LedgerSignOutcome.failure(LedgerSignOutcome.Status.SIGN_BY_HASH_DISABLED, "x").errorCode()); + Assert.assertEquals("ledger_unsupported_contract", + LedgerSignOutcome.failure(LedgerSignOutcome.Status.UNSUPPORTED_CONTRACT, "x").errorCode()); + Assert.assertEquals("ledger_already_signing", + LedgerSignOutcome.failure(LedgerSignOutcome.Status.ALREADY_SIGNING, "x").errorCode()); + Assert.assertEquals("ledger_user_rejected", + LedgerSignOutcome.failure(LedgerSignOutcome.Status.USER_REJECTED, "x").errorCode()); + Assert.assertEquals("ledger_timeout", + LedgerSignOutcome.failure(LedgerSignOutcome.Status.TIMEOUT, "x").errorCode()); + Assert.assertEquals("ledger_sign_failed", + LedgerSignOutcome.failure(LedgerSignOutcome.Status.SIGN_FAILED, "x").errorCode()); + } +} diff --git a/src/test/java/org/tron/walletcli/cli/ledger/NonInteractiveLedgerSignerTest.java b/src/test/java/org/tron/walletcli/cli/ledger/NonInteractiveLedgerSignerTest.java new file mode 100644 index 000000000..7262f0f71 --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/ledger/NonInteractiveLedgerSignerTest.java @@ -0,0 +1,359 @@ +package org.tron.walletcli.cli.ledger; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.trident.proto.Chain; +import org.tron.walletcli.cli.OutputFormatter; + +public class NonInteractiveLedgerSignerTest { + + private static final String ADDRESS = "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL"; + private static final String PATH = "m/44'/195'/0'/0/0"; + private static final String DEV_PATH = "/dev/hidraw0"; + + private FakeFinder finder; + private FakeStateReader stateReader; + private FakeExecutor executor; + private FakeResultReader resultReader; + private FakeContractSupport contractSupport; + private ByteArrayOutputStream stderrBuf; + private OutputFormatter formatter; + + @Before + public void setUp() { + finder = new FakeFinder(); + stateReader = new FakeStateReader(); + executor = new FakeExecutor(); + resultReader = new FakeResultReader(); + contractSupport = new FakeContractSupport(); + stderrBuf = new ByteArrayOutputStream(); + formatter = new OutputFormatter(OutputFormatter.OutputMode.JSON, false, + new PrintStream(new ByteArrayOutputStream()), new PrintStream(stderrBuf)); + } + + private NonInteractiveLedgerSigner newSigner() { + return new NonInteractiveLedgerSigner(formatter, finder, stateReader, executor, resultReader, + contractSupport); + } + + private LedgerSignOutcome signNonGasfree() { + return newSigner().sign(Chain.Transaction.getDefaultInstance(), PATH, ADDRESS, false); + } + + // ---------- happy path ---------- + + @Test + public void signSucceedsWhenUserConfirmsAndSignatureIsRecorded() { + finder.next = new FakeDeviceHandle(DEV_PATH); + Chain.Transaction signed = Chain.Transaction.newBuilder().build(); + resultReader.signedTransaction = Optional.of(signed); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_CONFIRMED); + + LedgerSignOutcome r = signNonGasfree(); + + Assert.assertEquals(LedgerSignOutcome.Status.OK, r.getStatus()); + Assert.assertSame(signed, r.getSignedTransaction()); + } + + @Test + public void signSucceedsForGasfreeWhenSignatureIsRecorded() { + finder.next = new FakeDeviceHandle(DEV_PATH); + resultReader.gasfreeSignature = Optional.of("deadbeef"); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_CONFIRMED); + + LedgerSignOutcome r = newSigner().sign(Chain.Transaction.getDefaultInstance(), + PATH, ADDRESS, true); + + Assert.assertEquals(LedgerSignOutcome.Status.OK, r.getStatus()); + Assert.assertEquals("deadbeef", r.getGasfreeSignature()); + } + + @Test + public void doesNotReportSuccessWhenSignedTransactionPresentButStateIsSigning() { + // Regression: the listener stashes the input transaction in TSM before the user presses + // the button. A non-null signedTransaction therefore does not by itself indicate confirm — + // the state file is the authoritative discriminator. + finder.next = new FakeDeviceHandle(DEV_PATH); + resultReader.signedTransaction = Optional.of(Chain.Transaction.newBuilder().build()); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_SIGNING); + + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.TIMEOUT, r.getStatus()); + } + + // ---------- discovery failures ---------- + + @Test + public void returnsNotConnectedWhenDeviceMissing() { + finder.next = null; + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.NOT_CONNECTED, r.getStatus()); + } + + @Test + public void returnsNotConnectedWhenFinderThrows() { + finder.toThrow = new IllegalStateException("transport boom"); + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.NOT_CONNECTED, r.getStatus()); + Assert.assertTrue(r.getMessage().contains("transport boom")); + } + + @Test + public void returnsAppNotOpenWhenFinderThrowsAppNotOpenException() { + // Simulates ProductionLedgerPorts detecting that a Ledger is physically attached + // (hasAnyLedgerAttached = true) but HID open() failed because the Tron app is not running. + finder.toThrow = new LedgerPorts.AppNotOpenException( + "Open the Tron app on your Ledger device and try again"); + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.APP_NOT_OPEN, r.getStatus()); + Assert.assertTrue(r.getMessage().contains("Tron app")); + } + + @Test + public void returnsUnsupportedContractBeforeDeviceLookup() { + contractSupport.canSign = false; + finder.next = new FakeDeviceHandle(DEV_PATH); + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.UNSUPPORTED_CONTRACT, r.getStatus()); + Assert.assertFalse("device lookup should not happen for unsupported contract", + finder.findCalled); + } + + // ---------- APDU error mapping ---------- + + @Test + public void returnsAppNotOpenOn0x6511() { + finder.next = new FakeDeviceHandle(DEV_PATH); + executor.lastSendResult = new byte[]{0x65, 0x11}; + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.APP_NOT_OPEN, r.getStatus()); + Assert.assertEquals("APDU failures must clear the current signing state", + NonInteractiveLedgerSigner.STATE_CANCEL, stateReader.updatedState); + } + + @Test + public void returnsSignByHashDisabledOn0x6a8c() { + finder.next = new FakeDeviceHandle(DEV_PATH); + executor.lastSendResult = new byte[]{0x6a, (byte) 0x8c}; + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.SIGN_BY_HASH_DISABLED, r.getStatus()); + Assert.assertEquals("APDU failures must clear the current signing state", + NonInteractiveLedgerSigner.STATE_CANCEL, stateReader.updatedState); + } + + @Test + public void returnsSignFailedOnUnknownApduResponse() { + finder.next = new FakeDeviceHandle(DEV_PATH); + executor.lastSendResult = new byte[]{(byte) 0xff, (byte) 0xff}; + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.SIGN_FAILED, r.getStatus()); + Assert.assertTrue(r.getMessage().toLowerCase().contains("0xffff")); + Assert.assertEquals("APDU failures must clear the current signing state", + NonInteractiveLedgerSigner.STATE_CANCEL, stateReader.updatedState); + } + + // ---------- pre-state ---------- + + @Test + public void returnsAlreadySigningWhenPriorStateIsSigning() { + FakeDeviceHandle d = new FakeDeviceHandle(DEV_PATH); + finder.next = d; + stateReader.preState = Optional.of(NonInteractiveLedgerSigner.STATE_SIGNING); + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.ALREADY_SIGNING, r.getStatus()); + Assert.assertFalse("listener should not be invoked when a sign is already in progress", + executor.executeCalled); + Assert.assertTrue("device must be closed on ALREADY_SIGNING early return", d.closed); + } + + // ---------- post-state outcomes ---------- + + @Test + public void returnsUserRejectedWhenPostStateIsCancel() { + finder.next = new FakeDeviceHandle(DEV_PATH); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_CANCEL); + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.USER_REJECTED, r.getStatus()); + } + + @Test + public void returnsTimeoutWhenPostStateIsTimeout() { + finder.next = new FakeDeviceHandle(DEV_PATH); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_TIMEOUT); + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.TIMEOUT, r.getStatus()); + } + + @Test + public void returnsTimeoutWhenPostStateRemainsSigning() { + finder.next = new FakeDeviceHandle(DEV_PATH); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_SIGNING); + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.TIMEOUT, r.getStatus()); + Assert.assertEquals("timeout must clear the current signing state so the next CLI command " + + "is not blocked by a stale signing entry", + NonInteractiveLedgerSigner.STATE_TIMEOUT, stateReader.timedOutState); + Assert.assertEquals(DEV_PATH, stateReader.timedOutDevicePath); + Assert.assertNotNull(stateReader.timedOutTxid); + } + + @Test + public void marksCurrentTxSigningBeforeExecutingLedgerRequest() { + finder.next = new FakeDeviceHandle(DEV_PATH); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_CANCEL); + + LedgerSignOutcome r = signNonGasfree(); + + Assert.assertEquals(LedgerSignOutcome.Status.USER_REJECTED, r.getStatus()); + Assert.assertEquals("signer must reset stale state for the current txid before waiting", + NonInteractiveLedgerSigner.STATE_SIGNING, stateReader.signingState); + Assert.assertEquals(DEV_PATH, stateReader.signingDevicePath); + Assert.assertNotNull(stateReader.signingTxid); + Assert.assertEquals("state reset must happen before listener execution", + stateReader.signingTxid, executor.txidAtExecute); + } + + @Test + public void returnsSignFailedWhenPostStateIsConfirmedButNoSignaturePresent() { + finder.next = new FakeDeviceHandle(DEV_PATH); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_CONFIRMED); + // resultReader returns empty + LedgerSignOutcome r = signNonGasfree(); + Assert.assertEquals(LedgerSignOutcome.Status.SIGN_FAILED, r.getStatus()); + } + + // ---------- invariants ---------- + + @Test + public void closesDeviceOnEveryExitPath() { + FakeDeviceHandle d = new FakeDeviceHandle(DEV_PATH); + finder.next = d; + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_CANCEL); + signNonGasfree(); + Assert.assertTrue("device must be closed even on USER_REJECTED", d.closed); + + FakeDeviceHandle d2 = new FakeDeviceHandle(DEV_PATH); + finder.next = d2; + Chain.Transaction signed = Chain.Transaction.newBuilder().build(); + resultReader.signedTransaction = Optional.of(signed); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_CONFIRMED); + signNonGasfree(); + Assert.assertTrue("device must be closed on OK", d2.closed); + } + + @Test + public void resetsResultReaderOnEveryExitPath() { + finder.next = new FakeDeviceHandle(DEV_PATH); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_CANCEL); + signNonGasfree(); + Assert.assertTrue("must reset before sign and after; at least one reset on exit", + resultReader.resetCalls >= 1); + } + + @Test + public void emitsExactlyOneStderrNoticeIncludingAddress() throws Exception { + finder.next = new FakeDeviceHandle(DEV_PATH); + stateReader.postState = Optional.of(NonInteractiveLedgerSigner.STATE_CONFIRMED); + Chain.Transaction signed = Chain.Transaction.newBuilder().build(); + resultReader.signedTransaction = Optional.of(signed); + signNonGasfree(); + String stderr = stderrBuf.toString(StandardCharsets.UTF_8.name()); + Assert.assertTrue("notice must include the address", stderr.contains(ADDRESS)); + Assert.assertEquals("notice must be exactly one line", 1, + stderr.split("\\R").length); + Assert.assertTrue(stderr.toLowerCase().contains("ledger")); + } + + // ---------- fakes ---------- + + private static final class FakeDeviceHandle implements LedgerPorts.DeviceHandle { + private final String path; + boolean closed; + FakeDeviceHandle(String path) { this.path = path; } + @Override public String path() { return path; } + @Override public boolean isClosed() { return closed; } + @Override public void close() { closed = true; } + } + + private static final class FakeFinder implements LedgerPorts.HidDeviceFinder { + LedgerPorts.DeviceHandle next; + RuntimeException toThrow; + boolean findCalled; + @Override public LedgerPorts.DeviceHandle find(String address, String bip44Path) { + findCalled = true; + if (toThrow != null) throw toThrow; + return next; + } + } + + private static final class FakeStateReader implements LedgerPorts.SignStateReader { + Optional preState = Optional.empty(); + Optional postState = Optional.empty(); + @Override public Optional lastState(String devicePath) { + return preState; + } + @Override public Optional stateByTxid(String devicePath, String txid) { + return postState; + } + String signingDevicePath; + String signingTxid; + String signingState; + @Override public void markSigning(String devicePath, String txid) { + signingDevicePath = devicePath; + signingTxid = txid; + signingState = NonInteractiveLedgerSigner.STATE_SIGNING; + } + String updatedDevicePath; + String updatedTxid; + String updatedState; + @Override public void markCanceled(String devicePath, String txid) { + updatedDevicePath = devicePath; + updatedTxid = txid; + updatedState = NonInteractiveLedgerSigner.STATE_CANCEL; + } + String timedOutDevicePath; + String timedOutTxid; + String timedOutState; + @Override public void markTimedOut(String devicePath, String txid) { + timedOutDevicePath = devicePath; + timedOutTxid = txid; + timedOutState = NonInteractiveLedgerSigner.STATE_TIMEOUT; + } + } + + private static final class FakeExecutor implements LedgerPorts.SignExecutor { + byte[] lastSendResult; + boolean executeCalled; + String txidAtExecute; + @Override public boolean executeSignListen(LedgerPorts.DeviceHandle device, + Chain.Transaction tx, String path, boolean gasfree) { + executeCalled = true; + txidAtExecute = org.tron.common.utils.TransactionUtils.getTransactionId(tx).toString(); + return true; + } + @Override public byte[] lastSendResultBytes() { return lastSendResult; } + } + + private static final class FakeResultReader implements LedgerPorts.SignResultReader { + Optional gasfreeSignature = Optional.empty(); + Optional signedTransaction = Optional.empty(); + int resetCalls; + Chain.Transaction preparedTransaction; + @Override public void prepareTransaction(Chain.Transaction transaction) { + preparedTransaction = transaction; + } + @Override public Optional gasfreeSignature() { return gasfreeSignature; } + @Override public Optional signedTransaction() { return signedTransaction; } + @Override public void reset() { resetCalls++; } + } + + private static final class FakeContractSupport implements LedgerPorts.ContractSupport { + boolean canSign = true; + @Override public boolean canSign(Chain.Transaction transaction) { return canSign; } + } +} diff --git a/src/test/java/org/tron/walletcli/cli/ledger/SystemOutSuppressorTest.java b/src/test/java/org/tron/walletcli/cli/ledger/SystemOutSuppressorTest.java new file mode 100644 index 000000000..c06df202d --- /dev/null +++ b/src/test/java/org/tron/walletcli/cli/ledger/SystemOutSuppressorTest.java @@ -0,0 +1,43 @@ +package org.tron.walletcli.cli.ledger; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import org.junit.Assert; +import org.junit.Test; + +public class SystemOutSuppressorTest { + + @Test + public void capturesPrintsAndRestoresStreamOnClose() { + PrintStream original = System.out; + ByteArrayOutputStream sentinel = new ByteArrayOutputStream(); + System.setOut(new PrintStream(sentinel)); + try { + try (SystemOutSuppressor s = SystemOutSuppressor.capture()) { + System.out.println("noisy"); + System.out.print("more"); + Assert.assertTrue(s.drained().contains("noisy")); + Assert.assertTrue(s.drained().contains("more")); + } + System.out.println("after-close"); + Assert.assertTrue("post-close output reaches the surrounding stream", + sentinel.toString().contains("after-close")); + Assert.assertFalse("captured output did not leak to the surrounding stream", + sentinel.toString().contains("noisy")); + } finally { + System.setOut(original); + } + } + + @Test + public void drainedReturnsEmptyWhenNothingPrinted() { + PrintStream original = System.out; + try { + try (SystemOutSuppressor s = SystemOutSuppressor.capture()) { + Assert.assertEquals("", s.drained()); + } + } finally { + System.setOut(original); + } + } +}