From e25ac9cb65e933cdb621e2a67e3ac0cf64a57869 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 6 May 2026 14:16:24 +0800 Subject: [PATCH 1/6] feat: stdin credential input --- docs/standard-cli-contract-spec.md | 37 ++++++- .../tron/walletcli/cli/CommandRegistry.java | 3 +- .../org/tron/walletcli/cli/GlobalOptions.java | 6 ++ .../tron/walletcli/cli/StandardCliRunner.java | 37 ++++++- .../walletcli/cli/StdinPasswordReader.java | 64 ++++++++++++ .../tron/walletcli/cli/GlobalOptionsTest.java | 23 +++++ .../walletcli/cli/StandardCliRunnerTest.java | 99 +++++++++++++++++++ .../cli/StdinPasswordReaderTest.java | 80 +++++++++++++++ 8 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/tron/walletcli/cli/StdinPasswordReader.java create mode 100644 src/test/java/org/tron/walletcli/cli/StdinPasswordReaderTest.java diff --git a/docs/standard-cli-contract-spec.md b/docs/standard-cli-contract-spec.md index 43190f43..0455f556 100644 --- a/docs/standard-cli-contract-spec.md +++ b/docs/standard-cli-contract-spec.md @@ -558,13 +558,42 @@ 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 + ### Handler Boundary - handlers must not re-decide runner auth policy ad hoc @@ -831,7 +860,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 +898,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 diff --git a/src/main/java/org/tron/walletcli/cli/CommandRegistry.java b/src/main/java/org/tron/walletcli/cli/CommandRegistry.java index d8d8883b..7506578d 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 67fd3dcb..34adf8d5 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/StandardCliRunner.java b/src/main/java/org/tron/walletcli/cli/StandardCliRunner.java index 0504f727..3a3524a5 100644 --- a/src/main/java/org/tron/walletcli/cli/StandardCliRunner.java +++ b/src/main/java/org/tron/walletcli/cli/StandardCliRunner.java @@ -10,6 +10,7 @@ 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 +31,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 +48,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(); @@ -140,9 +155,27 @@ static boolean requiresAutoAuth(CommandDefinition cmd, ParsedOptions opts) { */ 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 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 00000000..d1a30ed9 --- /dev/null +++ b/src/main/java/org/tron/walletcli/cli/StdinPasswordReader.java @@ -0,0 +1,64 @@ +package org.tron.walletcli.cli; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * 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]; + try { + int n; + while ((n = in.read(chunk)) != -1) { + buf.write(chunk, 0, n); + } + } catch (IOException e) { + throw new IllegalStateException("Failed to read password from stdin: " + e.getMessage(), e); + } + if (buf.size() == 0) { + return null; + } + byte[] 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); + } +} diff --git a/src/test/java/org/tron/walletcli/cli/GlobalOptionsTest.java b/src/test/java/org/tron/walletcli/cli/GlobalOptionsTest.java index 17ff1106..725c66e5 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/StandardCliRunnerTest.java b/src/test/java/org/tron/walletcli/cli/StandardCliRunnerTest.java index ec488635..952aa6df 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(); 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 00000000..a042253e --- /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)); + } +} From 0e8488ec0f48eff5408097c124424c3c28ca491d Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 11 May 2026 01:25:23 +0800 Subject: [PATCH 2/6] feat: ledger support --- docs/plan-standard-cli-ledger.md | 627 ++++ docs/qa-ledger-smoke.md | 125 + docs/spike-standard-cli-ledger.md | 266 ++ docs/standard-cli-contract-spec.md | 26 + docs/standard-cli-user-manual.md | 75 + .../plans/2026-05-07-trc20-token-alias.md | 1870 +++++++++++ .../plans/2026-05-08-unified-address-book.md | 2783 +++++++++++++++++ .../ledger/listener/LedgerEventListener.java | 15 + .../org/tron/walletcli/WalletApiWrapper.java | 42 +- .../tron/walletcli/cli/OutputFormatter.java | 11 + .../tron/walletcli/cli/StandardCliRunner.java | 2 + .../walletcli/cli/ledger/LedgerPorts.java | 79 + .../cli/ledger/LedgerSignOutcome.java | 79 + .../walletcli/cli/ledger/LedgerSigner.java | 26 + .../ledger/NonInteractiveLedgerSigner.java | 183 ++ .../cli/ledger/ProductionLedgerPorts.java | 133 + .../cli/ledger/SystemOutSuppressor.java | 41 + .../java/org/tron/walletserver/WalletApi.java | 29 + .../walletcli/cli/StandardCliRunnerTest.java | 40 + .../cli/ledger/LedgerSignOutcomeTest.java | 68 + .../NonInteractiveLedgerSignerTest.java | 285 ++ .../cli/ledger/SystemOutSuppressorTest.java | 43 + 22 files changed, 6835 insertions(+), 13 deletions(-) create mode 100644 docs/plan-standard-cli-ledger.md create mode 100644 docs/qa-ledger-smoke.md create mode 100644 docs/spike-standard-cli-ledger.md create mode 100644 docs/superpowers/plans/2026-05-07-trc20-token-alias.md create mode 100644 docs/superpowers/plans/2026-05-08-unified-address-book.md create mode 100644 src/main/java/org/tron/walletcli/cli/ledger/LedgerPorts.java create mode 100644 src/main/java/org/tron/walletcli/cli/ledger/LedgerSignOutcome.java create mode 100644 src/main/java/org/tron/walletcli/cli/ledger/LedgerSigner.java create mode 100644 src/main/java/org/tron/walletcli/cli/ledger/NonInteractiveLedgerSigner.java create mode 100644 src/main/java/org/tron/walletcli/cli/ledger/ProductionLedgerPorts.java create mode 100644 src/main/java/org/tron/walletcli/cli/ledger/SystemOutSuppressor.java create mode 100644 src/test/java/org/tron/walletcli/cli/ledger/LedgerSignOutcomeTest.java create mode 100644 src/test/java/org/tron/walletcli/cli/ledger/NonInteractiveLedgerSignerTest.java create mode 100644 src/test/java/org/tron/walletcli/cli/ledger/SystemOutSuppressorTest.java diff --git a/docs/plan-standard-cli-ledger.md b/docs/plan-standard-cli-ledger.md new file mode 100644 index 00000000..b6a23a35 --- /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 00000000..b1a01211 --- /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 00000000..f958c6ab --- /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 0455f556..bd65b7a4 100644 --- a/docs/standard-cli-contract-spec.md +++ b/docs/standard-cli-contract-spec.md @@ -594,6 +594,32 @@ Rules: - 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 diff --git a/docs/standard-cli-user-manual.md b/docs/standard-cli-user-manual.md index cdeb03b5..b770c6b0 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/docs/superpowers/plans/2026-05-07-trc20-token-alias.md b/docs/superpowers/plans/2026-05-07-trc20-token-alias.md new file mode 100644 index 00000000..8dac9d43 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-trc20-token-alias.md @@ -0,0 +1,1870 @@ +# TRC20 Token Alias Implementation Plan + +> **STATUS: SUPERSEDED by `2026-05-08-unified-address-book.md`.** Scope expanded from TRC20-only to full address book (account aliases for `--to/--from/--owner/--receiver/--address` plus token aliases for `--contract`). Do not execute this plan. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let Standard CLI users (and AI agents) pass a TRC20 symbol such as `USDT` wherever `--contract

