Skip to content

feat: add standard CLI mode with JSON output for AI agents and automation#921

Open
parsoncryptoai wants to merge 22 commits intotronprotocol:developfrom
parsoncryptoai:develop
Open

feat: add standard CLI mode with JSON output for AI agents and automation#921
parsoncryptoai wants to merge 22 commits intotronprotocol:developfrom
parsoncryptoai:develop

Conversation

@parsoncryptoai
Copy link
Copy Markdown

Summary

  • Standard CLI mode: Non-interactive, scriptable CLI (java -jar wallet-cli.jar --network nile get-account --address TXyz...) alongside the existing
    REPL mode. Supports --output json for structured output, --network for network selection, and --quiet flag. Designed for AI agents, scripts, and
    CI/CD pipelines.
  • Command framework: CommandRegistry/CommandDefinition pattern with fluent builder API. Commands organized by domain in cli/commands/ (Query,
    Transaction, Wallet, Staking, Contract, Exchange, Proposal, Witness, Misc). Supports aliases, typed options, and fuzzy command suggestion on typos.
  • JSON output envelope: All commands produce {"success":true,"data":...} or {"success":false,"error":"..."} in JSON mode, with stdout/stderr
    suppression to guarantee machine-parseable output.
  • Active wallet management: Persistent active wallet selection via set-active-wallet / list-wallets commands, stored in
    Wallet/active_wallet.conf.
  • QA verification suite: Shell-based parity tests (qa/) comparing REPL vs standard CLI output across text and JSON modes, with semantic comparison
    for format-independent validation.
  • Transfer USDT command: New transfer-usdt command for TRC20 USDT transfers with automatic contract address resolution per network.

Test plan

  • Build passes: ./gradlew build
  • Standard CLI text mode: java -jar build/libs/wallet-cli.jar --network nile get-account --address <addr>
  • Standard CLI JSON mode: java -jar build/libs/wallet-cli.jar --output json --network nile get-account --address <addr>
  • REPL mode still works: ./gradlew run
  • QA parity tests pass: TRON_TEST_APIKEY=<key> bash qa/run.sh verify
  • Active wallet commands: set-active-wallet, list-wallets
  • Transfer USDT: transfer-usdt --to <addr> --amount 1

HarukaMa and others added 11 commits April 1, 2026 15:47
Add a non-interactive standard CLI framework (--output json, --private-key,
--mnemonic flags) alongside the existing interactive REPL. Includes a bash
harness that verifies all 120+ commands across help, text, JSON, on-chain
transactions, REPL parity, and wallet management (321 tests, 315 pass, 0 fail).

Key changes:
- New cli/ package: StandardCliRunner, OutputFormatter, CommandRegistry,
  and per-domain command files (Query, Transaction, Staking, etc.)
- JSON mode suppresses stray System.out/err from WalletApi layer so only
  structured OutputFormatter output reaches stdout
- Remove debug print from AbiUtil.parseMethod() that contaminated stdout
- Harness scripts (harness/) for automated three-way parity verification
- Updated .gitignore for runtime artifacts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ments

Rename harness/ → qa/ across shell scripts, Java classes, docs, and build config.
Fix vote-witness QA test that extracted keystore address instead of witness address
by filtering "keystore" lines from list-witnesses output. Add lock/unlock commands,
improve GlobalOptions parsing, and update CLAUDE.md baseline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…d auth options

Move JSON stream suppression earlier in StandardCliRunner to cover network
init and authentication (not just command execution), remove --private-key
and --mnemonic global options, and update QA/plan docs to reflect current
test baseline (321 tests, 314 passed, 1 failed, 6 skipped).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Simplify protobuf formatting to use Utils.formatMessageString for both
modes, suppress info messages in JSON mode, and update spec/plan docs
to clarify the strict JSON-only contract.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add text+JSON parity, JSON field validation, round-trip verification
(set then get-active-wallet), and error case tests (no args, both args,
invalid address) for wallet management commands.
Replace login/logout/backup/export commands with list-wallet, set-active-wallet,
and get-active-wallet for multi-wallet support. Implement transfer-usdt with
automatic energy estimation. Update CLAUDE.md docs and add QA tests for new
transaction commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Skip unit tests and QA verification when no code files (src/, build.gradle, *.java) have changed. Consolidates two separate hook blocks into one.
… to JSON errors

WalletApi.listWallets filtered to skip the .active-wallet config file,
which was incorrectly treated as a wallet keystore. OutputFormatter.error()
and usageError() now include "success": false in JSON output for consistent
envelope format.
@lxcmyf
Copy link
Copy Markdown
Contributor

lxcmyf commented Apr 3, 2026

This PR is currently under review.
Separately, wallet-cli also has some related MCP plans on our side. The general idea is to expose selected wallet-cli capabilities as MCP services, so they can be called directly from AI chat interfaces or other agent-based workflows, instead of being limited to traditional interactive CLI usage. We are still working through the design and scope, and we will share updates as things move forward.

