Skip to content

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

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

feat: add standard CLI mode with JSON output for AI agents and automation#921
parsoncryptoai wants to merge 33 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.

Comment on lines +397 to +398
registry.add(noAuthCommand()
.name("reset-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.

reset-wallet, when configured as a noAuthCommand(), becomes a highly destructive local cleanup command that can delete all wallets with --force without requiring authentication.

With --force, it directly calls resetWalletForCli(force) in WalletApiWrapper.java (line 2356), which eventually invokes ClearWalletUtils.deleteFilesQuiet(filePaths) in WalletApiWrapper.java (line 2410) to delete local wallet/mnemonic files.

Comment on lines +2825 to +2827
String privateKey = Hex.toHexString(credentials.getPair().getPrivateKey());
String ledgerPath = getLedgerPath(passwd, wf);
boolean isLedgerFile = wf.getName().contains("Ledger");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

gasFreeTransferInternal() currently converts the private key into a non-clearable hex string, while the finally block only clears the password and not this copy of the private key. Worse, the string is created before checking isLedgerFile, so even the Ledger path unnecessarily retains an extra copy of the private key.

It is recommended to defer generating local signing material until the non-Ledger branch, keep it as a clearable byte[] where possible, and ensure it is cleared in a finally block.

Comment on lines +1437 to +1461
if (multi) {
if (!DecodeUtil.addressValid(owner) || !DecodeUtil.addressValid(to)) {
return false;
}
if (Arrays.equals(to, owner)) {
return false;
}
if (amount <= 0) {
return false;
}
Response.Account account = queryAccount(owner);
if (account == null) {
recordLastCliOperationError("Failed to query account.");
return false;
}
long balance = account.getBalance();
if (balance < amount) {
return false;
}
if (balance - amount < 200_0000L) {
return false;
}
if (!isControlledForCli(owner)) {
return false;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In the multisig branch of sendCoinForCli, there are still six return false paths that do not call recordLastCliOperationError(), causing the standard CLI to end up showing only the generic SendCoin failed !! without indicating the specific reason. Only the queryAccount(owner) == null case has been updated to record a clear error.

private static Pair<Pair<String, Boolean>, Pair<String, Boolean>> customNodes;
public MultiSignService multiSignService = initMultiSignService();

private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(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.

WalletApi owns a per-instance ScheduledExecutorService (WalletApi.java line 197) and exposes an explicit cleanup() hook to shut it down (WalletApi.java line 4365), but StandardCliRunner never calls wrapper.cleanup() on exit.

This is a lifecycle management gap rather than a guaranteed “one thread leaked per WalletApi construction” issue: the executor may remain idle unless a scheduled task is actually used, but once it is used, there is no shutdown path in the standard CLI.

It would be safer to either create the scheduler lazily only when needed, or ensure StandardCliRunner.execute() calls wrapper.cleanup() in a finally block.

private List<WalletFile> walletList = new ArrayList<>();
@Getter
@Setter
private static ApiClient apiCli = initApiCli();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WalletApi.apiCli is a mutable static client reference updated without synchronization (WalletApi.java lines 188 and 201). This introduces a general concurrency risk if RPC calls race with updateRpcCli()/network switching, since the old client is closed before the shared reference is replaced.

Comment on lines +952 to +954
registry.add(CommandDefinition.builder()
.authPolicy(CommandDefinition.AuthPolicy.REQUIRE)
.name("gas-free-info")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

gas-free-info is currently stricter than comparable query commands: even when --address is provided, it is still hard-coded to AuthPolicy.REQUIRE, and the wrapper also unconditionally requires a logged-in wallet before evaluating the optional address parameter (WalletApiWrapper.java line 2656).

This is inconsistent with commands such as get-balance / estimate-energy, where supplying an address allows no-auth usage.

The fix needs to address both layers:

  • Use authPolicyResolver(opts -> opts.has("address") ? NEVER : REQUIRE) in the command definition
  • Make getGasFreeInfoData(address) require login only when address is omitted

@3for
Copy link
Copy Markdown

3for commented Apr 11, 2026

The project’s cryptographic core paths are still under-tested. There is limited deterministic test coverage for security-sensitive flows such as transaction signing, private-key import, and mnemonic-to-key derivation. Most current tests focus on CLI routing, output envelopes, and wrapper behavior rather than validating known cryptographic vectors end-to-end.

Consider adding fixed test vectors for “private key -> address”, “mnemonic -> private key/address”, and representative signed-transaction cases.

this.walletFile = null;
setLedgerUser(false);
setCredentials(null);
setUnifiedPassword(null);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

logout() already sets unifiedPassword to null, but does not zero out the array contents first; pwdForDeploy is not handled at all. The root issue is that sensitive byte arrays are not properly zeroized.

Comment on lines +62 to +72
public static void setActiveAddress(String address) throws IOException {
File dir = getWalletDir();
if (!dir.exists()) {
dir.mkdirs();
}
File configFile = new File(dir, CONFIG_FILE);
Map<String, String> data = new LinkedHashMap<String, String>();
data.put("address", address);
try (FileWriter writer = new FileWriter(configFile)) {
gson.toJson(data, writer);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Currently, .active-wallet (ActiveWalletConfig.java line 62) and keystore files (WalletApi.java line 501, WalletUtils.java line 117) are created with the process’s default permissions, which ultimately depend on the host environment’s umask. A safer approach is to explicitly set owner-only permissions, e.g., 0600 for files and 0700 for directories.

ByteString constantResult = normalized.getConstantResult(0);
bigInteger = new BigInteger(1, constantResult.toByteArray());
}
return Triple.of(true, energyUsed, bigInteger.longValue());
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When calling BigInteger.longValue(), values exceeding Long.MAX_VALUE are silently truncated to the low 64
bits. Use longValueExact() with explicit overflow handling instead, or keep the BigInteger/string
representation as-is. See: WalletApiWrapper.java:1994, WalletApi.java:3698, and WalletApi.java:3772.

Comment on lines +1238 to +1240
public static String getLastBroadcastTxId() {
return LAST_BROADCAST_TX_ID.get();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LAST_BROADCAST_TX_ID has a “read-only without clearing” issue in the CLI scenario.

In WalletApi.java (line 1238), only getLastBroadcastTxId() is provided; meanwhile, the command layer reads the txid in CommandSupport.java (line 39) and TransactionCommands.java (line 73) without clearing it.

As a result, the txid remains attached to the thread until a subsequent transaction entry explicitly calls remove() in WalletApi.java (line 1094) / (line 1146). This indicates an overly long ThreadLocal lifecycle.

It is recommended to change to consumeLastBroadcastTxId(), similar to consumeLastCliOperationError(), so that reading also clears the value, enabling one-time consumption.

Comment on lines +1939 to +1941
if (!Boolean.TRUE.equals(result.getLeft())) {
throw new CommandErrorException("execution_error", "CallContract 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.

When result.getLeft() is false, a generic CommandErrorException is thrown directly without going through throwIfCliOperationFailed(). If the underlying layer has already set LAST_CLI_OPERATION_ERROR, it will not be consumed here, leaving the error state lingering in the thread.

It is recommended to reuse the unified failure-handling logic in this case.

}
return activeWalletFile.getCanonicalFile().equals(targetWalletFile.getCanonicalFile());
} catch (Exception e) {
return false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

shouldClearActiveWalletForDeletedTarget() swallows all exceptions when determining whether the deleted keystore is the current active wallet and directly returns false, silently downgrading what should be an exposed active-wallet configuration issue into “no cleanup needed”.

}
String privateKeyHex = System.getenv(PRIVATE_KEY_ENV);
if (privateKeyHex == null || privateKeyHex.isEmpty()) {
out.usageError("import-wallet requires " + PRIVATE_KEY_ENV + " in standard CLI mode.", null);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WalletCommands.java (line 99), WalletCommands.java (line 144), and MiscCommands.java (line 65) call out.usageError(...) when parameters or environment variables are missing, but then continue executing business logic. For example:

  • import-wallet proceeds to ByteArray.fromHexString(privateKeyHex)
  • import-wallet-by-mnemonic proceeds to mnemonic.trim().split(...)
  • get-private-key-by-mnemonic proceeds to mnemonicStr.trim().split(...)

These paths currently do not execute only because usageError() eventually throws an exception via recordError(). This ties the correctness of the handler to the side effects of OutputFormatter, rather than explicit local control flow. If usageError() is later changed to “record error without throwing,” these locations will immediately become real runtime issues.

Recommendation:

Add an explicit return; after each out.usageError(...) to complete the guard clause. This ensures the handler remains correct and readable even if the formatter’s exception semantics change in the future.

return 2; // unreachable after usageError()
}

applyPermissionIdOverride(cmd, opts);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

StandardCliRunner sets a global ThreadLocal once via applyPermissionIdOverride(cmd, opts) before command execution. However, multiple handlers also locally call setPermissionIdOverride(permissionId) and clean it up in finally.

This results in “double-setting” the same thread variable. While values are usually the same and no error occurs, the responsibility becomes unclear:

  • If the runner is the unified injection layer, then the repeated set/clear in handlers is redundant.
  • If handlers are meant to control precise scope, then the runner’s pre-injection expands the scope, making permission-id no longer limited to the minimal code path that actually requires signing.

For commands like transfer-usdt, which involve two-stage calls, the handler’s try/finally clearly indicates an intent for scoped control. This further shows that runner-level injection and handler-level injection overlap in design.

Recommendation:

Converge responsibilities by choosing one of the following:

  • Keep runner-level injection and remove duplicate set/clear logic from handlers; or
  • Remove applyPermissionIdOverride() from the runner and let each handler manage permission-id within the minimal required scope.

I recommend the second option, since handlers have better knowledge of where the override is actually needed and can enforce a more precise scope.

References:

  • StandardCliRunner.java (line 99)
  • StandardCliRunner.java (line 230)
  • TransactionCommands.java (line 53, line 141)
  • StakingCommands.java (line 67)
  • ContractCommands.java (line 125)

File externalDir = Files.createTempDirectory("change-password-global-wallet-file").toFile();
File walletFile = new File(externalDir, "alpha.json");
Assert.assertTrue(walletFile.createNewFile());
System.setProperty("user.dir", tempDir.getAbsolutePath());
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These tests switch the wallet root directory by overriding the process-wide global state via System.setProperty("user.dir", ...). The current usage spans multiple test files, making it inherently unsuitable for parallel execution.

Since user.dir is a JVM-wide shared state rather than test-local state, concurrent test execution can lead to nondeterministic failures and hard-to-reproduce flaky tests.

References:

  • StandardCliCommandRoutingTest.java (line 27, line 159)
  • StandardCliRunnerTest.java (line 304)
  • ActiveWalletConfigTest.java (line 61)

PrintStream originalOut = System.out;
ByteArrayOutputStream stdout = new ByteArrayOutputStream();
WalletApi.setCurrentNetwork(NetType.NILE);
System.setOut(new PrintStream(stdout));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tests in the following files:

  • TransactionCommandsTest.java (line 43)
  • StandardCliRunnerTest.java (line 305)
  • ClientMainTest.java (line 16)
  • TransactionUtilsTest.java (line 18)
  • ClearWalletUtilsTest.java (line 20)
  • ActiveWalletConfigTest.java (line 80)

extensively use System.setOut(...) / System.setErr(...) to redirect JVM-global standard streams for capturing output. Similar to the user.dir issue, this is process-wide shared state and inherently unsuitable for parallel execution.

Since System.out / System.err are JVM-wide global objects, concurrent tests can interfere with each other by competing for the same streams, leading to mixed outputs and flaky assertions.

The repository already contains a safer pattern. For example, OutputFormatterTest.java (line 15) injects a PrintStream directly into OutputFormatter, avoiding the need to mutate global streams.

Comment on lines +46 to +58
@Override
public Triple<Boolean, Long, Long> callContractForCli(
byte[] ownerAddress,
byte[] contractAddress,
long callValue,
byte[] data,
long feeLimit,
long tokenValue,
String tokenId,
boolean isConstant,
boolean display,
boolean multi) {
return Triple.of(true, 0L, 0L);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

StandardCliCommandRoutingTest.java (line 46) and (line 95) only verify that legacy methods are not invoked, but do not explicitly prove that the CLI-safe methods are actually called. In contrast, the overrides in StandardCliCommandRoutingTest.java (line 133) and (line 325) already validate input arguments, which inherently serve as proof of invocation.

Comment on lines +400 to +401
return Arrays.copyOf(wallet.getUnifiedPassword(), wallet.getUnifiedPassword().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.

wallet.getUnifiedPassword() repeated getter access on mutable state; prefer caching wallet.getUnifiedPassword() in a local variable before copying. This improves robustness/readability.

Comment on lines +884 to +894
public void sendCoinForCli(byte[] ownerAddress, byte[] toAddress, long amount, boolean multi) {
requireLoggedInWalletForCli();
try {
throwIfCliOperationFailed(
wallet.sendCoinForCli(ownerAddress, toAddress, amount, multi),
"SendCoin failed !!");
} catch (IllegalStateException e) {
throwCliError("execution_error", "SendCoin failed !!", e);
} catch (Exception e) {
throwCliError("execution_error", "SendCoin failed !!", e);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WalletApiWrapper contains many *ForCli methods that repeat the same try/catch boilerplate pattern:

requireLoggedInWalletForCli() → call underlying wallet.xxxForCli(...)throwIfCliOperationFailed(...) → catch IllegalStateException / Exception and convert to throwCliError(...).

Examples:

  • WalletApiWrapper.java (line 884)
  • WalletApiWrapper.java (line 908)
  • WalletApiWrapper.java (line 1250)
  • WalletApiWrapper.java (line 1273)
  • WalletApiWrapper.java (line 1297)
  • WalletApiWrapper.java (line 1897)

Problem

This boilerplate has already spread across dozens of locations, introducing two maintenance costs:

  1. Behavior consistency depends on manual duplication
    As soon as one method slightly deviates from the template (e.g., callContractForCli() in line 1931), inconsistencies arise in exception handling / error consumption.

  2. High cost for future strategy changes
    If CLI error mapping, logging, authentication pre-checks, or LAST_CLI_OPERATION_ERROR consumption semantics need to be updated, changes must be applied across 30+ methods, increasing the risk of omissions.

Recommendation

Extract this pattern into a unified helper, for example:

runCliAction(String failureMessage, CliBooleanAction action)

or:

runCliQuery(...) / runCliMutation(...)

The helper should be responsible for:

  • requireLoggedInWalletForCli()
  • invoking action
  • throwIfCliOperationFailed(...)
  • mapping IllegalStateException / Exception to throwCliError(...)

Then each *ForCli method only needs to forward parameters, e.g.:

public void sendCoinForCli(...) {
  runCliAction("SendCoin failed !!", () -> wallet.sendCoinForCli(...));
}

Comment on lines +1091 to +1094
private boolean processTransactionExtentionForCli(
Response.TransactionExtention transactionExtention, boolean multi)
throws IOException, CipherException, CancelException {
LAST_BROADCAST_TX_ID.remove();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The transaction processing flow in WalletApi has clearly diverged into multiple nearly duplicated implementations, resulting in high maintenance cost and increasing the risk of behavioral drift between CLI and non-CLI paths.

Locations

  • WalletApi.java (line 1037): processTransactionExtention(...)
  • WalletApi.java (line 1091): processTransactionExtentionForCli(...)
  • WalletApi.java (line 1144): processTransactionForCli(...)
  • WalletApi.java (line 1204): processTransaction(...)

Problem

These paths share essentially the same core steps:

  • Clear previous state
  • Validate transaction / contract
  • Validate multi-sign supported contract types
  • Sign
  • Broadcast
  • Record txid / history

The real differences are limited to a few strategy points:

  • Input type: TransactionExtention vs Transaction
  • Error handling: CLI vs non-CLI
  • Different signing/broadcast functions for CLI vs non-CLI
  • Whether to print interactive output

However, these differences are currently implemented via copy-paste rather than a shared skeleton with injected strategies. This has already caused observable drift, for example:

  • CLI path introduces LAST_CLI_OPERATION_ERROR lifecycle management
  • Non-CLI path has more complete console output
  • processTransactionForCli(...) and processTransactionExtentionForCli(...) are themselves highly duplicated

Recommendation

Extract the common flow into a unified skeleton method, and pass in strategy components (error handling, signing function, broadcast function, output behavior) as parameters or strategy branches.

At minimum, merge:

  • processTransactionExtention(...) with processTransactionExtentionForCli(...)
  • processTransaction(...) with processTransactionForCli(...)

This would significantly reduce the need to manually synchronize logic across 3–4 variants and lower the risk of behavioral drift going forward.

long originEnergyLimit, boolean multi)
throws CipherException, IOException, CancelException, IllegalException {
if (wallet == null || !wallet.isLoginState()) {
System.out.println("Warning: updateSetting " + failedHighlight() + ", Please login first !!");
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 are multiple “not logged in” warning messages in WalletApiWrapper that are clearly copy-paste leftovers. The method names have changed, but the error messages still say Warning: updateSetting ....

Locations

  • WalletApiWrapper.java (line 1815): updateEnergyLimit(...)
  • WalletApiWrapper.java (line 1839): clearContractABI(...)
  • WalletApiWrapper.java (line 2081): updateBrokerage(...)
  • WalletApiWrapper.java (line 2136): marketSellAsset(...)
  • WalletApiWrapper.java (line 2165): marketCancelOrder(...)
  • WalletApiWrapper.java (line 2261): lock()
  • WalletApiWrapper.java (line 2273): unlock(...)

Problem

When not logged in, these methods print messages that do not match the current operation. For example, marketSellAsset(), lock(), and unlock() all output Warning: updateSetting ....

This does not break the main flow, but it directly misleads users and troubleshooters. It also indicates that these error messages are maintained via copy-paste, making future drift likely.

Recommendation

Update the messages to reflect the actual operation name. Additionally, consider extracting a unified helper for “Please login first” warnings to avoid similar copy-paste errors in the future.

Comment on lines +13 to +17
public void add(CommandDefinition cmd) {
commands.put(cmd.getName(), cmd);
aliasToName.put(cmd.getName().toLowerCase(), cmd.getName());
for (String alias : cmd.getAliases()) {
aliasToName.put(alias.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.

CommandRegistry.add() currently does not detect conflicts between command names and aliases. Later registrations silently overwrite earlier ones, so the final registry state depends on registration order.

Location

  • CommandRegistry.java (line 12)

Problem

add() currently performs:

  • commands.put(cmd.getName(), cmd)
  • aliasToName.put(cmd.getName().toLowerCase(), cmd.getName())
  • For each alias: aliasToName.put(alias.toLowerCase(), cmd.getName())

This means the following conflicts are not detected:

  • A new command’s alias conflicts with an existing command name
  • A new command’s alias conflicts with an existing alias
  • A new command name conflicts with an existing alias
  • Duplicate registration of the same command name

As a result, the behavior of lookup / suggest / help depends on “who registers last” instead of failing explicitly. For a CLI that centrally registers all commands, this represents a missing architectural constraint at the registry level—uniqueness should be enforced by the registry itself, rather than relying on manual avoidance of conflicts.

Recommendation

Perform normalized conflict checks during add(), and throw an exception when a conflict is detected, including:

  • The conflicting token
  • The command that already owns it
  • The command being registered

Also add test coverage for:

  • alias vs alias
  • alias vs primary name
  • duplicate primary name

Comment on lines +56 to +59
String envPassword = System.getenv("MASTER_PASSWORD");
if (envPassword == null || envPassword.isEmpty()) {
out.error("auth_required",
"Set MASTER_PASSWORD environment variable for non-interactive wallet creation");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

register-wallet / import-wallet / import-wallet-by-mnemonic return auth_required when the MASTER_PASSWORD environment variable is missing. This mixes with the more common “please login first” semantics in the CLI, making the error code boundary unclear.

Locations

  • WalletCommands.java (line 56)
  • WalletCommands.java (line 91)
  • WalletCommands.java (line 136)

Problem

The actual failure reason here is:
“missing required input source (MASTER_PASSWORD) in non-interactive CLI mode”,
rather than “session not authenticated” or “login required for an existing wallet”.

In this repository, auth_required is more commonly used for scenarios like:

  • Please login first !!
  • MASTER_PASSWORD is required for unlock
  • Other operations that require an authenticated session

Using the same error code for register-wallet makes it difficult for callers to distinguish between:

  • Missing environment configuration
  • Login required
  • Missing unlock credentials

Recommendation

To keep error code semantics clear, prefer using a more accurate code, such as:

  • missing_config
  • missing_env
  • or invalid_input

At minimum, for commands like wallet creation/import—which do not require prior authentication but do require environment-provided credentials—avoid reusing the same auth_required semantics as “please login first”.

Comment on lines +140 to +146
if (mode == OutputMode.JSON) {
emitJsonError(current.errorCode, current.errorMessage);
} else {
out.println("Error: " + current.errorMessage);
if (current.usageHelp != null) {
out.println();
out.println(current.usageHelp);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In text mode, error output is currently written to stdout instead of stderr, which goes against common CLI conventions.

Location

  • OutputFormatter.java (line 140)

Problem

In OutputFormatter.flush(), when an error occurs in text mode, it executes:

out.println("Error: " + current.errorMessage);

instead of writing to err. This leads to several architectural issues:

  • Callers cannot easily distinguish between normal output and error output
  • In shell pipelines, redirection, and script integrations, error messages are mixed into stdout
  • This contradicts the class design, which already maintains an err stream
  • info() already uses err (OutputFormatter.java line 221), indicating that a stdout/stderr separation model exists, but error / usageError in text mode do not follow it

Tests have already baked in this behavior:

  • Multiple StandardCliRunnerTest cases explicitly assert that text-mode errors appear in stdout and that stderr is empty
  • Fixing this will require updating the test contract accordingly

Recommendation

  • In text mode, route error and usage/help output to err, and keep stdout for successful results only
  • In JSON mode, continuing to write everything to stdout is acceptable

This aligns better with standard CLI conventions and improves scriptability.


@Test
public void changePasswordUsesGlobalWalletOverrideAsExplicitTarget() throws Exception {
File tempDir = Files.createTempDirectory("change-password-global-wallet").toFile();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The usage of temporary directories in tests is widespread, but most cases lack corresponding cleanup and currently rely on the system’s temp directory reclamation.

Example Locations

  • StandardCliCommandRoutingTest.java (line 155)
  • StandardCliRunnerTest.java (line 300)
  • ActiveWalletConfigTest.java (line 55)

Problem

There are more than twenty usages of Files.createTempDirectory(...) across the repository, but explicit cleanup is only implemented in a few places, for example:

  • StandardCliRunnerTest.java (line 319)
  • ActiveWalletConfigTest.java (line 85)
  • ClearWalletUtilsTest.java (line 32)

Most other tests leave directories behind in the system temp path. While this usually does not affect correctness for a single run, it introduces several maintenance issues:

  • Temp directories accumulate over time in local and CI environments
  • It becomes harder to distinguish which leftover directories belong to the current test when debugging failures
  • Inconsistent test style: some tests clean up manually, others do not

Recommendation

Adopt a consistent strategy:

  • Use @Rule TemporaryFolder or a unified temp helper; or
  • At minimum, add deleteOnExit() or finally-based cleanup to all createTempDirectory usages

Avoid mixing “partial cleanup” and “no cleanup” approaches.

Comment on lines +48 to +49
json.put("private_key", priKeyHex);
out.success("Address: " + addressStr + "\nPrivate Key: " + priKeyHex, json);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

generate-address returns the private key in plaintext in JSON mode. This behavior appears intentional, but there is currently no explicit security warning in the command description, making it easy for automated callers to underestimate the risk.

Locations

  • MiscCommands.java (line 34)
  • MiscCommands.java (line 46)
  • MiscCommands.java (line 49)

Problem

generate-address currently outputs results to both:

  • text message: Address: ... / Private Key: ...
  • JSON data: { "address": "...", "private_key": "..." }

This means that whenever a user enables --output json in scripts, the private key will consistently appear in structured stdout output.

This may not be a bug, since the command is designed to generate an offline address and return the private key. However, it is a highly sensitive interface and fundamentally different from normal query commands. The current description:

"Generate a new address offline"

does not indicate that the output includes a private key. Neither the help text nor command metadata provides any risk warning, making it easy for callers to treat it like a normal address-generation command and inadvertently pipe it into logs, pipelines, or audit systems.

Recommendation

  • At minimum, explicitly warn in the command description or help that the output includes a plaintext private key
  • For additional safety, consider adding a prominent warning in text mode; for JSON mode, document that stdout should not be logged
  • Ideally, also document this as an explicit security constraint in the README / standard CLI contract

Comment on lines +21 to +23
Assert.assertTrue(json.contains("\"success\": true"));
Assert.assertTrue(json.contains("\"data\": {"));
Assert.assertTrue(json.contains("\"message\": \"hello\""));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Many JSON-related tests currently use String.contains(...) to assert serialized output, with assertions written as space-sensitive fragments such as "\"success\": true" or "\"error\": \"usage_error\"". This ties the tests to the current pretty-print format rather than the JSON semantics.

Example Locations

  • OutputFormatterTest.java (line 21)
  • TransactionCommandsTest.java (line 87)
  • StandardCliCommandRoutingTest.java (line 456)

Problem

These assertions depend not only on field presence, but also implicitly on:

  • Gson pretty-print retaining the ": " spacing style
  • Field names and formatting layout remaining unchanged
  • JSON not being compacted or serialized by a different serializer

If the JSON output is later switched to compact mode, a different serializer, or even minor formatting adjustments, many tests will fail despite no semantic change.

From the test intent, they are trying to verify that certain fields and values exist in the JSON. A more robust approach is to parse the JSON first and assert structurally.

Recommendation

Refactor these assertions to:

JsonObject root = JsonParser.parseString(json).getAsJsonObject();

Then assert structurally, e.g.:

  • root.get("success").getAsBoolean()
  • root.get("error").getAsString()
  • root.getAsJsonObject("data").get("txid")

Using contains(...) is still acceptable for checking free-form text messages, but it should not be used for core JSON contract validation.

local args_string="$1"
local substituted
substituted="$(qa_substitute_placeholders "$args_string")"
python3 - <<'PY' "$substituted"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

QA scripts heavily depend on python3, but the entry scripts do not perform a prerequisite availability check. When the environment is missing Python, failures occur mid-execution with obscure shell errors.

Locations

  • qa/run.sh (line 1)
  • qa/task_runner.sh (line 1)
  • qa/lib/cli.sh (line 244, line 275)
  • qa/lib/semantic.sh (line 11)
  • qa/lib/case_resolver.py (line 1)

Problem

There are multiple direct invocations of python3 and usages of #!/usr/bin/env python3 in the QA stack, but qa/run.sh / qa/task_runner.sh do not include a unified check such as:

command -v python3 >/dev/null 2>&1 || { echo "python3 is required"; exit 1; }

As a result, in environments without a Python runtime, users do not receive a clear failure message at entry. Instead, they encounter errors like python3: command not found deep inside helper scripts during execution. For a QA toolchain entry point, this indicates insufficient upfront dependency validation.

Recommendation

Add a require_python3() check at the entry point in qa/run.sh, with a clear error message indicating:

  • python3 is required
  • QA scripts depend on it for JSON / manifest / case parsing

For completeness, you may also check other dependencies such as java, bash, and gradle, but at minimum python3 should fail fast.

Comment on lines +109 to +114
run_task_file_parallel() {
local task_file="$1"
local jobs="$2"
[ -s "$task_file" ] || return 0
xargs -P "$jobs" -n 2 bash "$SCRIPT_DIR/task_runner.sh" < <(tr '|' '\n' < "$task_file")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The QA system currently lacks per-test-case timeout control. If a single case hangs, the entire worker process can remain stuck indefinitely, and in serial phases this can block the whole run.

Locations

  • qa/run.sh (line 109, line 116)
  • qa/task_runner.sh (line 98, line 103)
  • qa/lib/cli.sh (line 403)

Problem

The current execution model is:

  • qa/run.sh uses xargs -P ... bash task_runner.sh to schedule cases in parallel
  • task_runner.sh directly invokes qa_run_raw_capture for each case
  • qa_run_raw_capture directly executes the underlying command

There is no timeout / watchdog / per-case deadline control anywhere in this chain.

As a result, if a command hangs due to network issues, node problems, leftover interactive state, or a stuck subprocess:

  • The worker holds onto a concurrency slot indefinitely
  • Serial phases can block the entire QA run
  • Failures manifest as “the script never finishes” instead of a clear TIMEOUT, making diagnosis difficult

Recommendation

Wrap each test case with a unified timeout, e.g., in task_runner.sh or qa_run_raw_capture:

timeout "${QA_CASE_TIMEOUT:-60s}" ...

On timeout, write the result as FAIL: timeout.

  • Make the timeout configurable via an environment variable
  • Ensure hung cases do not stall the entire QA pipeline

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