` is currently required, while keeping the existing Base58/hex address path unchanged. + +**Architecture:** +Resolution is bare-name fallback (Foundry/ENS style): every value passed to a contract option is first parsed as Base58Check / hex; only on failure does the CLI consult a layered token table (user override → built-in resources). Resolution lives in a single `TokenResolver` service constructed once per `StandardCliRunner` invocation, injected into `ParsedOptions` so that command handlers call a new `getContractAddress(key)` accessor without each command knowing about aliases. Successful alias resolutions are recorded and surfaced both on stderr (text mode) and inside the JSON envelope's `meta.resolved` array (JSON mode) so the caller can audit what address was used. + +**Tech Stack:** Java 8, JCommander/Standard-CLI framework, Jackson (already on classpath via Trident), JUnit 4 (existing test infra under `src/test/java`). + +**Scope (intentionally narrow):** +- Only `--contract` in `ContractCommands` consults the token table. +- TRC10 / asset-name / `--token-id` / `--first-token` etc. are out of scope. +- No automatic decimals handling (`--amount 1.5 --token USDT`) — follow-up. +- Address-book for wallet-style aliases (`--to`, `--owner`, …) — out of scope. + +--- + +## File Structure + +``` +src/main/java/org/tron/walletcli/cli/tokens/ + TokenEntry.java # immutable record: symbol, address (byte[]), source, decimals + TokenStore.java # in-memory map keyed by upper-case symbol, query-only + TokenStoreLoader.java # builds layered store from resources + user file + TokenResolver.java # resolve(input) -> ResolutionResult; collects log + ResolutionResult.java # (byte[] address, String symbol|null, String source) + TokenValidation.java # symbol/address syntactic checks shared by store + cli + +src/main/resources/tokens/ + mainnet.json # built-in token list, mainnet + nile.json # built-in token list, nile testnet + shasta.json # built-in token list, shasta testnet (may be empty {}) + +src/main/java/org/tron/walletcli/cli/commands/ + TokenCommands.java # NEW: token-list / token-add / token-remove / token-resolve + +src/main/java/org/tron/walletcli/cli/ + ParsedOptions.java # MODIFY: inject resolver, add getContractAddress(key) + StandardCliRunner.java # MODIFY: build resolver from GlobalOptions.network + CommandDefinition.java # MODIFY: thread resolver into ParsedOptions + OutputFormatter.java # MODIFY: accept resolved entries, render in stderr/JSON + +src/main/java/org/tron/walletcli/cli/commands/ + ContractCommands.java # MODIFY: replace getAddress("contract") with getContractAddress("contract") + +src/main/java/org/tron/walletcli/Client.java + # MODIFY: register TokenCommands; keep REPL behavior unchanged + +src/test/java/org/tron/walletcli/cli/tokens/ + TokenStoreLoaderTest.java + TokenResolverTest.java + TokenValidationTest.java +src/test/java/org/tron/walletcli/cli/ + ParsedOptionsContractTest.java # NEW + +docs/standard-cli-contract-spec.md # MODIFY: token alias contract section +qa/commands/token_alias.sh # NEW: parity test +``` + +User token files live at `Wallet/tokens/.json` (next to existing keystore directory). One file per network. Missing files are treated as empty. + +--- + +## JSON formats + +**Built-in resource & user file** share the same schema: + +```json +{ + "tokens": [ + {"symbol": "USDT", "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "decimals": 6}, + {"symbol": "USDC", "address": "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", "decimals": 6} + ] +} +``` + +`decimals` is parsed but unused in this version; storing it now means follow-up amount conversion needs no schema change. + +**JSON output meta enrichment** (added envelope field): + +```json +{ + "success": true, + "data": { ... existing payload ... }, + "meta": { + "resolved": [ + {"option": "contract", "input": "USDT", "address": "TR7NHqj...", "symbol": "USDT", "source": "builtin"} + ] + } +} +``` + +`meta` is omitted when no aliases were resolved. + +--- + +## Task 1: Built-in token list resources + +**Files:** +- Create: `src/main/resources/tokens/mainnet.json` +- Create: `src/main/resources/tokens/nile.json` +- Create: `src/main/resources/tokens/shasta.json` + +- [ ] **Step 1: Write `mainnet.json`** + +```json +{ + "tokens": [ + {"symbol": "USDT", "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "decimals": 6}, + {"symbol": "USDC", "address": "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", "decimals": 6}, + {"symbol": "USDD", "address": "TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn", "decimals": 18}, + {"symbol": "WTRX", "address": "TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR", "decimals": 6} + ] +} +``` + +- [ ] **Step 2: Write `nile.json`** + +```json +{ + "tokens": [ + {"symbol": "USDT", "address": "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj", "decimals": 6} + ] +} +``` + +(Nile only has a sparse set of canonical TRC20s; users will add more locally.) + +- [ ] **Step 3: Write `shasta.json`** + +```json +{ "tokens": [] } +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/main/resources/tokens/ +git commit -m "feat(tokens): add built-in TRC20 token lists per network" +``` + +--- + +## Task 2: `TokenEntry` value type + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/tokens/TokenEntry.java` +- Test: `src/test/java/org/tron/walletcli/cli/tokens/TokenEntryTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package org.tron.walletcli.cli.tokens; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class TokenEntryTest { + + @Test + public void symbolIsUpperCasedAndTrimmed() { + TokenEntry e = new TokenEntry(" usdt ", new byte[21], 6, "builtin"); + assertEquals("USDT", e.getSymbol()); + } + + @Test + public void addressIsCopiedDefensively() { + byte[] addr = new byte[21]; + addr[0] = 0x41; + TokenEntry e = new TokenEntry("USDT", addr, 6, "builtin"); + addr[0] = 0x00; + assertEquals(0x41, e.getAddress()[0]); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNullSymbol() { + new TokenEntry(null, new byte[21], 6, "builtin"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsBlankSymbol() { + new TokenEntry(" ", new byte[21], 6, "builtin"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNullAddress() { + new TokenEntry("USDT", null, 6, "builtin"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsWrongAddressLength() { + new TokenEntry("USDT", new byte[20], 6, "builtin"); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.tokens.TokenEntryTest"` +Expected: FAIL — class missing. + +- [ ] **Step 3: Implement `TokenEntry`** + +```java +package org.tron.walletcli.cli.tokens; + +import java.util.Locale; + +public final class TokenEntry { + private final String symbol; + private final byte[] address; + private final int decimals; + private final String source; + + public TokenEntry(String symbol, byte[] address, int decimals, String source) { + if (symbol == null) throw new IllegalArgumentException("symbol must not be null"); + String normalized = symbol.trim().toUpperCase(Locale.ROOT); + if (normalized.isEmpty()) throw new IllegalArgumentException("symbol must not be blank"); + if (address == null) throw new IllegalArgumentException("address must not be null"); + if (address.length != 21) { + throw new IllegalArgumentException( + "address must be 21 bytes (raw TRON address), got " + address.length); + } + if (source == null || source.trim().isEmpty()) { + throw new IllegalArgumentException("source must not be blank"); + } + this.symbol = normalized; + this.address = address.clone(); + this.decimals = decimals; + this.source = source; + } + + public String getSymbol() { return symbol; } + public byte[] getAddress() { return address.clone(); } + public int getDecimals() { return decimals; } + public String getSource() { return source; } +} +``` + +- [ ] **Step 4: Run test to verify pass** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.tokens.TokenEntryTest"` +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/tokens/TokenEntry.java \ + src/test/java/org/tron/walletcli/cli/tokens/TokenEntryTest.java +git commit -m "feat(tokens): add TokenEntry value type" +``` + +--- + +## Task 3: `TokenValidation` syntactic guards + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/tokens/TokenValidation.java` +- Test: `src/test/java/org/tron/walletcli/cli/tokens/TokenValidationTest.java` + +Symbol rules (rejected as alias name): +- Decodable as Base58Check TRON address (would shadow real address parsing). +- Reasonable hex address shape: matches `^(0x|41)[0-9a-fA-F]{40,42}$`. +- Reserved words (case-insensitive): `me`, `self`, `mainnet`, `nile`, `shasta`, `trx`. +- Anything not matching `^[A-Za-z][A-Za-z0-9_.-]{0,31}$`. + +- [ ] **Step 1: Write the failing test** + +```java +package org.tron.walletcli.cli.tokens; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class TokenValidationTest { + + @Test public void acceptsTypicalSymbol() { + TokenValidation.requireValidSymbol("USDT"); + TokenValidation.requireValidSymbol("usd-coin"); + TokenValidation.requireValidSymbol("Pkg.v2"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsBase58Address() { + TokenValidation.requireValidSymbol("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsHexAddress() { + TokenValidation.requireValidSymbol("41a614f803b6fd780986a42c78ec9c7f77e6ded13c"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejects0xHexAddress() { + TokenValidation.requireValidSymbol("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsReservedWord() { + TokenValidation.requireValidSymbol("me"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsLeadingDigit() { + TokenValidation.requireValidSymbol("1inch"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsTooLong() { + StringBuilder sb = new StringBuilder("A"); + for (int i = 0; i < 32; i++) sb.append('a'); + TokenValidation.requireValidSymbol(sb.toString()); + } + + @Test + public void looksLikeTronAddressDetectsBase58() { + assertTrue(TokenValidation.looksLikeAddress("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")); + } + + @Test + public void looksLikeTronAddressDetectsHex() { + assertTrue(TokenValidation.looksLikeAddress( + "41a614f803b6fd780986a42c78ec9c7f77e6ded13c")); + } + + @Test + public void looksLikeTronAddressRejectsSymbol() { + assertFalse(TokenValidation.looksLikeAddress("USDT")); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.tokens.TokenValidationTest"` +Expected: FAIL — class missing. + +- [ ] **Step 3: Implement `TokenValidation`** + +```java +package org.tron.walletcli.cli.tokens; + +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 TokenValidation { + private TokenValidation() {} + + private static final Pattern SYMBOL = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]{0,31}$"); + private static final Pattern HEX_ADDRESS = Pattern.compile("^(0x|41)[0-9a-fA-F]{40,42}$"); + private static final Set RESERVED = new HashSet(Arrays.asList( + "me", "self", "mainnet", "nile", "shasta", "trx")); + + public static boolean looksLikeAddress(String input) { + if (input == null) return false; + String trimmed = input.trim(); + if (trimmed.isEmpty()) return false; + if (HEX_ADDRESS.matcher(trimmed).matches()) return true; + return WalletApi.decodeFromBase58Check(trimmed) != null; + } + + public static void requireValidSymbol(String symbol) { + if (symbol == null) { + throw new IllegalArgumentException("symbol must not be null"); + } + String trimmed = symbol.trim(); + if (!SYMBOL.matcher(trimmed).matches()) { + throw new IllegalArgumentException( + "invalid token symbol: " + symbol + + " (must match ^[A-Za-z][A-Za-z0-9_.-]{0,31}$)"); + } + if (RESERVED.contains(trimmed.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("symbol is reserved: " + trimmed); + } + if (looksLikeAddress(trimmed)) { + throw new IllegalArgumentException( + "symbol must not look like a TRON address: " + trimmed); + } + } +} +``` + +- [ ] **Step 4: Run test to verify pass** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.tokens.TokenValidationTest"` +Expected: PASS, 10 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/tokens/TokenValidation.java \ + src/test/java/org/tron/walletcli/cli/tokens/TokenValidationTest.java +git commit -m "feat(tokens): add TokenValidation guards" +``` + +--- + +## Task 4: `TokenStore` in-memory lookup + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/tokens/TokenStore.java` +- Test: `src/test/java/org/tron/walletcli/cli/tokens/TokenStoreTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package org.tron.walletcli.cli.tokens; + +import java.util.Arrays; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class TokenStoreTest { + + private byte[] addr(int marker) { + byte[] a = new byte[21]; + a[0] = 0x41; + a[20] = (byte) marker; + return a; + } + + @Test public void emptyStoreLooksUpToNull() { + TokenStore store = TokenStore.of(java.util.Collections.emptyList()); + assertNull(store.find("USDT")); + } + + @Test public void caseInsensitiveLookup() { + TokenStore store = TokenStore.of(Arrays.asList( + new TokenEntry("USDT", addr(1), 6, "builtin"))); + assertNotNull(store.find("usdt")); + assertNotNull(store.find("Usdt")); + } + + @Test public void userEntryShadowsBuiltin() { + TokenEntry builtin = new TokenEntry("USDT", addr(1), 6, "builtin"); + TokenEntry user = new TokenEntry("USDT", addr(2), 6, "user"); + TokenStore store = TokenStore.layered( + TokenStore.of(Arrays.asList(builtin)), + TokenStore.of(Arrays.asList(user))); + assertEquals("user", store.find("USDT").getSource()); + assertEquals(2, store.find("USDT").getAddress()[20] & 0xFF); + } + + @Test public void listAllReturnsBothLayersWithUserFirst() { + TokenEntry builtin = new TokenEntry("USDC", addr(1), 6, "builtin"); + TokenEntry user = new TokenEntry("USDT", addr(2), 6, "user"); + TokenStore store = TokenStore.layered( + TokenStore.of(Arrays.asList(builtin)), + TokenStore.of(Arrays.asList(user))); + List all = store.listAll(); + assertEquals(2, all.size()); + assertEquals("USDT", all.get(0).getSymbol()); + assertEquals("USDC", all.get(1).getSymbol()); + } + + @Test public void layeredFindFallsThroughToBuiltin() { + TokenEntry builtin = new TokenEntry("USDC", addr(1), 6, "builtin"); + TokenStore store = TokenStore.layered( + TokenStore.of(Arrays.asList(builtin)), + TokenStore.of(java.util.Collections.emptyList())); + assertEquals("builtin", store.find("USDC").getSource()); + } +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.tokens.TokenStoreTest"` +Expected: FAIL — class missing. + +- [ ] **Step 3: Implement `TokenStore`** + +```java +package org.tron.walletcli.cli.tokens; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class TokenStore { + + private final Map entries; // upper-case symbol -> entry + + private TokenStore(Map entries) { + this.entries = entries; + } + + public static TokenStore of(List source) { + Map map = new LinkedHashMap(); + for (TokenEntry e : source) { + map.put(e.getSymbol(), e); + } + return new TokenStore(map); + } + + /** Layered lookup: user overrides built-in. listAll returns user entries first. */ + public static TokenStore layered(TokenStore builtin, TokenStore user) { + Map map = new LinkedHashMap(user.entries); + for (Map.Entry e : builtin.entries.entrySet()) { + if (!map.containsKey(e.getKey())) { + map.put(e.getKey(), e.getValue()); + } + } + return new TokenStore(map); + } + + public TokenEntry find(String symbol) { + if (symbol == null) return null; + return entries.get(symbol.trim().toUpperCase(Locale.ROOT)); + } + + public List listAll() { + return Collections.unmodifiableList(new ArrayList(entries.values())); + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.tokens.TokenStoreTest"` +Expected: PASS, 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/tokens/TokenStore.java \ + src/test/java/org/tron/walletcli/cli/tokens/TokenStoreTest.java +git commit -m "feat(tokens): add TokenStore with layered lookup" +``` + +--- + +## Task 5: `TokenStoreLoader` — load resources + user file + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/tokens/TokenStoreLoader.java` +- Test: `src/test/java/org/tron/walletcli/cli/tokens/TokenStoreLoaderTest.java` + +`TokenStoreLoader` exposes: + +```java +public static TokenStore loadBuiltin(String network); +public static TokenStore loadUserFile(File file); // missing -> empty +public static void writeUserFile(File file, List entries); // pretty JSON, atomic via tmp+rename +public static TokenStore loadLayered(String network, File userFile); +``` + +JSON parsing uses Jackson (already a transitive dependency through Trident/protobuf; verify with `./gradlew dependencies | grep jackson`). If Jackson is not present, fall back to a minimal hand-rolled parser — but Jackson is preferred. The TokenStoreLoader integrates `TokenValidation.requireValidSymbol` on load: malformed entries are skipped with a warning to `stderr` (silenced when `quiet`), and the loader keeps loading the rest. + +- [ ] **Step 1: Verify Jackson availability** + +Run: `./gradlew dependencies --configuration runtimeClasspath | grep -i jackson | head -3` +Expected: lines containing `jackson-databind`. If empty, add to `build.gradle`: + +```groovy +dependencies { + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.4' +} +``` + +Then re-run `./gradlew build`. + +- [ ] **Step 2: Write the failing test** + +```java +package org.tron.walletcli.cli.tokens; + +import java.io.File; +import java.io.PrintWriter; +import java.util.Arrays; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +public class TokenStoreLoaderTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + @Test public void builtinMainnetContainsUSDT() { + TokenStore store = TokenStoreLoader.loadBuiltin("mainnet"); + TokenEntry usdt = store.find("USDT"); + assertNotNull(usdt); + assertEquals("builtin", usdt.getSource()); + assertEquals(6, usdt.getDecimals()); + } + + @Test public void unknownNetworkReturnsEmpty() { + TokenStore store = TokenStoreLoader.loadBuiltin("does-not-exist"); + assertTrue(store.listAll().isEmpty()); + } + + @Test public void userFileMissingReturnsEmpty() { + File missing = new File(tmp.getRoot(), "missing.json"); + TokenStore store = TokenStoreLoader.loadUserFile(missing); + assertTrue(store.listAll().isEmpty()); + } + + @Test public void userFileLoadsAndIsTaggedUser() throws Exception { + File f = tmp.newFile("user.json"); + try (PrintWriter w = new PrintWriter(f)) { + w.println("{ \"tokens\": [ {\"symbol\":\"FOO\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\",\"decimals\":4} ] }"); + } + TokenStore store = TokenStoreLoader.loadUserFile(f); + assertEquals("user", store.find("FOO").getSource()); + assertEquals(4, store.find("FOO").getDecimals()); + } + + @Test public void writeThenReadRoundTrips() throws Exception { + byte[] addr = org.tron.walletserver.WalletApi.decodeFromBase58Check( + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + TokenEntry e = new TokenEntry("BAR", addr, 8, "user"); + File f = new File(tmp.getRoot(), "out.json"); + TokenStoreLoader.writeUserFile(f, Arrays.asList(e)); + TokenStore loaded = TokenStoreLoader.loadUserFile(f); + assertEquals(8, loaded.find("BAR").getDecimals()); + } + + @Test public void malformedEntriesAreSkipped() throws Exception { + File f = tmp.newFile("bad.json"); + try (PrintWriter w = new PrintWriter(f)) { + w.println("{ \"tokens\": [" + + "{\"symbol\":\"OK\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\",\"decimals\":6}," + + "{\"symbol\":\"1bad\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\"}," + + "{\"symbol\":\"NoAddr\"}" + + "]}"); + } + TokenStore store = TokenStoreLoader.loadUserFile(f); + assertNotNull(store.find("OK")); + assertNull(store.find("1bad")); + assertNull(store.find("NoAddr")); + } +} +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.tokens.TokenStoreLoaderTest"` +Expected: FAIL. + +- [ ] **Step 4: Implement `TokenStoreLoader`** + +```java +package org.tron.walletcli.cli.tokens; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import org.tron.walletserver.WalletApi; + +public final class TokenStoreLoader { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT); + + private TokenStoreLoader() {} + + public static TokenStore loadBuiltin(String network) { + if (network == null) return TokenStore.of(Collections.emptyList()); + String resource = "/tokens/" + network.toLowerCase(Locale.ROOT) + ".json"; + try (InputStream in = TokenStoreLoader.class.getResourceAsStream(resource)) { + if (in == null) return TokenStore.of(Collections.emptyList()); + return TokenStore.of(parseEntries(MAPPER.readTree(in), "builtin")); + } catch (IOException e) { + System.err.println("warn: failed to load builtin token list " + resource + ": " + e.getMessage()); + return TokenStore.of(Collections.emptyList()); + } + } + + public static TokenStore loadUserFile(File file) { + if (file == null || !file.isFile()) { + return TokenStore.of(Collections.emptyList()); + } + try { + return TokenStore.of(parseEntries(MAPPER.readTree(file), "user")); + } catch (IOException e) { + System.err.println("warn: failed to read user token file " + file + ": " + e.getMessage()); + return TokenStore.of(Collections.emptyList()); + } + } + + public static TokenStore loadLayered(String network, File userFile) { + return TokenStore.layered(loadBuiltin(network), loadUserFile(userFile)); + } + + public static void writeUserFile(File file, List entries) { + ObjectNode root = MAPPER.createObjectNode(); + ArrayNode arr = root.putArray("tokens"); + for (TokenEntry e : entries) { + ObjectNode n = arr.addObject(); + n.put("symbol", e.getSymbol()); + n.put("address", WalletApi.encode58Check(e.getAddress())); + n.put("decimals", e.getDecimals()); + } + File parent = file.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IllegalStateException("cannot create directory: " + parent); + } + File tmp = new File(file.getAbsolutePath() + ".tmp"); + try { + MAPPER.writerWithDefaultPrettyPrinter().writeValue(tmp, root); + Files.move(tmp.toPath(), file.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + throw new IllegalStateException("failed to write " + file + ": " + e.getMessage(), e); + } + } + + private static List parseEntries(JsonNode root, String source) { + List out = new ArrayList(); + if (root == null || !root.has("tokens") || !root.get("tokens").isArray()) { + return out; + } + for (JsonNode node : root.get("tokens")) { + String symbol = node.path("symbol").asText(null); + String address = node.path("address").asText(null); + int decimals = node.path("decimals").asInt(0); + if (symbol == null || address == null) { + System.err.println("warn: skipping token entry missing symbol/address"); + continue; + } + try { + TokenValidation.requireValidSymbol(symbol); + } catch (IllegalArgumentException e) { + System.err.println("warn: skipping token entry: " + e.getMessage()); + continue; + } + byte[] addr = WalletApi.decodeFromBase58Check(address); + if (addr == null) { + System.err.println("warn: skipping token " + symbol + " — invalid address: " + address); + continue; + } + out.add(new TokenEntry(symbol, addr, decimals, source)); + } + return out; + } +} +``` + +- [ ] **Step 5: Run tests** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.tokens.TokenStoreLoaderTest"` +Expected: PASS, 6 tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/tokens/TokenStoreLoader.java \ + src/test/java/org/tron/walletcli/cli/tokens/TokenStoreLoaderTest.java \ + build.gradle +git commit -m "feat(tokens): add TokenStoreLoader for builtin + user JSON" +``` + +--- + +## Task 6: `ResolutionResult` + `TokenResolver` + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/tokens/ResolutionResult.java` +- Create: `src/main/java/org/tron/walletcli/cli/tokens/TokenResolver.java` +- Test: `src/test/java/org/tron/walletcli/cli/tokens/TokenResolverTest.java` + +Resolver responsibilities: +- Try Base58Check → return result with `source = "address"`, `symbol = null`. +- Try hex (`0x...` or `41...`, length 42 incl. prefix) → result with `source = "hex"`. +- Else look up symbol in `TokenStore` → result with `source = "user" | "builtin"`, `symbol = `. +- Else throw `TokenResolutionException` with a clear message. + +- [ ] **Step 1: Write the failing test** + +```java +package org.tron.walletcli.cli.tokens; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class TokenResolverTest { + + private final byte[] addr = org.tron.walletserver.WalletApi.decodeFromBase58Check( + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + + private TokenResolver buildResolver() { + TokenEntry e = new TokenEntry("USDT", addr, 6, "builtin"); + return new TokenResolver(TokenStore.of(java.util.Arrays.asList(e))); + } + + @Test public void base58Passthrough() { + ResolutionResult r = buildResolver() + .resolve("contract", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + assertNull(r.getSymbol()); + assertEquals("address", r.getSource()); + assertArrayEquals(addr, r.getAddress()); + } + + @Test public void hexPassthrough() { + String hex = "41" + bytesToHex(addr).substring(2); + ResolutionResult r = buildResolver().resolve("contract", hex); + assertEquals("hex", r.getSource()); + assertArrayEquals(addr, r.getAddress()); + } + + @Test public void zeroXHexPassthrough() { + String hex = "0x" + bytesToHex(addr).substring(2); + ResolutionResult r = buildResolver().resolve("contract", hex); + assertEquals("hex", r.getSource()); + assertArrayEquals(addr, r.getAddress()); + } + + @Test public void symbolResolves() { + ResolutionResult r = buildResolver().resolve("contract", "usdt"); + assertEquals("USDT", r.getSymbol()); + assertEquals("builtin", r.getSource()); + assertEquals("contract", r.getOption()); + assertEquals("usdt", r.getInput()); + } + + @Test(expected = TokenResolutionException.class) + public void unknownInputThrows() { + buildResolver().resolve("contract", "DOES_NOT_EXIST"); + } + + private static String bytesToHex(byte[] in) { + StringBuilder sb = new StringBuilder(); + for (byte b : in) sb.append(String.format("%02x", b)); + return sb.toString(); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.tokens.TokenResolverTest"` +Expected: FAIL — classes missing. + +- [ ] **Step 3: Implement `ResolutionResult`** + +```java +package org.tron.walletcli.cli.tokens; + +public final class ResolutionResult { + private final String option; + private final String input; + private final byte[] address; + private final String symbol; // null when input was a literal address + private final String source; // "address" | "hex" | "user" | "builtin" + + public ResolutionResult(String option, String input, byte[] address, + String symbol, String source) { + this.option = option; + this.input = input; + this.address = address.clone(); + this.symbol = symbol; + this.source = source; + } + + public String getOption() { return option; } + public String getInput() { return input; } + public byte[] getAddress() { return address.clone(); } + public String getSymbol() { return symbol; } + public String getSource() { return source; } + + public boolean isAlias() { + return "user".equals(source) || "builtin".equals(source); + } +} +``` + +- [ ] **Step 4: Implement `TokenResolutionException`** + +Inline as a static nested class on `TokenResolver`, or its own file. Keep it as its own file for clarity: + +`src/main/java/org/tron/walletcli/cli/tokens/TokenResolutionException.java` + +```java +package org.tron.walletcli.cli.tokens; + +public class TokenResolutionException extends IllegalArgumentException { + public TokenResolutionException(String message) { super(message); } +} +``` + +- [ ] **Step 5: Implement `TokenResolver`** + +```java +package org.tron.walletcli.cli.tokens; + +import java.util.regex.Pattern; +import org.tron.walletserver.WalletApi; + +public class TokenResolver { + + private static final Pattern HEX = Pattern.compile("^(0x|41)([0-9a-fA-F]{40})$"); + + private final TokenStore store; + + public TokenResolver(TokenStore store) { + this.store = store; + } + + public ResolutionResult resolve(String option, String input) { + if (input == null || input.trim().isEmpty()) { + throw new TokenResolutionException("--" + option + " is empty"); + } + String raw = input.trim(); + + byte[] base58 = WalletApi.decodeFromBase58Check(raw); + if (base58 != null) { + return new ResolutionResult(option, raw, base58, null, "address"); + } + + java.util.regex.Matcher m = HEX.matcher(raw); + if (m.matches()) { + byte[] hexBytes = decodeHex(m.group(2)); + byte[] full = new byte[21]; + full[0] = 0x41; + System.arraycopy(hexBytes, 0, full, 1, 20); + return new ResolutionResult(option, raw, full, null, "hex"); + } + + TokenEntry entry = store.find(raw); + if (entry != null) { + return new ResolutionResult(option, raw, entry.getAddress(), + entry.getSymbol(), entry.getSource()); + } + + throw new TokenResolutionException( + "--" + option + " value \"" + raw + + "\" is neither a valid TRON address nor a known token symbol"); + } + + private static byte[] decodeHex(String hex) { + byte[] out = new byte[hex.length() / 2]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return out; + } +} +``` + +- [ ] **Step 6: Run tests** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.tokens.TokenResolverTest"` +Expected: PASS, 5 tests. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/tokens/ResolutionResult.java \ + src/main/java/org/tron/walletcli/cli/tokens/TokenResolver.java \ + src/main/java/org/tron/walletcli/cli/tokens/TokenResolutionException.java \ + src/test/java/org/tron/walletcli/cli/tokens/TokenResolverTest.java +git commit -m "feat(tokens): add TokenResolver with address-first fallback" +``` + +--- + +## Task 7: Inject resolver into `ParsedOptions` + add `getContractAddress` + +**Files:** +- Modify: `src/main/java/org/tron/walletcli/cli/ParsedOptions.java` +- Modify: `src/main/java/org/tron/walletcli/cli/CommandDefinition.java` (only the path that builds `ParsedOptions` — see below) +- Test: `src/test/java/org/tron/walletcli/cli/ParsedOptionsContractTest.java` + +`ParsedOptions` becomes constructible with an optional `TokenResolver` and a `List` accumulator. Calls to `getAddress(key)` keep their existing semantics. New method `getContractAddress(key)` uses the resolver and records hits. + +- [ ] **Step 1: Inspect call sites of `new ParsedOptions(...)` to plan the constructor change** + +Run: `grep -rn "new ParsedOptions(" src/main/java src/test/java` +Note every site — they all must keep compiling. + +- [ ] **Step 2: Write the failing test** + +```java +package org.tron.walletcli.cli; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; +import org.tron.walletcli.cli.tokens.ResolutionResult; +import org.tron.walletcli.cli.tokens.TokenEntry; +import org.tron.walletcli.cli.tokens.TokenResolver; +import org.tron.walletcli.cli.tokens.TokenStore; +import static org.junit.Assert.*; + +public class ParsedOptionsContractTest { + + private final byte[] addr = org.tron.walletserver.WalletApi.decodeFromBase58Check( + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + + private TokenResolver buildResolver() { + TokenEntry e = new TokenEntry("USDT", addr, 6, "builtin"); + return new TokenResolver(TokenStore.of(java.util.Arrays.asList(e))); + } + + @Test public void getContractAddressResolvesSymbol() { + Map values = new LinkedHashMap(); + values.put("contract", "USDT"); + ParsedOptions opts = new ParsedOptions(values, buildResolver()); + assertArrayEquals(addr, opts.getContractAddress("contract")); + assertEquals(1, opts.getResolutionLog().size()); + ResolutionResult r = opts.getResolutionLog().get(0); + assertEquals("USDT", r.getSymbol()); + } + + @Test public void getContractAddressAcceptsBase58() { + Map values = new LinkedHashMap(); + values.put("contract", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + ParsedOptions opts = new ParsedOptions(values, buildResolver()); + assertArrayEquals(addr, opts.getContractAddress("contract")); + assertTrue(opts.getResolutionLog().isEmpty()); + } + + @Test(expected = IllegalArgumentException.class) + public void getContractAddressMissingKeyThrows() { + ParsedOptions opts = new ParsedOptions(new LinkedHashMap(), buildResolver()); + opts.getContractAddress("contract"); + } + + @Test(expected = IllegalArgumentException.class) + public void getContractAddressUnknownSymbolThrows() { + Map values = new LinkedHashMap(); + values.put("contract", "WHO"); + ParsedOptions opts = new ParsedOptions(values, buildResolver()); + opts.getContractAddress("contract"); + } + + @Test public void legacyConstructorStillCompilesAndWorks() { + Map values = new LinkedHashMap(); + values.put("contract", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + ParsedOptions opts = new ParsedOptions(values); + assertArrayEquals(addr, opts.getAddress("contract")); + } +} +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.ParsedOptionsContractTest"` +Expected: FAIL. + +- [ ] **Step 4: Modify `ParsedOptions`** + +Add fields, second constructor, accessor, and resolution log. Insert immediately after the existing `getAddress` method: + +```java +// at top of class +private final TokenResolver resolver; +private final List resolutionLog; +``` + +Replace the existing single-arg constructor with two constructors: + +```java +public ParsedOptions(Map values) { + this(values, null); +} + +public ParsedOptions(Map values, TokenResolver resolver) { + this.values = values == null + ? Collections.emptyMap() + : new LinkedHashMap(values); + this.resolver = resolver; + this.resolutionLog = new ArrayList(); +} +``` + +Add the new accessor and getter: + +```java +public byte[] getContractAddress(String key) { + String raw = values.get(key); + if (raw == null) { + throw new IllegalArgumentException("Missing required option: --" + key); + } + if (resolver == null) { + return getAddress(key); + } + ResolutionResult r = resolver.resolve(key, raw); + if (r.isAlias()) { + resolutionLog.add(r); + } + return r.getAddress(); +} + +public List getResolutionLog() { + return Collections.unmodifiableList(resolutionLog); +} +``` + +Imports to add at top of the file: + +```java +import java.util.ArrayList; +import java.util.List; +import org.tron.walletcli.cli.tokens.ResolutionResult; +import org.tron.walletcli.cli.tokens.TokenResolver; +``` + +- [ ] **Step 5: Run tests** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.ParsedOptionsContractTest"` +Expected: PASS, 5 tests. + +Run: `./gradlew test` +Expected: full suite still passes. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/ParsedOptions.java \ + src/test/java/org/tron/walletcli/cli/ParsedOptionsContractTest.java +git commit -m "feat(cli): ParsedOptions.getContractAddress with token resolver" +``` + +--- + +## Task 8: Build resolver in `StandardCliRunner` + thread it through `CommandDefinition.parseArgs` + +**Files:** +- Modify: `src/main/java/org/tron/walletcli/cli/StandardCliRunner.java` +- Modify: `src/main/java/org/tron/walletcli/cli/CommandDefinition.java` +- Modify: `src/main/java/org/tron/walletcli/cli/CommandContext.java` + +The runner already knows `globalOpts.getNetwork()`. Build the resolver once per command and pass via a context. The cleanest minimal change is: + +1. Add a `TokenResolver getTokenResolver()` accessor to `CommandContext`. +2. `StandardCliRunner` constructs `TokenResolver` from `loadLayered(network, userTokenFile(network))`. +3. `CommandDefinition.parseArgs` returns `ParsedOptions` built with the resolver from the context. + +User token file location: `Wallet/tokens/.json` resolved relative to `WalletApi.FilePath` (which is `Wallet`). + +- [ ] **Step 1: Inspect existing `CommandDefinition.parseArgs`** + +Run: `grep -n "parseArgs\|new ParsedOptions" src/main/java/org/tron/walletcli/cli/CommandDefinition.java` +Note the signature so the threading is minimal (likely it builds `ParsedOptions(values)` directly). + +- [ ] **Step 2: Modify `CommandContext` — add resolver field** + +Add field, constructor parameter, and getter (mirroring the pattern used for `masterPasswordProvider`): + +```java +private final TokenResolver tokenResolver; + +public CommandContext(String walletOverride, File resolvedAuthWalletFile, + StandardCliRunner.MasterPasswordProvider masterPasswordProvider, + TokenResolver tokenResolver) { + this.walletOverride = walletOverride; + this.resolvedAuthWalletFile = resolvedAuthWalletFile; + this.masterPasswordProvider = masterPasswordProvider; + this.tokenResolver = tokenResolver; +} +``` + +Update existing constructors to delegate (passing `null` for the resolver), and add: + +```java +public TokenResolver getTokenResolver() { return tokenResolver; } + +public CommandContext withTokenResolver(TokenResolver resolver) { + return new CommandContext(walletOverride, resolvedAuthWalletFile, + masterPasswordProvider, resolver); +} +``` + +Also extend `withResolvedAuthWalletFile` to pass `tokenResolver` through. + +Add import: `import org.tron.walletcli.cli.tokens.TokenResolver;` + +- [ ] **Step 3: Modify `CommandDefinition.parseArgs` to thread resolver** + +Update the method signature to accept `CommandContext` (or whatever it currently takes — confirm in step 1) and propagate. If the current signature is `parseArgs(String[] args)`, add an overload `parseArgs(String[] args, TokenResolver resolver)` that constructs `new ParsedOptions(values, resolver)` and have the existing one delegate with `null`. + +```java +public ParsedOptions parseArgs(String[] args) { + return parseArgs(args, null); +} + +public ParsedOptions parseArgs(String[] args, TokenResolver resolver) { + Map values = doParse(args); // existing logic, refactored if needed + return new ParsedOptions(values, resolver); +} +``` + +Add import: `import org.tron.walletcli.cli.tokens.TokenResolver;` + +- [ ] **Step 4: Modify `StandardCliRunner` to build resolver and pass it** + +Near the top of `run`/`execute` (where the command is dispatched), build the resolver once: + +```java +String network = globalOpts.getNetwork(); // may be null -> default +File userTokenFile = new File("Wallet/tokens/" + + (network == null ? "default" : network.toLowerCase(java.util.Locale.ROOT)) + + ".json"); +TokenResolver tokenResolver = new TokenResolver( + TokenStoreLoader.loadLayered( + network == null ? "mainnet" : network, userTokenFile)); +CommandContext ctx = CommandContext.fromGlobalOptions(globalOpts, masterPasswordProvider) + .withTokenResolver(tokenResolver); +``` + +Then pass `tokenResolver` into the existing `cmd.parseArgs(...)` call: + +```java +ParsedOptions opts = cmd.parseArgs(commandArgs, tokenResolver); +``` + +Add imports: + +```java +import org.tron.walletcli.cli.tokens.TokenResolver; +import org.tron.walletcli.cli.tokens.TokenStoreLoader; +``` + +(If `globalOpts.getNetwork()` defaults to `null` for "mainnet", reuse whatever helper `ApiClientFactory` uses to canonicalize the name — match its lower-case form.) + +- [ ] **Step 5: Build everything** + +Run: `./gradlew build -x test` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 6: Run full test suite** + +Run: `./gradlew test` +Expected: all tests pass (existing + new). Fix any compile breaks introduced by the constructor signature change in `CommandContext`. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/CommandContext.java \ + src/main/java/org/tron/walletcli/cli/CommandDefinition.java \ + src/main/java/org/tron/walletcli/cli/StandardCliRunner.java +git commit -m "feat(cli): wire TokenResolver through StandardCliRunner" +``` + +--- + +## Task 9: Switch `ContractCommands` to `getContractAddress` + +**Files:** +- Modify: `src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java` + +Twelve call sites identified by `grep -nE 'getAddress\("contract"\)' ContractCommands.java`. Each must change to `getContractAddress("contract")`. + +- [ ] **Step 1: List the lines** + +Run: `grep -n 'getAddress("contract")' src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java` +Expected: lines 150, 213, 271, 323, 346, 374 (verify before editing). + +- [ ] **Step 2: Replace each occurrence** + +Use `sed` carefully or a manual edit per line. Search-and-replace every `getAddress("contract")` → `getContractAddress("contract")` in this file only. Do **not** touch other commands' `--owner` / `--to` (out of scope). + +```bash +sed -i.bak 's/getAddress("contract")/getContractAddress("contract")/g' \ + src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java +rm src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java.bak +``` + +- [ ] **Step 3: Verify** + +Run: `grep -c 'getContractAddress("contract")' src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java` +Expected: matches the count from Step 1. + +Run: `grep -c 'getAddress("contract")' src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java` +Expected: 0. + +- [ ] **Step 4: Build + test** + +Run: `./gradlew build` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/commands/ContractCommands.java +git commit -m "feat(contracts): accept TRC20 symbol via --contract" +``` + +--- + +## Task 10: Surface resolution log in `OutputFormatter` + +**Files:** +- Modify: `src/main/java/org/tron/walletcli/cli/OutputFormatter.java` +- Modify: `src/main/java/org/tron/walletcli/cli/StandardCliRunner.java` (push log after parseArgs) +- Test: extend `src/test/java/org/tron/walletcli/cli/OutputFormatterTest.java` if it exists; otherwise add `OutputFormatterResolvedTest.java`. + +Behaviour: +- `OutputFormatter.recordResolved(List)` accumulates entries. +- In **text** mode (and not quiet): emit one line per alias resolution to stderr **before** the success message — `Resolved --contract "USDT" → TR7NHqj... (source=builtin)`. +- In **JSON** mode: include `meta.resolved` array in the success envelope. Omit `meta` when empty. +- In **quiet** text mode: suppress the stderr line (still record into JSON if applicable; quiet only suppresses stderr noise, not JSON metadata). + +- [ ] **Step 1: Read current `OutputFormatter.java`** + +Run: `wc -l src/main/java/org/tron/walletcli/cli/OutputFormatter.java` +Run: `grep -n "envelope\|emitJson\|err\.println\|recordSuccess" src/main/java/org/tron/walletcli/cli/OutputFormatter.java` +Note where the success envelope is built and where text-mode messages are emitted. + +- [ ] **Step 2: Add field + recorder** + +Insert near the other private fields: + +```java +private final java.util.List resolved = + new java.util.ArrayList(); + +public void recordResolved(java.util.List entries) { + if (entries == null) return; + for (org.tron.walletcli.cli.tokens.ResolutionResult r : entries) { + if (r.isAlias()) resolved.add(r); + } +} +``` + +- [ ] **Step 3: Emit stderr lines for text mode** + +In the text-mode emission path (`if (current.success) { ... }` branch where `out.println` is called), before the success line is printed, add: + +```java +if (mode == OutputMode.TEXT && !quiet) { + for (org.tron.walletcli.cli.tokens.ResolutionResult r : resolved) { + err.println("Resolved --" + r.getOption() + " \"" + r.getInput() + + "\" → " + org.tron.walletserver.WalletApi.encode58Check(r.getAddress()) + + " (source=" + r.getSource() + + (r.getSymbol() == null ? "" : ", symbol=" + r.getSymbol()) + ")"); + } +} +``` + +(Adjust field names — `mode`, `quiet`, `err` — to whatever the file uses; from existing grep we know `err.println("Error: …")` already exists.) + +- [ ] **Step 4: Add `meta.resolved` to JSON envelope** + +In `emitJsonSuccess` (or wherever the envelope map is finalised before serialisation), after the `data` field is set: + +```java +if (!resolved.isEmpty()) { + java.util.List> arr = + new java.util.ArrayList>(); + for (org.tron.walletcli.cli.tokens.ResolutionResult r : resolved) { + java.util.Map m = new java.util.LinkedHashMap(); + m.put("option", r.getOption()); + m.put("input", r.getInput()); + m.put("address", org.tron.walletserver.WalletApi.encode58Check(r.getAddress())); + if (r.getSymbol() != null) m.put("symbol", r.getSymbol()); + m.put("source", r.getSource()); + arr.add(m); + } + java.util.Map meta = new java.util.LinkedHashMap(); + meta.put("resolved", arr); + envelope.put("meta", meta); +} +``` + +- [ ] **Step 5: Wire from `StandardCliRunner`** + +After parsing options and **before** invoking the handler, push the log into the formatter so that even if the handler throws, partial resolution is reported: + +```java +ParsedOptions opts = cmd.parseArgs(commandArgs, tokenResolver); +formatter.recordResolved(opts.getResolutionLog()); +``` + +- [ ] **Step 6: Add a focused test** + +```java +package org.tron.walletcli.cli; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.Collections; +import org.junit.Test; +import org.tron.walletcli.cli.tokens.ResolutionResult; +import static org.junit.Assert.*; + +public class OutputFormatterResolvedTest { + + private final byte[] addr = org.tron.walletserver.WalletApi.decodeFromBase58Check( + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + + @Test public void textModeEmitsResolvedLine() { + ByteArrayOutputStream errBytes = new ByteArrayOutputStream(); + OutputFormatter f = new OutputFormatter( + OutputFormatter.OutputMode.TEXT, + new PrintStream(new ByteArrayOutputStream()), + new PrintStream(errBytes), + false /* quiet */); + f.recordResolved(Collections.singletonList( + new ResolutionResult("contract", "USDT", addr, "USDT", "builtin"))); + f.success("ok", null); + f.flush(); + String stderr = errBytes.toString(); + assertTrue(stderr, stderr.contains("Resolved --contract \"USDT\"")); + assertTrue(stderr, stderr.contains("source=builtin")); + } + + @Test public void quietModeSuppressesStderrLine() { + ByteArrayOutputStream errBytes = new ByteArrayOutputStream(); + OutputFormatter f = new OutputFormatter( + OutputFormatter.OutputMode.TEXT, + new PrintStream(new ByteArrayOutputStream()), + new PrintStream(errBytes), + true /* quiet */); + f.recordResolved(Collections.singletonList( + new ResolutionResult("contract", "USDT", addr, "USDT", "builtin"))); + f.success("ok", null); + f.flush(); + assertFalse(errBytes.toString().contains("Resolved")); + } +} +``` + +> Adapt constructor arguments to match the actual `OutputFormatter` signature once read in Step 1. If `OutputFormatter` has no public `flush()` method, call whatever finaliser exists (e.g. via the existing test helper). + +- [ ] **Step 7: Run tests** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.OutputFormatterResolvedTest"` +Expected: PASS, 2 tests. + +Run: `./gradlew test` +Expected: full suite still passes. + +- [ ] **Step 8: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/OutputFormatter.java \ + src/main/java/org/tron/walletcli/cli/StandardCliRunner.java \ + src/test/java/org/tron/walletcli/cli/OutputFormatterResolvedTest.java +git commit -m "feat(cli): surface token alias resolution in stderr + JSON meta" +``` + +--- + +## Task 11: `token` subcommand family + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/commands/TokenCommands.java` +- Modify: `src/main/java/org/tron/walletcli/Client.java` (register new commands) + +Subcommands: + +| Name | Options | Behaviour | +|---|---|---| +| `token-list` | `[--source builtin\|user\|all]` (default `all`) | Print symbol / address / decimals / source. JSON: `{tokens: [...]}` | +| `token-add` | `--symbol --address [--decimals ]` | Validate symbol; require address resolves to Base58 (no recursion through resolver); persist to user file. | +| `token-remove` | `--symbol ` | Remove from user file (built-in cannot be removed; exit code != 0 if symbol is built-in only). | +| `token-resolve` | `--input ` | Resolve and print address / symbol / source. | + +User file path: same as Task 8 (`Wallet/tokens/.json`). + +- [ ] **Step 1: Build the file with all four commands** + +```java +package org.tron.walletcli.cli.commands; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.tron.walletcli.cli.CommandDefinition; +import org.tron.walletcli.cli.CommandRegistry; +import org.tron.walletcli.cli.OptionDef; +import org.tron.walletcli.cli.OutputFormatter; +import org.tron.walletcli.cli.ParsedOptions; +import org.tron.walletcli.cli.tokens.ResolutionResult; +import org.tron.walletcli.cli.tokens.TokenEntry; +import org.tron.walletcli.cli.tokens.TokenResolver; +import org.tron.walletcli.cli.tokens.TokenStore; +import org.tron.walletcli.cli.tokens.TokenStoreLoader; +import org.tron.walletcli.cli.tokens.TokenValidation; +import org.tron.walletcli.WalletApiWrapper; +import org.tron.walletserver.WalletApi; + +public final class TokenCommands { + + private TokenCommands() {} + + public static void register(CommandRegistry registry) { + registry.register(buildList()); + registry.register(buildAdd()); + registry.register(buildRemove()); + registry.register(buildResolve()); + } + + private static String currentNetwork() { + // StandardCliRunner stores the network on a thread-local OR (simpler) we re-read + // from System property "tron.cli.network" set in StandardCliRunner. + String n = System.getProperty("tron.cli.network"); + return n == null ? "mainnet" : n.toLowerCase(Locale.ROOT); + } + + private static File userFile(String network) { + return new File("Wallet/tokens/" + network + ".json"); + } + + private static CommandDefinition buildList() { + return CommandDefinition.builder("token-list") + .description("List built-in and user-defined TRC20 token aliases") + .option("source", "Filter source: builtin | user | all (default: all)", false) + .handler((opts, wrapper, formatter) -> { + String network = currentNetwork(); + String source = opts.has("source") ? opts.getString("source").toLowerCase(Locale.ROOT) : "all"; + TokenStore store; + if ("builtin".equals(source)) { + store = TokenStoreLoader.loadBuiltin(network); + } else if ("user".equals(source)) { + store = TokenStoreLoader.loadUserFile(userFile(network)); + } else { + store = TokenStoreLoader.loadLayered(network, userFile(network)); + } + List> json = new ArrayList<>(); + StringBuilder text = new StringBuilder(); + for (TokenEntry e : store.listAll()) { + Map m = new LinkedHashMap<>(); + m.put("symbol", e.getSymbol()); + m.put("address", WalletApi.encode58Check(e.getAddress())); + m.put("decimals", e.getDecimals()); + m.put("source", e.getSource()); + json.add(m); + text.append(String.format("%-12s %s decimals=%-2d [%s]%n", + e.getSymbol(), WalletApi.encode58Check(e.getAddress()), + e.getDecimals(), e.getSource())); + } + Map data = new LinkedHashMap<>(); + data.put("network", network); + data.put("tokens", json); + formatter.success(text.toString(), data); + }) + .build(); + } + + private static CommandDefinition buildAdd() { + return CommandDefinition.builder("token-add") + .description("Add a TRC20 token alias to the user token list") + .option("symbol", "Token symbol (e.g. USDT)", true) + .option("address", "TRC20 contract address (Base58Check)", true) + .option("decimals", "Token decimals (default: 0)", false, OptionDef.Type.LONG) + .handler((opts, wrapper, formatter) -> { + String symbol = opts.getString("symbol"); + TokenValidation.requireValidSymbol(symbol); + byte[] addr = opts.getAddress("address"); + int decimals = opts.has("decimals") ? opts.getInt("decimals") : 0; + String network = currentNetwork(); + File f = userFile(network); + List existing = new ArrayList<>( + TokenStoreLoader.loadUserFile(f).listAll()); + existing.removeIf(e -> e.getSymbol().equalsIgnoreCase(symbol)); + existing.add(new TokenEntry(symbol, addr, decimals, "user")); + TokenStoreLoader.writeUserFile(f, existing); + Map data = new LinkedHashMap<>(); + data.put("symbol", symbol.toUpperCase(Locale.ROOT)); + data.put("address", WalletApi.encode58Check(addr)); + data.put("decimals", decimals); + data.put("network", network); + formatter.success("Added token alias " + symbol.toUpperCase(Locale.ROOT) + + " on network " + network, data); + }) + .build(); + } + + private static CommandDefinition buildRemove() { + return CommandDefinition.builder("token-remove") + .description("Remove a TRC20 token alias from the user token list") + .option("symbol", "Token symbol", true) + .handler((opts, wrapper, formatter) -> { + String symbol = opts.getString("symbol"); + String network = currentNetwork(); + File f = userFile(network); + List existing = new ArrayList<>( + TokenStoreLoader.loadUserFile(f).listAll()); + int before = existing.size(); + existing.removeIf(e -> e.getSymbol().equalsIgnoreCase(symbol)); + if (existing.size() == before) { + // not in user file — but might be builtin + TokenStore builtin = TokenStoreLoader.loadBuiltin(network); + if (builtin.find(symbol) != null) { + formatter.error("builtin_token", + "cannot remove built-in token: " + symbol.toUpperCase(Locale.ROOT)); + return; + } + formatter.error("not_found", + "no user-defined token with symbol: " + symbol); + return; + } + TokenStoreLoader.writeUserFile(f, existing); + Map data = new LinkedHashMap<>(); + data.put("symbol", symbol.toUpperCase(Locale.ROOT)); + data.put("network", network); + formatter.success("Removed token alias " + symbol.toUpperCase(Locale.ROOT), data); + }) + .build(); + } + + private static CommandDefinition buildResolve() { + return CommandDefinition.builder("token-resolve") + .description("Resolve an input string to a TRON address") + .option("input", "Symbol or address to resolve", true) + .handler((opts, wrapper, formatter) -> { + String network = currentNetwork(); + TokenResolver resolver = new TokenResolver( + TokenStoreLoader.loadLayered(network, userFile(network))); + ResolutionResult r = resolver.resolve("input", opts.getString("input")); + Map data = new LinkedHashMap<>(); + data.put("input", r.getInput()); + data.put("address", WalletApi.encode58Check(r.getAddress())); + if (r.getSymbol() != null) data.put("symbol", r.getSymbol()); + data.put("source", r.getSource()); + data.put("network", network); + formatter.success(WalletApi.encode58Check(r.getAddress()), data); + }) + .build(); + } +} +``` + +> The exact `CommandDefinition.builder(...).handler(...)` signature must match the existing pattern used in `WalletCommands.java` etc. Read one of those before writing this file and adjust handler parameter order if needed (`(opts, wrapper, formatter)` matches the CLAUDE.md description — verify). + +- [ ] **Step 2: Set the system property `tron.cli.network` from `StandardCliRunner`** + +This is how `TokenCommands` learns the network without invasive plumbing. Near where `globalOpts.getNetwork()` is read in Task 8: + +```java +if (network != null) { + System.setProperty("tron.cli.network", network.toLowerCase(java.util.Locale.ROOT)); +} else { + System.clearProperty("tron.cli.network"); +} +``` + +(This is acceptable because `StandardCliRunner` is single-threaded per JVM invocation. If the project uses a long-lived JVM that runs multiple commands, replace with a `ThreadLocal` field on `StandardCliRunner`.) + +- [ ] **Step 3: Register the new commands in `Client.java`** + +After the existing registrations (around `Client.java:4824`): + +```java +org.tron.walletcli.cli.commands.TokenCommands.register(registry); +``` + +- [ ] **Step 4: Smoke-test with the fat jar** + +```bash +./gradlew shadowJar +java -jar build/libs/wallet-cli.jar --network nile token-list +java -jar build/libs/wallet-cli.jar --network nile token-add --symbol FOO --address TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t --decimals 6 +java -jar build/libs/wallet-cli.jar --network nile token-list --source user +java -jar build/libs/wallet-cli.jar --output json --network nile token-resolve --input FOO +java -jar build/libs/wallet-cli.jar --network nile token-remove --symbol FOO +``` + +Expected: each command exits 0; the JSON `token-resolve` output contains `"symbol":"FOO"` and `"source":"user"`. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/commands/TokenCommands.java \ + src/main/java/org/tron/walletcli/Client.java \ + src/main/java/org/tron/walletcli/cli/StandardCliRunner.java +git commit -m "feat(cli): add token-list/add/remove/resolve commands" +``` + +--- + +## Task 12: End-to-end test of `trigger-constant-contract --contract USDT` + +**Files:** +- Test: `src/test/java/org/tron/walletcli/cli/commands/ContractCommandsTokenAliasTest.java` + +This test exercises the full parse path without calling gRPC. We construct a `ParsedOptions` with the resolver and verify that `getContractAddress("contract")` returns the built-in USDT mainnet address. + +- [ ] **Step 1: Write the test** + +```java +package org.tron.walletcli.cli.commands; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; +import org.tron.walletcli.cli.ParsedOptions; +import org.tron.walletcli.cli.tokens.TokenResolver; +import org.tron.walletcli.cli.tokens.TokenStoreLoader; +import org.tron.walletserver.WalletApi; +import static org.junit.Assert.*; + +public class ContractCommandsTokenAliasTest { + + @Test public void mainnetUSDTSymbolResolvesToCanonicalAddress() { + TokenResolver resolver = new TokenResolver( + TokenStoreLoader.loadLayered("mainnet", new java.io.File("/tmp/no-such-file.json"))); + Map values = new LinkedHashMap<>(); + values.put("contract", "USDT"); + ParsedOptions opts = new ParsedOptions(values, resolver); + byte[] resolved = opts.getContractAddress("contract"); + assertEquals("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + WalletApi.encode58Check(resolved)); + assertEquals(1, opts.getResolutionLog().size()); + } +} +``` + +- [ ] **Step 2: Run** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.commands.ContractCommandsTokenAliasTest"` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/org/tron/walletcli/cli/commands/ContractCommandsTokenAliasTest.java +git commit -m "test(contracts): verify USDT symbol resolves on mainnet" +``` + +--- + +## Task 13: Update standard-cli contract spec + +**Files:** +- Modify: `docs/standard-cli-contract-spec.md` + +- [ ] **Step 1: Append a new section** + +Insert at the end of the spec (before any "Future work" section if present): + +```markdown +## Token Alias Resolution + +The standard CLI accepts TRC20 token symbols anywhere a `--contract` value is required. +Resolution order for `--contract `: + +1. Try Base58Check decode of ``. +2. Try hex decode (`0x` or `41` prefix, 40 hex chars). +3. Look up `` (case-insensitive) in the user token file + `Wallet/tokens/.json`. +4. Look up in the built-in token list bundled with the CLI. +5. Fail with `--contract value "" is neither a valid TRON address nor a known token symbol`. + +Other address-bearing options (`--to`, `--from`, `--owner`, `--receiver`, `--address`) +do **not** consult the token table in this version. They keep their existing +Base58/hex-only contract. + +When an alias resolves, the CLI emits an audit record: +- **Text mode (not quiet):** one stderr line per resolution — + `Resolved --contract "USDT" → TR7NHqj... (source=builtin, symbol=USDT)`. +- **JSON mode:** the success envelope gains a `meta.resolved` array with one entry + per resolved alias. The `data` field is unchanged. + +User-defined tokens shadow built-in entries with the same symbol. Symbols are +case-insensitive; reserved words (`me`, `self`, `mainnet`, `nile`, `shasta`, `trx`) +and any string that decodes as a TRON address cannot be registered. + +Management commands: `token-list`, `token-add`, `token-remove`, `token-resolve`. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/standard-cli-contract-spec.md +git commit -m "docs(spec): document token alias resolution contract" +``` + +--- + +## Task 14: QA parity script + +**Files:** +- Create: `qa/commands/token_alias.sh` +- Modify: `qa/run.sh` (or whichever orchestrator file lists test scripts) — append the new script. + +- [ ] **Step 1: Inspect the QA harness** + +Run: `ls qa/commands/ && head -40 qa/run.sh` +Note conventions used by an existing simple script (e.g. how `JAR`, `NETWORK`, `MASTER_PASSWORD` are referenced). + +- [ ] **Step 2: Write `qa/commands/token_alias.sh`** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +source "$(dirname "$0")/../config.sh" + +JAR="${JAR:-build/libs/wallet-cli.jar}" +NETWORK="${NETWORK:-nile}" + +run() { java -jar "$JAR" --network "$NETWORK" "$@"; } +runj() { java -jar "$JAR" --output json --network "$NETWORK" "$@"; } + +echo "[token-alias] token-list (built-in nile contains nothing problematic)" +run token-list >/dev/null + +echo "[token-alias] token-add then token-list --source user contains FOO" +run token-add --symbol FOO \ + --address TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t --decimals 6 >/dev/null +run token-list --source user | grep -q '^FOO ' \ + || { echo "FAIL: FOO not in user list"; exit 1; } + +echo "[token-alias] token-resolve FOO -> address + source=user (JSON)" +out=$(runj token-resolve --input FOO) +echo "$out" | grep -q '"symbol":"FOO"' || { echo "FAIL: missing symbol"; exit 1; } +echo "$out" | grep -q '"source":"user"' || { echo "FAIL: source!=user"; exit 1; } + +echo "[token-alias] trigger-constant-contract with --contract FOO emits meta.resolved" +out=$(runj trigger-constant-contract --contract FOO --method "name()" \ + --owner TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t || true) +echo "$out" | grep -q '"meta"' || { echo "FAIL: expected meta.resolved in JSON"; exit 1; } +echo "$out" | grep -q '"option":"contract"' || { echo "FAIL: missing option=contract in meta"; exit 1; } + +echo "[token-alias] cleanup" +run token-remove --symbol FOO >/dev/null + +echo "[token-alias] PASS" +``` + +- [ ] **Step 3: Append to `qa/run.sh`** + +Add the new script alongside the existing entries (mirror whatever pattern is used — typically a `bash qa/commands/.sh` line in a list). + +- [ ] **Step 4: Run locally** + +```bash +./gradlew shadowJar +TRON_TEST_PRIVATE_KEY= bash qa/run.sh verify +``` + +Expected: full QA passes including the new script. + +- [ ] **Step 5: Commit** + +```bash +git add qa/commands/token_alias.sh qa/run.sh +git commit -m "qa: parity tests for TRC20 token alias" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** built-in list (T1), data layer (T2-T6), CLI plumbing (T7-T8), command surface (T9, T11), audit output (T10), spec doc (T13), parity tests (T12, T14). Every spec promise has a task. +- **Type consistency:** `TokenEntry` constructor `(symbol, address, decimals, source)` is used identically in T2, T5, T11, T12. `ResolutionResult` `(option, input, address, symbol, source)` matches across T6, T7, T10, T11. `TokenStore.find` returns nullable `TokenEntry` consistently. `TokenStoreLoader.loadLayered(network, userFile)` signature is uniform. +- **Placeholder check:** every step that changes code shows the code; commit messages are written; expected test counts are stated. The two adapt-to-existing-code spots (T8 step 1, T10 step 1) are scoped to "read this file first then make the change shown" and the diff content is provided. +- **Out of scope reminders:** TRC10, address-book wallet aliases, decimals-aware amount, and `--params`/`--library` ABI parsing are explicitly excluded. diff --git a/docs/superpowers/plans/2026-05-08-unified-address-book.md b/docs/superpowers/plans/2026-05-08-unified-address-book.md new file mode 100644 index 00000000..0db7d26e --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-unified-address-book.md @@ -0,0 +1,2783 @@ +# Unified Address Book Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a unified address book to the Standard CLI so users (and AI agents) can use named aliases instead of raw TRON addresses for both **accounts** (recipients, owners, voters) and **TRC20 tokens** (`USDT`, `USDC`, ...). + +**Architecture:** +A single `AliasStore` holds typed entries (`type in {ACCOUNT, TOKEN}`). The store is layered: an immutable built-in baseline (TRC20 tokens only, bundled as JSON resources per network) plus a per-network user file. **Built-in entries cannot be overridden** — `alias-add` rejects any name that exists in the built-in set, and runtime layering also keeps built-in entries authoritative if a user file is edited by hand. Resolution is bare-name fallback (Foundry/ENS style): inputs that decode as Base58 or hex are passed through unchanged; only on failure does the resolver consult the alias store, filtered by the type the calling option expects (`--contract` -> TOKEN, `--to/--from/--owner/--receiver/--address` -> ACCOUNT, except `get-contract` / `get-contract-info` whose `--address` is semantically a contract). Every alias hit is recorded and surfaced via stderr (text mode) and `meta.resolved` (JSON mode) so the resolved address is auditable before signing. + +**Tech Stack:** Java 8, JCommander/Standard-CLI framework, Jackson 2.x (add to `build.gradle` if missing), JUnit 4. + +**Scope:** +- All 76 `opts.getAddress(...)` call sites across `cli/commands/*.java` migrate to `getAccountAddress(...)` or `getContractAddress(...)`. +- Built-in baseline ships TRC20 tokens only (USDT/USDC/USDD/WTRX on `main`; USDT on `nile`; empty on `shasta`; empty on `custom`). +- New `alias-add / alias-remove / alias-list / alias-resolve` commands. +- Naming standard, reserved words, anti-Base58 checks enforced at registration. +- Symbol collision with built-in is rejected with an actionable error. +- JSON envelope gains optional `meta.resolved` array. + +**Out of scope:** +- Decimals-aware amount conversion (`--amount 1.5 --token USDT`). +- ABI-level alias inside `--params` and `--library` strings. +- Built-in account/recipient entries (built-in contains tokens only). +- Cross-network sync, third-party token list import. +- REPL (`Client.java`) interactive command alias support — Standard CLI only. + +--- + +## File Structure + +``` +src/main/java/org/tron/walletcli/cli/aliases/ + AliasType.java # enum ACCOUNT, TOKEN + AliasEntry.java # name, type, address[21], decimals (token only), source + AliasValidation.java # name regex, reserved, anti-Base58, anti-hex + AliasStore.java # in-memory typed lookup + AliasStoreLoader.java # builtin resources + user JSON file (read/write atomic) + ResolutionResult.java # option, input, address, name|null, type|null, source + AliasResolver.java # resolve(option, input, expectedType) -> ResolutionResult + AliasResolutionException.java + +src/main/resources/aliases/ + main.json + nile.json + shasta.json + +src/main/java/org/tron/walletcli/cli/ + ParsedOptions.java # MODIFY: add resolver field, getAccountAddress, getContractAddress + CommandDefinition.java # MODIFY: parseArgs(args, resolver) overload + CommandContext.java # MODIFY: thread AliasResolver through + StandardCliRunner.java # MODIFY: build resolver per invocation, push log to formatter + OutputFormatter.java # MODIFY: stderr resolved lines + JSON meta.resolved + +src/main/java/org/tron/walletcli/cli/commands/ + AliasCommands.java # NEW: alias-add / alias-remove / alias-list / alias-resolve + TransactionCommands.java # MODIFY: 16 sites + StakingCommands.java # MODIFY: 14 sites + ContractCommands.java # MODIFY: 13 sites (12 owner/contract, 1 mixed) + QueryCommands.java # MODIFY: 21 sites (2 contract, rest account) + ExchangeCommands.java # MODIFY: 5 sites + WitnessCommands.java # MODIFY: 4 sites + ProposalCommands.java # MODIFY: 3 sites + +src/main/java/org/tron/walletcli/Client.java + # MODIFY: register AliasCommands + +src/test/java/org/tron/walletcli/cli/aliases/ + AliasEntryTest.java + AliasValidationTest.java + AliasStoreTest.java + AliasStoreLoaderTest.java + AliasResolverTest.java +src/test/java/org/tron/walletcli/cli/ + ParsedOptionsAliasTest.java + OutputFormatterResolvedTest.java + +docs/standard-cli-contract-spec.md # MODIFY +qa/commands/alias.sh # NEW +``` + +User alias file path: `Wallet/aliases/.json` (alongside the keystore directory). `` uses the Standard CLI values: `main`, `nile`, `shasta`, or `custom`. + +--- + +## JSON formats + +**Built-in resource & user file** share one schema: + +```json +{ + "entries": [ + {"name": "USDT", "type": "TOKEN", "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "decimals": 6}, + {"name": "alice","type": "ACCOUNT", "address": "TXyzExampleRecipientAddress...........", "note": "company hot wallet"} + ] +} +``` + +`decimals` only meaningful for TOKEN; ignored for ACCOUNT. `note` optional, only for ACCOUNT. + +**JSON output meta enrichment** (envelope field, omitted when empty): + +```json +{ + "success": true, + "data": { ... }, + "meta": { + "resolved": [ + {"option": "contract", "input": "USDT", "address": "TR7NHqj...", "name": "USDT", "type": "TOKEN", "source": "builtin"}, + {"option": "to", "input": "alice","address": "TXyz...", "name": "alice","type": "ACCOUNT","source": "user"} + ] + } +} +``` + +--- + +## Phase Map + +| Phase | Tasks | Outcome | +|---|---|---| +| **0. Foundation** | T1–T4 | `AliasType`, `AliasEntry`, `AliasValidation`, built-in JSON resources | +| **1. Storage** | T5–T7 | `AliasStore`, `AliasStoreLoader`, layered loader | +| **2. Resolver** | T8–T9 | `ResolutionResult`, `AliasResolver`, exception | +| **3. CLI plumbing** | T10–T12 | `ParsedOptions` + `CommandContext` + `StandardCliRunner` wired | +| **4. Audit output** | T13 | `OutputFormatter` resolved lines + meta | +| **5. Token migration** | T14 | ContractCommands `--contract` + QueryCommands get-contract* | +| **6. Account migration** | T15–T20.5 | All remaining sites: Transaction, Staking, Witness, Proposal, Exchange, Query; Wallet (switch-wallet uses opts.getString manually so needs its own task T20.5) | +| **7. CLI commands** | T21 | `alias-add/remove/list/resolve` | +| **8. Docs & QA** | T22–T23 | Contract spec + parity script | + +Each command-migration task in Phase 6 is a self-contained file edit + smoke build, so they can be parallelised by separate subagents if Subagent-Driven execution is chosen. + +--- + +## Task 1: `AliasType` enum + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/aliases/AliasType.java` + +- [ ] **Step 1: Implement** + +```java +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); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/aliases/AliasType.java +git commit -m "feat(aliases): add AliasType enum" +``` + +--- + +## Task 2: `AliasEntry` value type + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/aliases/AliasEntry.java` +- Test: `src/test/java/org/tron/walletcli/cli/aliases/AliasEntryTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package org.tron.walletcli.cli.aliases; + +import org.junit.Test; +import static org.junit.Assert.*; + +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 rejectsBlankSource() { + AliasEntry.token("USDT", addr(), 6, " "); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsBlankName() { + AliasEntry.token(" ", addr(), 6, "builtin"); + } + + @Test public void tokenHasTokenType() { + assertEquals(AliasType.TOKEN, AliasEntry.token("USDT", addr(), 6, "builtin").getType()); + } + + @Test public void accountHasAccountType() { + assertEquals(AliasType.ACCOUNT, AliasEntry.account("alice", addr(), "user", null).getType()); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.aliases.AliasEntryTest"` +Expected: FAIL — class missing. + +- [ ] **Step 3: Implement** + +```java +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; // meaningful for TOKEN; 0 for ACCOUNT + private final String source; // "builtin" | "user" + private final String note; // optional ACCOUNT 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"); + } + 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; } +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.aliases.AliasEntryTest"` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/aliases/AliasEntry.java \ + src/test/java/org/tron/walletcli/cli/aliases/AliasEntryTest.java +git commit -m "feat(aliases): add typed AliasEntry value type" +``` + +--- + +## Task 3: `AliasValidation` syntactic guards + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/aliases/AliasValidation.java` +- Test: `src/test/java/org/tron/walletcli/cli/aliases/AliasValidationTest.java` + +Naming rules (rejected): +- Decodable as Base58Check TRON address. +- Matches hex-address shape: `^(0x|41)[0-9a-fA-F]{40}$`. +- Reserved (case-insensitive): `me`, `self`, `main`, `mainnet`, `nile`, `shasta`, `custom`, `trx`, `default`. +- Anything not matching `^[A-Za-z][A-Za-z0-9_.-]{0,31}$`. + +- [ ] **Step 1: Write the failing test** + +```java +package org.tron.walletcli.cli.aliases; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class AliasValidationTest { + + @Test public void acceptsTypicalNames() { + AliasValidation.requireValidName("USDT"); + AliasValidation.requireValidName("alice"); + AliasValidation.requireValidName("hot-wallet"); + AliasValidation.requireValidName("v2.usdt"); + AliasValidation.requireValidName("Pkg_Beta"); + } + + @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 rejects0xHex() { + AliasValidation.requireValidName("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsReserved() { AliasValidation.requireValidName("me"); } + + @Test(expected = IllegalArgumentException.class) + public void rejectsLeadingDigit() { AliasValidation.requireValidName("1inch"); } + + @Test(expected = IllegalArgumentException.class) + public void rejectsTooLong() { + StringBuilder sb = new StringBuilder("A"); + for (int i = 0; i < 32; i++) sb.append('a'); + AliasValidation.requireValidName(sb.toString()); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNull() { AliasValidation.requireValidName(null); } + + @Test public void looksLikeAddressDetectsBase58() { + assertTrue(AliasValidation.looksLikeAddress("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")); + } + + @Test public void looksLikeAddressDetectsHex() { + assertTrue(AliasValidation.looksLikeAddress( + "41a614f803b6fd780986a42c78ec9c7f77e6ded13c")); + } + + @Test public void looksLikeAddressRejectsName() { + assertFalse(AliasValidation.looksLikeAddress("USDT")); + assertFalse(AliasValidation.looksLikeAddress("alice")); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.aliases.AliasValidationTest"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```java +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; + return WalletApi.decodeFromBase58Check(t) != null; + } + + 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); + } + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.aliases.AliasValidationTest"` +Expected: PASS, 11 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/aliases/AliasValidation.java \ + src/test/java/org/tron/walletcli/cli/aliases/AliasValidationTest.java +git commit -m "feat(aliases): add naming validation guards" +``` + +--- + +## Task 4: Built-in alias resources + +**Files:** +- Create: `src/main/resources/aliases/main.json` +- Create: `src/main/resources/aliases/nile.json` +- Create: `src/main/resources/aliases/shasta.json` + +- [ ] **Step 1: Write `main.json`** + +```json +{ + "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} + ] +} +``` + +- [ ] **Step 2: Write `nile.json`** + +```json +{ + "entries": [ + {"name": "USDT", "type": "TOKEN", "address": "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj", "decimals": 6} + ] +} +``` + +- [ ] **Step 3: Write `shasta.json`** + +```json +{ "entries": [] } +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/main/resources/aliases/ +git commit -m "feat(aliases): bundle built-in TRC20 token list per network" +``` + +--- + +## Task 5: `AliasStore` typed lookup + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/aliases/AliasStore.java` +- Test: `src/test/java/org/tron/walletcli/cli/aliases/AliasStoreTest.java` + +Behaviour: +- `find(name, type)` returns the entry whose name (case-insensitive) matches AND whose `type` equals the argument (or any type when argument is `null`). +- `containsName(name)` is case-insensitive across types — used by `alias-add` to detect collisions. +- `listAll()` and `listByType(type)` are stable iteration helpers. +- Layering: `layered(builtin, user)` returns a store where built-in entries are authoritative. Any user entry whose name collides with a built-in name (case-insensitive, across types) is ignored at runtime. This is required because users can hand-edit `Wallet/aliases/.json`; `alias-add` rejection alone is not a security boundary. + +- [ ] **Step 1: Write the failing test** + +```java +package org.tron.walletcli.cli.aliases; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class AliasStoreTest { + + private byte[] addr(int marker) { + byte[] a = new byte[21]; + a[0] = 0x41; a[20] = (byte) marker; + return a; + } + + @Test public void emptyStoreLooksUpToNull() { + AliasStore s = AliasStore.of(Collections.emptyList()); + assertNull(s.find("USDT", AliasType.TOKEN)); + assertFalse(s.containsName("USDT")); + } + + @Test public void findIsCaseInsensitiveAndTypeFiltered() { + AliasStore s = AliasStore.of(Arrays.asList( + AliasEntry.token("USDT", addr(1), 6, "builtin"), + AliasEntry.account("alice", addr(2), "user", null))); + assertNotNull(s.find("usdt", AliasType.TOKEN)); + assertNull(s.find("usdt", AliasType.ACCOUNT)); + assertNotNull(s.find("ALICE", AliasType.ACCOUNT)); + assertNull(s.find("alice", AliasType.TOKEN)); + } + + @Test public void findWithNullTypeMatchesAcrossTypes() { + AliasStore s = AliasStore.of(Arrays.asList( + AliasEntry.token("USDT", addr(1), 6, "builtin"))); + assertNotNull(s.find("USDT", null)); + } + + @Test public void containsNameIgnoresType() { + AliasStore s = AliasStore.of(Arrays.asList( + AliasEntry.token("USDT", addr(1), 6, "builtin"))); + assertTrue(s.containsName("usdt")); + assertTrue(s.containsName("USDT")); + } + + @Test public void layeredBuiltinWinsOnSameTypeAndName() { + AliasStore builtin = AliasStore.of(Arrays.asList( + AliasEntry.token("USDT", addr(1), 6, "builtin"))); + AliasStore user = AliasStore.of(Arrays.asList( + AliasEntry.token("USDT", addr(2), 6, "user"))); + AliasStore layered = AliasStore.layered(builtin, user); + assertEquals("builtin", layered.find("USDT", AliasType.TOKEN).getSource()); + assertEquals(1, layered.find("USDT", AliasType.TOKEN).getAddress()[20] & 0xFF); + } + + @Test public void layeredRejectsBuiltinNameAcrossTypes() { + AliasStore builtin = AliasStore.of(Arrays.asList( + AliasEntry.token("USDT", addr(1), 6, "builtin"))); + AliasStore user = AliasStore.of(Arrays.asList( + AliasEntry.account("usdt", addr(2), "user", null))); + AliasStore layered = AliasStore.layered(builtin, user); + assertEquals("builtin", layered.find("USDT", AliasType.TOKEN).getSource()); + assertNull(layered.find("USDT", AliasType.ACCOUNT)); + } + + @Test public void listByTypeFilters() { + AliasStore s = AliasStore.of(Arrays.asList( + AliasEntry.token("USDT", addr(1), 6, "builtin"), + AliasEntry.account("alice", addr(2), "user", null))); + assertEquals(1, s.listByType(AliasType.TOKEN).size()); + assertEquals(1, s.listByType(AliasType.ACCOUNT).size()); + assertEquals(2, s.listAll().size()); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.aliases.AliasStoreTest"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```java +package org.tron.walletcli.cli.aliases; + +import java.util.ArrayList; +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 byKey; // "TYPE/UPPERNAME" -> entry + private final Map> byNameAcrossTypes; + + private AliasStore(Map byKey, + Map> byName) { + this.byKey = byKey; + this.byNameAcrossTypes = byName; + } + + private static String key(AliasType type, String name) { + return type.name() + "/" + name.toUpperCase(Locale.ROOT); + } + + public static AliasStore of(List entries) { + Map byKey = new LinkedHashMap(); + Map> byName = new LinkedHashMap>(); + for (AliasEntry e : entries) { + byKey.put(key(e.getType(), e.getName()), e); + String upper = e.getName().toUpperCase(Locale.ROOT); + List list = byName.get(upper); + if (list == null) { + list = new ArrayList(); + byName.put(upper, list); + } + list.add(e); + } + return new AliasStore(byKey, byName); + } + + public static AliasStore layered(AliasStore builtin, AliasStore user) { + Map mergedByKey = new LinkedHashMap(builtin.byKey); + for (AliasEntry e : user.byKey.values()) { + if (builtin.containsName(e.getName())) { + continue; + } + mergedByKey.put(key(e.getType(), e.getName()), e); + } + return AliasStore.of(new ArrayList(mergedByKey.values())); + } + + public AliasEntry find(String name, AliasType type) { + if (name == null) return null; + if (type == null) { + List list = byNameAcrossTypes.get(name.trim().toUpperCase(Locale.ROOT)); + return (list == null || list.isEmpty()) ? null : list.get(0); + } + return byKey.get(key(type, name.trim())); + } + + public boolean containsName(String name) { + if (name == null) return false; + return byNameAcrossTypes.containsKey(name.trim().toUpperCase(Locale.ROOT)); + } + + public List listAll() { + return Collections.unmodifiableList(new ArrayList(byKey.values())); + } + + public List listByType(AliasType type) { + List out = new ArrayList(); + for (AliasEntry e : byKey.values()) { + if (e.getType() == type) out.add(e); + } + return Collections.unmodifiableList(out); + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.aliases.AliasStoreTest"` +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/aliases/AliasStore.java \ + src/test/java/org/tron/walletcli/cli/aliases/AliasStoreTest.java +git commit -m "feat(aliases): add typed AliasStore with layered lookup" +``` + +--- + +## Task 6: Verify Jackson is on the classpath + +- [ ] **Step 1: Inspect** + +Run: `./gradlew dependencies --configuration runtimeClasspath | grep -i jackson | head -3` +Expected: lines for `jackson-databind`. If absent, add to `build.gradle`: + +```groovy +dependencies { + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.4' +} +``` + +Then `./gradlew build -x test` to confirm it resolves. + +- [ ] **Step 2: Commit (only if build.gradle changed)** + +```bash +git add build.gradle +git commit -m "build: add jackson-databind for alias JSON parsing" +``` + +--- + +## Task 7: `AliasStoreLoader` + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/aliases/AliasStoreLoader.java` +- Test: `src/test/java/org/tron/walletcli/cli/aliases/AliasStoreLoaderTest.java` + +Loader API: + +```java +public static AliasStore loadBuiltin(String network); +public static AliasStore loadUserFile(File file); // missing -> empty +public static void writeUserFile(File file, List entries); // .tmp + atomic move when supported +public static AliasStore loadLayered(String network, File userFile); +``` + +Per-entry behaviour: +- Bad symbol / bad address / unknown type → log warn to stderr, skip entry, continue. +- Duplicate names inside one built-in resource (impossible in our resources) → still loads, last wins within that resource. User entries still cannot override built-ins at runtime because `AliasStore.layered(...)` ignores built-in name collisions. + +- [ ] **Step 1: Write the failing test** + +```java +package org.tron.walletcli.cli.aliases; + +import java.io.File; +import java.io.PrintWriter; +import java.util.Arrays; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +public class AliasStoreLoaderTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + @Test public void builtinMainContainsUSDT() { + AliasStore s = AliasStoreLoader.loadBuiltin("main"); + AliasEntry usdt = s.find("USDT", AliasType.TOKEN); + assertNotNull(usdt); + assertEquals("builtin", usdt.getSource()); + assertEquals(6, usdt.getDecimals()); + } + + @Test public void unknownNetworkReturnsEmpty() { + AliasStore s = AliasStoreLoader.loadBuiltin("does-not-exist"); + assertTrue(s.listAll().isEmpty()); + } + + @Test public void userFileMissingReturnsEmpty() { + File f = new File(tmp.getRoot(), "missing.json"); + AliasStore s = AliasStoreLoader.loadUserFile(f); + assertTrue(s.listAll().isEmpty()); + } + + @Test public void userFileLoadsTokenAndAccount() throws Exception { + File f = tmp.newFile("u.json"); + try (PrintWriter w = new PrintWriter(f)) { + w.println("{ \"entries\": [" + + " {\"name\":\"FOO\",\"type\":\"TOKEN\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\",\"decimals\":4}," + + " {\"name\":\"alice\",\"type\":\"ACCOUNT\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\",\"note\":\"hot\"}" + + " ] }"); + } + AliasStore s = AliasStoreLoader.loadUserFile(f); + assertEquals("user", s.find("FOO", AliasType.TOKEN).getSource()); + assertEquals(4, s.find("FOO", AliasType.TOKEN).getDecimals()); + assertEquals("hot", s.find("alice", AliasType.ACCOUNT).getNote()); + } + + @Test public void writeThenReadRoundTripsBothTypes() throws Exception { + byte[] addr = org.tron.walletserver.WalletApi.decodeFromBase58Check( + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + File f = new File(tmp.getRoot(), "out.json"); + AliasStoreLoader.writeUserFile(f, Arrays.asList( + AliasEntry.token("BAR", addr, 8, "user"), + AliasEntry.account("bob", addr, "user", "cold"))); + AliasStore s = AliasStoreLoader.loadUserFile(f); + assertEquals(8, s.find("BAR", AliasType.TOKEN).getDecimals()); + assertEquals("cold", s.find("bob", AliasType.ACCOUNT).getNote()); + } + + @Test public void malformedEntriesAreSkipped() throws Exception { + File f = tmp.newFile("bad.json"); + try (PrintWriter w = new PrintWriter(f)) { + w.println("{ \"entries\": [" + + " {\"name\":\"OK\",\"type\":\"TOKEN\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\",\"decimals\":6}," + + " {\"name\":\"1bad\",\"type\":\"TOKEN\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\"}," + + " {\"name\":\"NoType\",\"address\":\"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t\"}," + + " {\"name\":\"BadAddr\",\"type\":\"TOKEN\",\"address\":\"not-base58\"}" + + " ]}"); + } + AliasStore s = AliasStoreLoader.loadUserFile(f); + assertNotNull(s.find("OK", AliasType.TOKEN)); + assertNull(s.find("1bad", AliasType.TOKEN)); + assertNull(s.find("NoType", null)); + assertNull(s.find("BadAddr", AliasType.TOKEN)); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.aliases.AliasStoreLoaderTest"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```java +package org.tron.walletcli.cli.aliases; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import org.tron.walletserver.WalletApi; + +public final class AliasStoreLoader { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT); + + private AliasStoreLoader() {} + + public static AliasStore loadBuiltin(String network) { + if (network == null) return AliasStore.of(Collections.emptyList()); + String resource = "/aliases/" + network.toLowerCase(Locale.ROOT) + ".json"; + try (InputStream in = AliasStoreLoader.class.getResourceAsStream(resource)) { + if (in == null) return AliasStore.of(Collections.emptyList()); + return AliasStore.of(parseEntries(MAPPER.readTree(in), "builtin")); + } catch (IOException e) { + System.err.println("warn: failed to load builtin alias list " + + resource + ": " + e.getMessage()); + return AliasStore.of(Collections.emptyList()); + } + } + + public static AliasStore loadUserFile(File file) { + if (file == null || !file.isFile()) { + return AliasStore.of(Collections.emptyList()); + } + try { + return AliasStore.of(parseEntries(MAPPER.readTree(file), "user")); + } catch (IOException e) { + System.err.println("warn: failed to read user alias file " + + file + ": " + e.getMessage()); + return AliasStore.of(Collections.emptyList()); + } + } + + public static AliasStore loadLayered(String network, File userFile) { + return AliasStore.layered(loadBuiltin(network), loadUserFile(userFile)); + } + + public static void writeUserFile(File file, List entries) { + ObjectNode root = MAPPER.createObjectNode(); + ArrayNode arr = root.putArray("entries"); + for (AliasEntry e : entries) { + ObjectNode n = arr.addObject(); + n.put("name", e.getName()); + n.put("type", e.getType().name()); + n.put("address", WalletApi.encode58Check(e.getAddress())); + if (e.getType() == AliasType.TOKEN) { + n.put("decimals", e.getDecimals()); + } + if (e.getNote() != null) { + n.put("note", e.getNote()); + } + } + File parent = file.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IllegalStateException("cannot create directory: " + parent); + } + File tmp = new File(file.getAbsolutePath() + ".tmp"); + try { + MAPPER.writerWithDefaultPrettyPrinter().writeValue(tmp, root); + 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); + } + } catch (IOException e) { + throw new IllegalStateException("failed to write " + file + ": " + e.getMessage(), e); + } + } + + private static List parseEntries(JsonNode root, String source) { + List out = new ArrayList(); + if (root == null || !root.has("entries") || !root.get("entries").isArray()) { + return out; + } + for (JsonNode node : root.get("entries")) { + String name = node.path("name").asText(null); + String typeStr = node.path("type").asText(null); + String address = node.path("address").asText(null); + if (name == null || typeStr == null || address == null) { + System.err.println("warn: skipping alias entry missing name/type/address"); + continue; + } + AliasType type; + try { type = AliasType.parse(typeStr); } + catch (IllegalArgumentException e) { + System.err.println("warn: skipping alias " + name + " - " + e.getMessage()); + continue; + } + try { AliasValidation.requireValidName(name); } + catch (IllegalArgumentException e) { + System.err.println("warn: skipping alias entry: " + e.getMessage()); + continue; + } + byte[] addr = WalletApi.decodeFromBase58Check(address); + if (addr == null) { + System.err.println("warn: skipping alias " + name + " - invalid address: " + address); + continue; + } + if (type == AliasType.TOKEN) { + int decimals = node.path("decimals").asInt(0); + out.add(AliasEntry.token(name, addr, decimals, source)); + } else { + String note = node.has("note") ? node.path("note").asText(null) : null; + out.add(AliasEntry.account(name, addr, source, note)); + } + } + return out; + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `./gradlew test --tests "org.tron.walletcli.cli.aliases.AliasStoreLoaderTest"` +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/aliases/AliasStoreLoader.java \ + src/test/java/org/tron/walletcli/cli/aliases/AliasStoreLoaderTest.java +git commit -m "feat(aliases): add AliasStoreLoader (builtin resources + user JSON)" +``` + +--- + +## Task 8: `ResolutionResult` + `AliasResolutionException` + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/aliases/ResolutionResult.java` +- Create: `src/main/java/org/tron/walletcli/cli/aliases/AliasResolutionException.java` + +- [ ] **Step 1: Implement `ResolutionResult`** + +```java +package org.tron.walletcli.cli.aliases; + +public final class ResolutionResult { + private final String option; + private final String input; + private final byte[] address; + private final String name; // null when input was raw address/hex + private final AliasType type; // null when input was raw address/hex + private final String source; // "address" | "hex" | "user" | "builtin" + + public ResolutionResult(String option, String input, byte[] address, + String name, AliasType type, String source) { + this.option = option; + this.input = input; + this.address = 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.clone(); } + public String getName() { return name; } + public AliasType getType() { return type; } + public String getSource() { return source; } + + public boolean isAlias() { + return "user".equals(source) || "builtin".equals(source); + } +} +``` + +- [ ] **Step 2: Implement exception** + +```java +package org.tron.walletcli.cli.aliases; + +public class AliasResolutionException extends IllegalArgumentException { + public AliasResolutionException(String message) { super(message); } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/org/tron/walletcli/cli/aliases/ResolutionResult.java \ + src/main/java/org/tron/walletcli/cli/aliases/AliasResolutionException.java +git commit -m "feat(aliases): add ResolutionResult and exception type" +``` + +--- + +## Task 9: `AliasResolver` + +**Files:** +- Create: `src/main/java/org/tron/walletcli/cli/aliases/AliasResolver.java` +- Test: `src/test/java/org/tron/walletcli/cli/aliases/AliasResolverTest.java` + +Resolution sequence per call `resolve(option, input, expectedType)`: +1. Base58Check decode succeeds → return with `source="address"`, `name=null`, `type=null`. +2. Hex matches `^(0x|41)[0-9a-fA-F]{40}$` → return with `source="hex"`. +3. `store.find(input, expectedType)` → return alias hit. +4. If `store.containsName(input)` succeeds for a *different* type → throw with a useful message: + `--contract value "alice" is registered as ACCOUNT, not TOKEN`. +5. Else → throw `AliasResolutionException`: `--