HarukaMa and others added 3 commits April 3, 2026 17:05
…defaults

- OutputFormatter.success() now wraps jsonData in {"success":true,"data":{...}}
  envelope for consistent API response format
- OutputFormatter.protobuf() outputs single-line valid JSON via
  JsonFormat.printToString in JSON mode (was using formatJson which
  introduced illegal newlines inside string values)
- deploy-contract defaults token-id to empty string (matching REPL behavior)
  and origin-energy-limit to 1 (TRON requires > 0)
- add --force support for reset-wallet and clear-wallet-keystore
- de-interactivize standard CLI transaction signing with permission-id support
- align wallet command JSON schema with success/data envelope
- add standard CLI change-password command and QA coverage
- improve QA command counting, skip reasons, and stale result cleanup
- add --case support to qa/run.sh for targeted reruns
- strengthen transaction QA side-effect checks
- make send-coin JSON return txid and verify send-coin-balance via tx receipt fallback
- update QA reports and fix report documentation
@3for
Copy link
Copy Markdown

3for commented Apr 7, 2026

Right now the output json does not implement the unified envelope as described in the PR summary. Only success() emits {"success": true, "data": ...}; result() emits {success, message}, and printMessage()/raw()/keyValue() bypass the envelope entirely. This affects real commands, not just edge cases: get-account still uses printMessage(), register-wallet uses raw(), and many transaction/contract commands use result(). In its current state, JSON mode does not provide a stable machine-parseable contract.

  • JSON output envelope: All commands produce {"success":true,"data":...} or {"success":false,"error":"..."} in JSON mode, with stdout/stderr
    suppression to guarantee machine-parseable output.

@3for
Copy link
Copy Markdown

3for commented Apr 7, 2026

It says the active wallet config is stored at Wallet/active_wallet.conf, but the implementation actually uses Wallet/.active-wallet (ActiveWalletConfig.java (line 15)).

  • Active wallet management: Persistent active wallet selection via set-active-wallet / list-wallets commands, stored in
    Wallet/active_wallet.conf.

CommandCapture cap = new CommandCapture();
cap.startCapture();
try {
String[] cliArgs = {"--network", network, "--mnemonic", mnemonic, cmdName};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing the private key / mnemonic as process arguments is a serious security issue. In QARunner, values are constructed into CLI args like --private-key <key> and --mnemonic <words>, which makes them visible via process listings such as ps on Linux/macOS. That is not acceptable on shared machines or in CI environments. These secrets should be passed via environment variables or stdin instead of command-line arguments. (QARunner.java:130, 144, 301)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think the ps exposure concern applies here. QARunner does not spawn a subprocess with --private-key/--mnemonic; it passes an in-memory String[] directly into GlobalOptions.parse() within the same JVM. That said, this QA path is stale and should be cleaned up separately because it still assumes old global auth flags.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gummy789j Agreed. QARunner does not expose these values via ps. However, from a secret-handling perspective, this approach is still not ideal: both values are kept as Strings in process memory and could be exposed via heap dumps, crash artifacts, or debugging tools.

Also, it looks like the entire QARunner.java file was newly introduced in this PR. Since it’s described as “this QA path is stale”, would it make sense to clean it up as part of this PR?

Additionally, for external calls like qa/config.sh that run java -jar ... --private-key/--mnemonic, there is still a real risk of leaking secrets via ps.

Repro steps

  1. In terminal A, start a polling ps watcher:

while true; do
ps axww -o pid=,command= | grep '[w]allet-cli.jar' | grep 'import-wallet'
sleep 0.05
done

  1. In terminal B, run:

bash qa/run.sh verify

Then paste the private key when prompted:


$ bash qa/run.sh verify
TRON_TEST_APIKEY not set. Please enter your Nile testnet private key:
*****THE_ACTUAL_PRIVATE_KEY******
TRON_TEST_MNEMONIC not set (optional). Mnemonic tests will be skipped.
=== Wallet CLI QA — Mode: verify, Network: nile ===

Building wallet-cli...
Build complete.

Phase 1: Setup & connectivity check...
✓ nile connectivity OK
Standard CLI commands: 118

.........

  1. You can observe multiple entries with the private key in terminal A:

15456 java -jar build/libs/wallet-cli.jar --network nile import-wallet --private-key *****THE_ACTUAL_PRIVATE_KEY******

The same applies if TRON_TEST_MNEMONIC is set — the mnemonic can also be observed in ps output.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that String-based in-memory secret handling is not ideal. For QARunner, though, this is a local, short-lived QA helper running in the same JVM, so I do not view it as the same severity as the external argv/ps exposure.

The concrete reproducible issue here was the QA shell path passing secrets to an external java -jar ... process, and that is fixed now. I am not reworking QARunner’s internal in-memory secret handling in this PR because that would be a broader QA/auth handoff redesign rather than a localized fix.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gummy789j This leaves qa/run.sh java-verify on a stale code path. QARunner still builds --private-key / --mnemonic global args, but GlobalOptions no longer supports those flags. In practice, the Java-side verifier is no longer exercising the intended authenticated CLI flows, so its results are unreliable as regression coverage. This is not just dead code: it creates a false sense of QA coverage for the current standard CLI contract.

Reproduction Steps

export TRON_TEST_APIKEY=<your test private key>
export TRON_NETWORK=nile

Run:

$ java -cp build/libs/wallet-cli.jar org.tron.qa.QARunner baseline qa/baseline
=== QA Baseline Capture ===
Network: nile
Output dir: qa/baseline
Commands: 118

  Capturing: get-address... OK
  Capturing: get-balance... OK
  Capturing: current-network... OK
  Capturing: get-block... OK
  Capturing: get-chain-parameters... OK
  Capturing: get-bandwidth-prices... OK
  Capturing: get-energy-prices... OK
  Capturing: get-memo-fee... OK
  Capturing: get-next-maintenance-time... OK
  Capturing: list-nodes... OK
  Capturing: list-witnesses... OK
  Capturing: list-asset-issue... OK
  Capturing: list-proposals... OK
  Capturing: list-exchanges... OK
  Capturing: get-market-pair-list... OK

Baseline capture complete: 15 captured, 0 skipped

Although it prints OK, this does not mean the commands actually succeeded.
In captureBaseline(), the code prints OK unconditionally ([QARunner.java:124]), without checking whether the captured output is a success result or an error.

Root Cause

The issue originates from this line:

String[] cliArgs = {"--network", network, "--private-key", privateKey, cmdName};

([QARunner.java:130])

However, GlobalOptions does not support the global flag --private-key ([GlobalOptions.java:48]).

As a result, the parsed arguments are not interpreted as “run get-address with a private key”, but instead:

  • command = null
  • commandArgs = ["--private-key", "<privateKey>", "get-address"]

Then the runner executes:

CommandDefinition cmd = registry.lookup(cmdName);

Here, cmdName is null, which eventually triggers:

nameOrAlias.toLowerCase()

inside lookup().

Verification

You can verify this by inspecting the captured baseline files:

sed -n '1,40p' qa/baseline/get-address.json
sed -n '1,40p' qa/baseline/current-network.json

Example outputs:

{
  "command": "get-address",
  "text_stdout": "User defined config file doesn't exists, use default config file in jar\nError: Cannot invoke \"String.toLowerCase()\" because \"nameOrAlias\" is null\n",
  "text_stderr": "",
  "json_stdout": "Error: Cannot invoke \"String.toLowerCase()\" because \"nameOrAlias\" is null\n",
  "json_stderr": ""
}
"text_stdout": "Error: Cannot invoke \"String.toLowerCase()\" because \"nameOrAlias\" is null\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by retirement rather than rehabilitation. QARunner now only keeps the supported list helper; the stale baseline / verify modes have been retired and fail with a clear unsupported/deprecation message, and qa/run.sh java-verify now does the same instead of pretending to provide current regression coverage.

Comment on lines +85 to +89
byte[] priKey = ByteArray.fromHexString(opts.getString("private-key"));
String walletName = opts.has("name") ? opts.getString("name") : "mywallet";

ECKey ecKey = ECKey.fromPrivate(priKey);
WalletFile walletFile = Wallet.createStandard(passwd, ecKey);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Arrays.fill(priKey, (byte) 0) here, so the private key bytes remain in memory until GC, which unnecessarily increases secret exposure.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

@gummy789j
Copy link
Copy Markdown

Right now the output json does not implement the unified envelope as described in the PR summary. Only success() emits {"success": true, "data": ...}; result() emits {success, message}, and printMessage()/raw()/keyValue() bypass the envelope entirely. This affects real commands, not just edge cases: get-account still uses printMessage(), register-wallet uses raw(), and many transaction/contract commands use result(). In its current state, JSON mode does not provide a stable machine-parseable contract.

  • JSON output envelope: All commands produce {"success":true,"data":...} or {"success":false,"error":"..."} in JSON mode, with stdout/stderr
    suppression to guarantee machine-parseable output.

fixed, still working on rest of comments.

Wipe temporary secret buffers in the standard CLI import-wallet path after
keystore creation.

- add try/finally around private-key import flow
- clear private key bytes with Arrays.fill(priKey, (byte) 0)
- clear derived password bytes after use for consistency with existing secret handling
@gummy789j
Copy link
Copy Markdown

  • Active wallet management: Persistent active wallet selection via set-active-wallet / list-wallets commands, stored in
    Wallet/active_wallet.conf.

Good catch. The implementation uses Wallet/.active-wallet; the PR description will be updated to match the actual behavior.

Copy link
Copy Markdown
Contributor

@lxcmyf lxcmyf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Code Review

Reviewed the new Standard CLI mode implementation. Found 6 critical, 4 high, and 4 medium severity issues. Key concerns:

  1. Fund Safety: register-wallet doesn't return mnemonic; --network silently drops missing values; auto-confirm stdin bypasses safety prompts
  2. Security: Password/key byte arrays not zeroed after use; MASTER_PASSWORD bypasses password strength checks
  3. Correctness: System.exit() in OutputFormatter bypasses finally cleanup and makes code untestable
  4. Silent Failures: ActiveWalletConfig swallows all exceptions; authenticate() fails silently with no feedback

See inline comments for details.

ActiveWalletConfig.setActiveAddress(address);
out.raw("Register a wallet successful, keystore file name is " + keystoreName);
} else {
out.error("register_failed", "Register wallet failed");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical - Fund Safety] register-wallet only outputs the keystore filename. The mnemonic phrase generated by wrapper.registerWallet() is never returned to the user.

If the user loses the keystore file, they cannot recover the wallet. For a cryptocurrency wallet, the mnemonic must be surfaced in both text and JSON output so the user can back it up.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not changing this in the current PR. The standard CLI currently matches the existing REPL behavior by writing the keystore and encrypted mnemonic artifacts rather than printing the mnemonic to stdout/JSON. Changing that would be a product and secret-handling behavior change, so I would rather scope it separately.


/** Print an error for usage mistakes and exit with code 2. */
public void usageError(String message, CommandDefinition cmd) {
if (mode == OutputMode.JSON) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical - Correctness] System.exit(1) here terminates the JVM before the finally block in StandardCliRunner.execute() can restore System.in, System.out, and System.err.

In JSON mode, stdout/stderr are redirected to /dev/null — the finally cleanup at StandardCliRunner:107-113 is bypassed. Also makes the code completely untestable.

Same issue in usageError() (line 175) and result() (line 105).

Suggestion: Throw a CliExitException(int exitCode) instead. Only call System.exit() once at the main() entry point.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. OutputFormatter no longer calls System.exit(). It now signals abort via CliAbortException, StandardCliRunner.execute() maps that to the final exit code, and stream restoration stays in finally. I also added a focused regression test for the in-process runner path.

}

// Load specific wallet file and authenticate
byte[] password = StringUtils.char2Byte(envPwd.toCharArray());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical - Security] The password byte array is never zeroed after use. If checkPassword() throws or the method exits early, password bytes remain in memory.

Suggestion: Wrap lines 150-161 in a try/finally that calls Arrays.fill(password, (byte) 0) on failure paths.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The auto-auth password handling is now wrapped in try/finally, and the temporary byte[] is cleared with Arrays.fill(password, (byte) 0) after use.

if (i + 1 < args.length) opts.output = args[++i];
break;
case "--network":
if (i + 1 < args.length) opts.network = args[++i];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical - Fund Safety] If --network is the last argument (no value follows), it is silently ignored and the default network is used.

Worse: wallet-cli --network send-coin --to TXyz --amount 100 consumes send-coin as the network name, leaving the actual command as null.

Running a transaction on the wrong network (mainnet vs testnet) could mean real money lost.

Suggestion: Throw IllegalArgumentException when the required value for --output, --network, --wallet, or --grpc-endpoint is missing.

Same issue on lines 60, 66, 69.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Global option parsing is now strict for --output, --network, --wallet, and --grpc-endpoint: missing values fail fast, and --network / --output also validate allowed values so command tokens are no longer consumed as option values.

// "1\n" — wallet file selection (choose first)
// "y\n" — additional signing confirmations
// Repeated to cover multiple rounds of signing prompts.
String autoInput = "y\n1\ny\ny\n1\ny\ny\n1\ny\ny\n";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical - Fund Safety] This hardcoded auto-confirm stream blindly answers "y" to all interactive prompts and always selects wallet #1.

  1. Auto-confirms dangerous operations (e.g., "Are you sure you want to delete this wallet?").
  2. Users with multiple wallets cannot control which one signs the transaction.
  3. If the legacy code adds/removes a prompt, this silently feeds wrong answers.

Suggestion: Short-circuit interactive prompts at a higher level (e.g., the PERMISSION_ID_OVERRIDE pattern) rather than faking stdin.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the risk. I am not changing this in the current PR because removing that compatibility path safely would require reworking shared REPL / legacy interaction behavior, and I do not want to take that larger behavioral change as part of this review-fix scope.


public void add(CommandDefinition cmd) {
commands.put(cmd.getName(), cmd);
aliasToName.put(cmd.getName(), cmd.getName());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Primary name is stored as-is here, but aliases are lowercased on line 17. lookup() normalizes input to lowercase.

If a command name ever contains uppercase letters, lookup would fail to match the primary name entry.

Suggestion: Normalize here too: aliasToName.put(cmd.getName().toLowerCase(), cmd.getName());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Primary command names are now normalized to lowercase when registered, so lookup stays consistent with the existing lowercase alias normalization.

while (i < args.length) {
String token = args[i];

if ("-m".equals(token)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Hard-coded -m"multi" mapping leaks a domain-specific concern into the general-purpose parser. All commands implicitly accept -m, even those that don't use multi-signature mode.

If a command wanted -m for --memo, it would collide.

Suggestion: Add a shortFlag field to OptionDef so individual commands declare their own short flags.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. -m is no longer treated as a global shorthand for every command. The parser now only accepts -m for commands that actually declare the multi option.

WalletApi.setApiCli(WalletApi.initApiCli());
break;
default:
formatter.usageError("Unknown network: " + network
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The default case calls formatter.usageError() which calls System.exit(2). But there is no return or throw statement after it. If System.exit() is ever replaced with an exception (per the OutputFormatter issue), execution would continue with no network configured.

Also: all four cases share identical WalletApi.setApiCli(WalletApi.initApiCli()) — consider extracting after the switch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as part of the abort-handling refactor. usageError() now throws a controlled abort exception instead of exiting the JVM, so execution does not continue past this branch.

public boolean isVerbose() { return verbose; }
public String getCommand() { return command; }
public String[] getCommandArgs() { return commandArgs; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Returns the internal String[] reference directly. Callers can mutate the array and corrupt internal state.

Suggestion: return Arrays.copyOf(commandArgs, commandArgs.length);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. getCommandArgs() now returns a defensive copy instead of exposing the internal array directly.

return null;
}
try (FileReader reader = new FileReader(configFile)) {
Map map = gson.fromJson(reader, Map.class);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Raw type Map — if the JSON contains {"address": 12345} (number instead of string), the (String) cast on line 35 throws ClassCastException, which is then swallowed by the catch block.

Suggestion: Use Map<String, Object> and validate the type before casting.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The active-wallet config read path now uses typed validation instead of a raw cast, so invalid JSON types are rejected explicitly instead of relying on a swallowed ClassCastException.

Comment on lines +65 to +70
case "--wallet":
opts.wallet = requireValue(args, ++i, "--wallet");
break;
case "--grpc-endpoint":
opts.grpcEndpoint = requireValue(args, ++i, "--grpc-endpoint");
break;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--wallet and --grpc-endpoint are dead global flags. They are documented in help and parsed into GlobalOptions, but StandardCliRunner never consumes them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. StandardCliRunner now consumes both global flags: --wallet is used to resolve the auto-auth keystore target, and --grpc-endpoint overrides the ApiClient for the current run.

@3for
Copy link
Copy Markdown

3for commented Apr 9, 2026

Run ./gradlew test failed with:

org.tron.walletcli.cli.StandardCliRunnerTest > missingWalletDirectoryPrintsAutoLoginSkipInfoInTextMode FAILED
    java.lang.AssertionError at StandardCliRunnerTest.java:155

return;
}

String activeAddress = ActiveWalletConfig.getActiveAddressStrict();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list-wallet now uses getActiveAddressStrict(), so a malformed Wallet/.active-wallet prevents the command from listing any keystores. Since set-active-wallet requires an address or name, this also removes the main in-CLI recovery path for repairing wallet selection. list-wallet should treat an unreadable active-wallet config as "no active wallet" and still enumerate the available wallets.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. list-wallet now uses the lenient active-wallet read path instead of the strict one, so a malformed Wallet/.active-wallet is treated as no active wallet and the command still enumerates the available keystores.

}
File targetFile = ActiveWalletConfig.findWalletFileByAddress(activeAddress);
if (targetFile == null) {
throw new IllegalStateException(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

authenticate() runs before every command and now throws if .active-wallet points at a missing keystore. That means even wallet-independent commands such as current-network, list-witnesses, or recovery commands like import-wallet fail with execution_error as soon as the selected file is moved or deleted. Auto-login should be skipped or deferred for commands that do not require an authenticated wallet.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The standard CLI no longer runs auto-login unconditionally before every command. It now applies command-level auto-auth policy, so wallet-independent / recovery commands skip auto-login while wallet-required commands still authenticate deterministically.

@3for
Copy link
Copy Markdown

3for commented Apr 9, 2026

trigger-constant-contract still bypasses OutputFormatter, so --output json does not guarantee a machine-parseable envelope for this command. The handler just calls wrapper.callContract(...) and returns without emitting via out.*; in JSON mode, runner-wide stdout/stderr suppression will swallow the underlying direct prints instead of converting them to JSON. That means this command can produce empty output in JSON mode, which directly contradicts the summary’s “all commands produce {success,data} / {success,error}” claim.

Reproduction

Run:

java -jar build/libs/wallet-cli.jar \
  --output json \
  --network nile \
  trigger-constant-contract \
  --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf \
  --method "balanceOf(address)" \
  --params "\"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf\""

Result:

  • The process exits
  • No JSON output is printed in the terminal
  • stdout is empty

Additionally the QA suite still does not implement the “semantic comparison for format-independent validation” described in the summary. check_json_text_parity() validates that JSON parses, that the envelope shape looks right, and that text output is non-empty, but it never compares the text meaningfully against the JSON payload. On top of that, trigger-constant-contract is only smoke-tested in text mode, so the exact JSON-mode regression above is not covered.

Right now the output json does not implement the unified envelope as described in the PR summary. Only success() emits {"success": true, "data": ...}; result() emits {success, message}, and printMessage()/raw()/keyValue() bypass the envelope entirely. This affects real commands, not just edge cases: get-account still uses printMessage(), register-wallet uses raw(), and many transaction/contract commands use result(). In its current state, JSON mode does not provide a stable machine-parseable contract.

  • JSON output envelope: All commands produce {"success":true,"data":...} or {"success":false,"error":"..."} in JSON mode, with stdout/stderr
    suppression to guarantee machine-parseable output.

fixed, still working on rest of comments.

Comment on lines +9 to +13
public class InteractiveSession {

private final Object clientInstance;

public InteractiveSession(Object clientInstance) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused code — can the entire file be removed?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. InteractiveSession was unused, and I deleted it as part of retiring the stale Java-side QA verification path.

* check_json_text_parity and filter_noise functions, used by
* QARunner for Java-side verification.
*/
public class TextSemanticParser {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused code - should be used in runVerification() (QARunner.java (line 175) )?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed this instead of wiring it back in. The supported QA paths are now qa/run.sh verify (optionally with --query-batch) and QARunner list; I do not want to reintroduce a second unused semantic/parser path in Java.

public static boolean isNumericallyEquivalent(String sunValue, String trxValue) {
try {
long sun = Long.parseLong(sunValue.replaceAll("[^0-9]", ""));
double trx = Double.parseDouble(trxValue.replaceAll("[^0-9.]", ""));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although this is currently dead code, it's still worth noting that using floating-point for SUN/TRX conversion introduces precision issues.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I removed TextSemanticParser entirely in this cleanup, so this floating-point precision issue no longer exists in the codebase.

- add command-level auto-auth policy for standard CLI
- keep list-wallet usable when active wallet config is malformed
- retire stale QARunner baseline/verify modes
- remove unused QA helper code
- fix remaining parser/config review follow-ups
.mapToLong(org.tron.trident.proto.Response.ChainParameters.ChainParameter::getValue)
.findFirst()
.orElse(420L);
long feeLimit = (long) (energyFee * energyUsed * 1.2);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a potential long overflow issue.

energyFee * energyUsed is a long × long multiplication. When the product exceeds Long.MAX_VALUE, it overflows to a negative value, leading to an incorrect fee limit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

The fee-limit calculation no longer uses unchecked long * long or floating-point arithmetic. I moved it to a shared helper (WalletApiWrapper.computeBufferedFeeLimit) that uses Math.multiplyExact / Math.addExact with a 20% integer buffer. Both the standard CLI and the legacy REPL transfer-USDT path now use the same exact-arithmetic calculation, and overflow fails fast instead of silently wrapping.

Comment on lines +331 to +334
char[] envPassword = resolveEnvPassword(System.getenv("MASTER_PASSWORD"), checkStrength);
if (envPassword != null) {
return envPassword;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Utils.inputPassword() globally prioritizes reading from the MASTER_PASSWORD environment variable.

As a result, if a user runs the REPL interactive mode in a terminal where MASTER_PASSWORD is set, any password prompt that goes through inputPassword() will be automatically satisfied by the environment variable, leaving no opportunity for manual input.

Additionally, when an interactive command enforces strong password validation, if MASTER_PASSWORD does not meet the policy, the command will fail immediately instead of prompting the user again for input.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Utils.inputPassword() no longer unconditionally prioritizes MASTER_PASSWORD. The env-password fallback is now enabled only within the standard CLI runner scope, so non-interactive standard CLI behavior is preserved while the original interactive REPL path goes back to prompt-first behavior instead of being silently overridden by the environment.

Comment on lines +124 to +130
byte[] passwd = org.tron.keystore.StringUtils.char2Byte(
envPassword.toCharArray());
List<String> words = Arrays.asList(
opts.getString("mnemonic").split("\\s+"));
String walletName = opts.has("name") ? opts.getString("name") : "mywallet";

byte[] priKey = MnemonicUtils.getPrivateKeyFromMnemonic(words);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

passwd is not cleared, and priKey is not cleared at the appropriate point. It should follow the approach used in import-wallet (lines 102–104), clearing sensitive data in a try/finally block:

Arrays.fill(priKey, (byte) 0);
Arrays.fill(passwd, (byte) 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. import-wallet-by-mnemonic now uses a try/finally cleanup path so both priKey and passwd are cleared on all exit paths, including exceptions.

Comment on lines +55 to +56
char[] password = envPassword.toCharArray();
String keystoreName = wrapper.registerWallet(password, wordCount);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A try/finally block should be added here to clear the char[] password, since wrapper.registerWallet clears a different variable (byte[] passwd).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. register-wallet now wraps the local char[] password in a try/finally and clears it after wrapper.registerWallet(...) returns or throws. This is separate from the wrapper's internal byte[] cleanup.

- route trigger-constant-contract through OutputFormatter so --output json always emits an envelope
- move constant-call normalization into WalletApi to avoid duplicated logic in ContractCommands
- add typed command errors for trigger-constant-contract, gas-free-info, and gas-free-trace
- fix gas-free-info / gas-free-trace standard CLI error envelopes
- return auth_required instead of false success for tronlink-multi-sign auth failures
- extend QA coverage for trigger-constant-contract json parity
- fix gas-free-trace QA case to use --id in shell and batch runner
- strengthen semantic parity checks for text/json validation
@gummy789j
Copy link
Copy Markdown

trigger-constant-contract still bypasses OutputFormatter, so --output json does not guarantee a machine-parseable envelope for this command. The handler just calls wrapper.callContract(...) and returns without emitting via out.*; in JSON mode, runner-wide stdout/stderr suppression will swallow the underlying direct prints instead of converting them to JSON. That means this command can produce empty output in JSON mode, which directly contradicts the summary’s “all commands produce {success,data} / {success,error}” claim.

Reproduction

Run:

java -jar build/libs/wallet-cli.jar \
  --output json \
  --network nile \
  trigger-constant-contract \
  --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf \
  --method "balanceOf(address)" \
  --params "\"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf\""

Result:

  • The process exits
  • No JSON output is printed in the terminal
  • stdout is empty

Additionally the QA suite still does not implement the “semantic comparison for format-independent validation” described in the summary. check_json_text_parity() validates that JSON parses, that the envelope shape looks right, and that text output is non-empty, but it never compares the text meaningfully against the JSON payload. On top of that, trigger-constant-contract is only smoke-tested in text mode, so the exact JSON-mode regression above is not covered.

Confirmed and fixed.

trigger-constant-contract was still bypassing OutputFormatter, so in --output json mode the legacy direct-print path could be swallowed by the runner’s stdout/stderr suppression and produce empty stdout. That command now goes through the standard CLI output path and emits a proper JSON envelope instead of relying on direct prints.

I also addressed the QA gap called out here:

  • check_json_text_parity() now performs semantic comparison when the text side contains structured payloads, instead of only checking parseability / envelope shape / non-empty text
  • trigger-constant-contract now has text + JSON parity coverage rather than text-only smoke coverage

Verified with:

  • bash qa/run.sh verify --case trigger-constant-contract

This now passes, and the JSON-mode empty-output regression is no longer reproducible.

@gummy789j
Copy link
Copy Markdown

Run ./gradlew test failed with:

org.tron.walletcli.cli.StandardCliRunnerTest > missingWalletDirectoryPrintsAutoLoginSkipInfoInTextMode FAILED
    java.lang.AssertionError at StandardCliRunnerTest.java:155

Confirmed and fixed.

Comment on lines +108 to +113
if (def != null && def.getType() == OptionDef.Type.BOOLEAN) {
// If next arg doesn't look like a flag value, treat as boolean flag
if (i + 1 >= args.length || args[i + 1].startsWith("--") || args[i + 1].startsWith("-")) {
isBooleanFlag = true;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CommandDefinition.parseArgs() is too permissive when handling BOOLEAN options with a “flag without value”.

The current implementation treats any following token that starts with - as the next option. As a result, if a boolean option is explicitly given a value that starts with - (such as -1), it will be incorrectly parsed as true, and the token will be left for the next round, eventually triggering an Unexpected argument.

This does not affect typical numeric parameters such as --amount -1, but it makes the parsing behavior for BOOLEAN options less robust.

It is recommended to either:

  • Only treat tokens with a -- prefix as the next long option, or
  • Enforce that BOOLEAN options support only two forms: a valueless flag, or explicit true|false, and tighten the parsing logic accordingly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. BOOLEAN options now accept only --flag or explicit boolean values (true/false/1/0/yes/no). Values like -1 are no longer treated as an implicit valueless flag and now fail with a clear usage error. I also added a focused parser test covering this case.

- route trigger-constant-contract through OutputFormatter and return stable JSON envelopes
- move constant-call normalization into shared WalletApi/WalletApiWrapper logic
- add typed command-error handling for trigger-constant-contract, gas-free-info, and gas-free-trace
- fix tronlink-multi-sign auth failures to return an error envelope instead of false success
- scope MASTER_PASSWORD fallback to standard CLI so REPL password prompts keep legacy behavior
- harden transfer-usdt fee-limit calculation with exact arithmetic and overflow protection in both CLI and REPL
- clear wallet command sensitive buffers with try/finally cleanup
- tighten BOOLEAN option parsing to avoid implicit flag/value ambiguity
- retire stale Java QA verification paths and fix QA semantic-parity / query-runner regressions
- extend tests and QA coverage for JSON parity and command error handling
Comment on lines +117 to +121
// Determine whether this is a boolean flag (no following value)
boolean isBooleanFlag = i + 1 >= args.length || args[i + 1].startsWith("--");
if (isBooleanFlag) {
values.put(key, "true");
i++;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CommandDefinition.parseArgs() treats non-boolean options without a value as a flag and assigns "true", effectively bypassing the “missing required option” check and deferring what should be a usage error into a type error or invalid address error.

For example, --contract --method balanceOf(address) ends up parsing contract=true instead of immediately reporting a missing value for --contract. This then leads to secondary errors in ParsedOptions.java (line 38) / (line 68).

Comment on lines +236 to +239
File walletDir = resolveWalletDir();
if (!walletDir.exists() || !walletDir.isDirectory()) {
formatter.info("No wallet directory found — skipping auto-login");
return; // No wallet — commands that need auth will fail gracefully
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even when --wallet explicitly points to a keystore, it still doesn’t work in a “clean directory”.

authenticate() first requires the local Wallet/ directory to exist and contain at least one .json file before it even checks resolveAuthenticationWalletFile->globalOpts.getWallet(). This means that running:

wallet-cli --wallet /abs/path/to/keystore.json get-address

will skip auto-login if the current working directory has no Wallet/, instead of using the user-provided keystore file.

Comment on lines 138 to 161
byte[] passwd = char2Byte(password);

WalletFile walletFile = WalletApi.CreateWalletFile(passwd, wordsNumber);
nameWallet(walletFile, false);
clear(passwd);

String keystoreName = WalletApi.store2Keystore(walletFile);
logout();
return keystoreName;
}

public String importWallet(char[] password, byte[] priKey, List<String> mnemonic) throws CipherException, IOException {
if (!WalletApi.passwordValid(password)) {
return null;
}
if (!WalletApi.priKeyValid(priKey)) {
return null;
}

byte[] passwd = char2Byte(password);

WalletFile walletFile = WalletApi.CreateWalletFile(passwd, priKey, mnemonic);
nameWallet(walletFile, false);
clear(passwd);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearing byte[] passwd only on the success path is insufficient. If registerWallet() / importWallet() throws an exception in WalletApi.CreateWalletFile(...) or nameWallet(...), passwd will remain in memory until GC. It’s still recommended to clear it in a try/finally block.

Comment on lines +99 to +112
wrapper.viewTransactionHistory();
out.result(true, "Transaction history completed", "Transaction history failed");
})
.build());
}

private static void registerViewBackupRecords(CommandRegistry registry) {
registry.add(CommandDefinition.builder()
.name("view-backup-records")
.aliases("viewbackuprecords")
.description("View backup records")
.handler((opts, wrapper, out) -> {
wrapper.viewBackupRecords();
out.result(true, "Backup records completed", "Backup records failed");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

view-transaction-history and view-backup-records falsely report success in the CLI when not logged in.

The handlers unconditionally call out.result(true, ...), while the wrapper only prints a warning and returns early. In JSON mode, the warning is suppressed, so the caller ends up seeing success: true.

See WalletApiWrapper.java (line 2056-2058) and (line 2067-2069).

Comment on lines +78 to +85
if (!args[i].startsWith("--")) {
opts.command = args[i].toLowerCase();
commandFound = true;
} else {
// Unknown global flag — treat as start of command args
remaining.add(args[i]);
commandFound = true;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GlobalOptions.parse() silently treats unknown global flags as the start of command arguments instead of rejecting them. For an input like --outputt json get-balance, opts.command remains null while the unknown flag is pushed into commandArgs, which later drives StandardCliRunner into a null command lookup path instead of producing a clear “unknown global option: --outputt” usage error. This is misleading for users and can surface as a downstream execution error rather than a parser-level diagnostic.

Comment on lines +68 to +76
String[] parts = votesStr.trim().split("\\s+");
if (parts.length % 2 != 0) {
out.usageError("Votes must be pairs of 'address count'", null);
return;
}
HashMap<String, String> witness = new HashMap<String, String>();
for (int i = 0; i < parts.length; i += 2) {
witness.put(parts[i], parts[i + 1]);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It only validates that --votes contains an even number of tokens, but does not validate each vote count at the CLI boundary. As a result, malformed counts such as non-numeric values abc are deferred to lower layers (Long.parseLong(...) in WalletApi), and negative/zero counts are only rejected on the multi=true path. This should be normalized into a parser-level usage error with explicit “count must be a positive integer” feedback.

private final String code;

public CommandErrorException(String code, String message) {
super(message);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CommandErrorException is being used as a structured command-flow exception, similar to CliAbortException, but it still pays the full fillInStackTrace() cost via super(message). Since callers immediately catch it and convert it into structured CLI output, the stack trace is not used for diagnostics. Consider disabling writable stack traces here as well, e.g. super(message, null, true, false), to keep the exception aligned with its control-flow role.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants