Release v4.13.0 - #998
Open
gummy789j wants to merge 121 commits into
Open
Conversation
Code Smells for evm-extension
…asUsed union - eslint.config.js: the scripts/**/*.mjs block never applied — scripts/** is in the global ignores, so `eslint .` is byte-identical with and without it. - evm.ts: #send was a one-line pass-through to #request with a single caller; its doc comment moves to #request, which is what it describes. - TxInfoView.gasUsed: production only ever writes String(...), so the `number` half was kept alive solely by a stale test fixture. Fixture aligned to the shape production emits; the rendered output is unchanged (formatInt already handles decimal strings). Claude-Session: https://claude.ai/code/session_01Reu4W1oqGVqUWkMpAjR9Xo
…network API keys Consolidates the TRON and EVM client adapters onto a single HttpTransport (adapters/outbound/http) and builds the per-network credential pair on top of it. Transport - TronRpcClient and EvmRpcClient accept a NetworkDescriptor or an endpoint config; raw fetch calls, URL joining and timeout wiring now live in one place. - tronweb is handed explicit HttpProviders so it carries the same headers. - TronGridHistoryReader goes through the transport; its failures no longer echo the underlying message. Per-network API key - networks.<id>.apiKeyHeader / .apiKey are readable and writable via `config`. Both must be set before a header is sent, and the header name is validated as an RFC 9110 token so a hand-edited config.yaml cannot smuggle in a second header. - apiKey is write-only on every read surface and forces the 0600 check on config.yaml, which now looks inside `networks` rather than only at top level. - Credentialed requests refuse to follow redirects on both the fetch and the tronweb path, so a redirecting endpoint cannot collect the key. Config surface - A network renders as its configurable fields rather than a bare endpoint, so a new field shows up in every view at once. Listings still trim the endpoint to its host; naming one network reveals it in full. - `config` prints the document as a tree. Nested keys carry no trailing colon -- the ids at that level contain colons themselves. Fixes found while re-testing the above on Nile and Sepolia - EVM transport timeouts report `timeout`, not `rpc_error`, matching TRON and the documented meaning of the code. - --args/--verb/--group/--source are rejected as user-typed flags. They are yargs plumbing keys that must stay in the per-command allowlist, so they worked as undocumented aliases: `contract call --args <addr>` bound into a slot the command has not got and answered 0, a wrong result that reads like a real one. The check runs on the raw tokens, before yargs folds the two sources together, and a meta-test pins that no command declares a field by those names. Startup migration - The gate runs ahead of every surface and stops after upgrading rather than running the command that triggered it. Verified on Nile and Sepolia: reads across every transport path, TRX/TRC20 and ETH transfers confirmed on-chain, the credential observed on the wire on all three paths, and the redirect target receiving nothing once one is configured. Claude-Session: https://claude.ai/code/session_01JSxAgrttq54UacDxykm7gy
The source-of-truth doc it named was deleted in 7a33dcd, leaving the one instruction that tells a reader where the boundary rules live pointing at nothing. Split the reference across what actually holds each part now: the table below it for the boundaries, .dependency-cruiser.cjs for enforcement, machine-interface.md for the JSON contract, and docs/adr/ for the reasoning. Claude-Session: https://claude.ai/code/session_01JSxAgrttq54UacDxykm7gy
5389b00 pointed at ts/docs/adr/ after verifying it existed on disk, which is the wrong test: ts/.gitignore:9 ignores it, so it holds zero tracked files and is not there for anyone who clones. That replaced one dead reference with another. What remains is what the repo actually carries: the table, the dependency-cruiser config that enforces it, and machine-interface.md. Claude-Session: https://claude.ai/code/session_01JSxAgrttq54UacDxykm7gy
…sion feat: evm extension
Extracting `tronTxModeFields` out of the shared `txModeFields` moved --permission-id and --expiration behind an explicit opt-in. The commands that serve two families re-added them in their TRON binding (tx send, contract send/deploy) and the governance writes get them via `governanceTxModeFields`, but the 18 single-family TRON writes kept spreading `txModeFields` alone and silently lost both flags. The effect was not cosmetic: signing under a non-owner active permission group — the whole point of TRON multi-sig — became impossible on stake, vote, reward, asset, exchange and account activate/set, and there was no way to extend a transaction's expiry while collecting co-signatures. The same account could still do both through `tx send`, which is what makes this a gap rather than a decision. These commands are single-family, so the fields belong in `baseFields` directly. Not `governanceTxModeFields`: that also overrides --build-only's description, which would change help text on 18 commands as a side effect. Nothing below the CLI layer needed touching — the value already rides `...transactionMode(input)` into the pipeline's `prepare` hook, which is why the services never name it and why the break was invisible to them. Covered by transaction-options.test.ts: both flags present on all 18, identity-equal to the exported field objects so a second copy cannot drift, --build-only's shared description pinned, and argv coercion.
The EVM family went public on this branch — `FAMILY_REGISTRY` now carries `evmFamily`, and `networks` lists seven networks across two families — but ts/docs still described a TRON-only tool. Every page was checked against the running binary rather than against the diff: the current and the merge-base `--json-schema` catalogs were compared to find the 56 commands whose spec actually moved, and every example below was captured from a real run on tron:nile and evm:11155111. Command pages (91) now state, per command, which families serve it: - Portable commands document both families where they genuinely differ — the field SETS, not just the values: `account info` reports resources and permissions on TRON, nonce and code on EVM; `chain prices` answers in energy/bandwidth or in gas and shares no field between them; `tx info` nests `info` or `receipt`; `contract call` returns words or one blob. - TRON-only commands say so, and name `family_mismatch` — the error an EVM network returns before any node call. - Family-scoped flags are split into "TRON only" / "EVM only" tables, since using one on the other family is `invalid_option`. - `contract deploy` is rewritten for the artifact-based interface that replaced --bytecode/--params. - Account pages carry the per-family address model: `addresses` and `derivationPath` are maps now, and text listings show one family at a time while JSON carries every one. machine-interface.md gains the family rules, the discovery call, and a pointer to the published error-code index; the `chain` block note is corrected — `list` and `current` emit one without contacting a node. concepts/networks.md documents the evm-gas model beside tron-resource, and troubleshooting.md keys the new failure modes. Also corrected, pre-dating this branch: the stake info unfreezing tree, `Max delegatable` reporting TRX, the token book's official layer no longer being mainnet-only, and chain params claiming the CLI cannot create proposals. Verified: 0 broken links or anchors across 125 files, every documented flag matches the live JSON Schema, and all 91 pages keep the house layout.
Execution blueprint for removing org.tron.walletcli.cli from the Java implementation, measured against the tree rather than restated from the requirements doc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxauSno5mhxX4DmBr39iso
…ckage Standard CLI removal prep. CommandErrorException lives in org.tron.walletcli.cli but leaks into the main sources in 127 places, including REPL-reachable methods (getUSDTBalance, gasFreeTransferInternal). Deleting the cli/ package with the type still inside would not compile. Move it verbatim to org.tron.core.exception, alongside the exceptions signTransactionForCli already throws (CancelException). Package move only: same class name, same semantics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxauSno5mhxX4DmBr39iso
Comments cited ADRs (as `ADR-00XX` and as `docs/adr/000X` paths) and architecture-source-of-truth.md. Neither is in version control -- the ADR directory is gitignored and that second file does not exist anywhere -- so a maintainer reading these comments was pointed at nothing. Where the citation was decoration the sentence already stood on its own and the reference is simply gone. Where the ADR carried the reasoning, the reason is now inline: why `--path` is unimplemented, what makes the node-rejection allowlist narrow, which migration leans on the address re-encoding, and what the alias rule supersedes. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRyKvkmEc4viwzrtM4SeYR
The selection filtered on `evm:`, but production keys moved to `eip155:` with the CAIP-2 rename, so `evmNetworks` was empty and the three `it.each` blocks over it produced no cases at all. The file reported green while checking nothing: not the EIP-55 checksums, not the explicit decimals, not the duplicate-contract rule -- the checks that stand between a mistyped contract address and lost funds. Fixed the namespace and added a case asserting the selection is non-empty, so the next rename fails the suite instead of silently emptying it. Before: 4 tests. After: 13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRyKvkmEc4viwzrtM4SeYR
The one-hop fold walks the WHOLE alias book, builtin entries included, so it could reach backwards. A config keyed on `tron:3448148188` is accepted -- ids resolve before the book, so such an entry is merely dead -- but the fold followed it and rewrote the builtin `nile -> tron:3448148188` to that key's own target. `--network nile` would then build, sign and broadcast on MAINNET. A canonical id is now a terminal target, never a hop. Accepting the dead entry is left alone: that a canonical id can never be shadowed is deliberate and already covered by a test. Also carries this file's share of the ADR-comment cleanup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRyKvkmEc4viwzrtM4SeYR
…snapshot `plan()` reads outside any lock -- it has to, because the gate must know whether a password will be needed before it prompts -- so the document it captured travels through notice, consent and an interactive password prompt before `commit()` finally takes the lock. Writing that snapshot back erases anything another process committed in between. Verified with two real processes: A plans, B migrates and creates an account, A commits. Before this change B's account was gone from wallets.json and its encrypted key blob orphaned; after it, B's account survives. `commit()` now re-reads each file under the lock and migrates what is on disk. A version comparison is enough to detect interference -- the gate is absolute, so a second process cannot touch a stale file without migrating it, which moves the version. Already migrated: skip, writing nothing. Moved somewhere unexpected: refuse, leaving the file untouched. The pre-migration copy is taken from the fresh read too, so it can no longer be overwritten with the snapshot. `StaleFile.doc` is dropped: carrying the document is exactly the stale read the re-read exists to avoid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRyKvkmEc4viwzrtM4SeYR
`primaryType` says WHICH struct is being signed, and the EIP-712 JSON-RPC schema carries it precisely because the type map alone does not determine the root. A payload may declare types the chosen root never reaches -- one shared dictionary covering `Permit` / `Order` / `Cancel` is the ordinary case. Handing the whole map to the encoder made it see several roots and refuse, so a payload that passed our own validation failed at signing with `signing_rejected`: a code that reads as a key or device fault when nothing was wrong with either. `normalizeTypedData` now narrows `types` to the closure the declared root reaches. Being in the domain, this fixes all four signing paths at once -- EVM software, EVM Ledger, TRON software, TRON Ledger -- with no adapter change, and it removes the Ledger paths' latent disagreement between the root `hash()` infers and the `primaryType` `hashStruct()` is given. Narrowing cannot change an existing signature: every type a single-root payload declares is reachable from that root. Confirmed end to end -- a payload with an unrelated extra type now signs to the same digest as the same payload without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRyKvkmEc4viwzrtM4SeYR
…ked for Basis points express two decimal places, so a finer `--slippage` was rounded to the nearest one -- in either direction. On a predicted 1,000,000: 1.006% gave 989,900 where 989,940 was asked for, accepting 40 units less than the floor the user set; 1.004% gave 990,000 instead of 989,960, refusing a trade they would have taken. Under 0.005% the floor snapped to the prediction itself, and since that is an estimate the trade reverts on any last-unit disagreement -- asking for a very tight tolerance produced no tolerance at all. Millionths of a percent keep the arithmetic integral while making every precision a person types exact, and the tolerance now rounds DOWN rather than to nearest, so the floor can only ever come out stricter than requested. The comment always claimed that direction; the code did not implement it. Every percentage basis points could already express is unchanged, the spec's 1% example included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRyKvkmEc4viwzrtM4SeYR
An imported file chooses its own KDF cost and the KDF runs to completion BEFORE the MAC is checked, so the file's author decides how much work this process does without knowing any password. `dklen` had only a lower bound and pbkdf2's `c` had none: measured, c=10,000,000 takes 7.8s and scales linearly, so c=10^9 runs for ~13 minutes; dklen=2^34 did not finish at all. Both are now bounded before the KDF runs. scrypt's `n`, `r` and `p` are deliberately left to @noble/hashes, which refuses a non-power-of-two `n` and caps 128*r*(n+p+1) at a 1 GiB maxmem -- a tighter bound here would only risk refusing a file that library accepts. Those library refusals were also escaping as bare Errors, which surface as `internal_error` and send the reader off to debug the CLI over a file that simply names impossible parameters; they are now reported as `invalid_keystore` like every other malformed-file rejection. The costs real wallets write (our own export, MEW's 262,144) are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRyKvkmEc4viwzrtM4SeYR
`soliditynode.ip.list` is documented as optional, and every other consumer of that choice honours it -- ApiClient switches to local-create when it is absent. Network identification did not: it compared the value the fallback had just copied from `fullnode.ip.list`, so a config naming only Nile's full node could never match Nile's (fullnode, solidity) pair and was reported as CUSTOM. That is not cosmetic. The GasFree commands refuse any network other than MAIN or NILE, so a user on Nile was told GasFree does not support their network, and the transaction history applies an extra endpoint filter once the network is CUSTOM. Identification is now a testable `identifyNetwork`, comparing only the endpoints that were actually supplied; an omitted one says nothing about which network this is and is not compared. Connection behaviour and the fallback are unchanged. The config reference said the network is inferred by comparing both endpoints, which was never what a fullnode-only config would get -- corrected. Pre-existing defect, not introduced by this release. The unit tests cover the identification rule; a fullnode-only start against Nile still wants one manual run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRyKvkmEc4viwzrtM4SeYR
error.details.code collided in name with the outer error.code but meant something entirely different depending on the site: the TRON node's own rejection code (broadcastTransaction, tx broadcast --hex) or the TronLink collaboration service's response code (multisig list). A reader following error.details.code could easily assume it was the same taxonomy as the envelope's error.code. Rename to nodeCode (paired with the existing nodeMessage, both TRON node values) and providerCode (paired with the existing providerMessage, both TronLink values), so each pair's source is unambiguous at a glance. This is an external contract change: details.code no longer appears in the JSON envelope for these errors. Consumers must switch to details.nodeCode or details.providerCode.
A node that could not be reached came back as state "not_found" at exit 0. In the
four-state model that string means "keep polling", so a script waited forever on
an endpoint that was simply down — the same defect the EVM side fixed, on the
family that carries this project's production traffic.
tronweb throws "Transaction not found" for an unknown hash, so that rejection
cannot separate the two on its own. getTransactionInfoById can: it resolves ({}
on Nile) for an unknown hash and rejects only when the node failed. Letting it
propagate makes its resolution the evidence that the node answered, after which
the other rejection is a fact about the transaction. No message matching needed.
Classifying a rejection replaced the message with canned category text and moved the node's words to details.nodeMessage — which text mode never renders. So the same insufficient_balance showed the node's actual numbers through a dry run and a bare category through a broadcast. Every classified path now reads "<category>: <what the node said>", the shape the revert case already used. The category stays scannable and stable; the specifics stay visible to the person reading a terminal. details.nodeMessage is unchanged for machine readers, and TRON's redaction still runs before anything is folded in.
…the listing A wallets.json holding a source type this build does not know took `list` down with a redacted internal_error: SOURCE_KINDS returns undefined for it and enumerateAddresses reads fields it does not have. One unusable account made every usable one unreachable. The listing now skips what it cannot read and the command warns, naming the wallet ids, so the accounts that do work stay available and the ones that do not are visible rather than silently absent.
The same spelling was handled two opposite ways: `contract deploy --code 0X…` passed the local hex check and was left for ethers to refuse later as invalid_transaction at exit 1, while `--account 0X…` was rejected outright by a regex that insisted on a lowercase prefix. 0X is valid hex notation. Only our downstream library insists on 0x, and that is not something a caller should have to carry. Both paths now accept it and normalise. Note that isEvmAddress's checksum comparison had to normalise the prefix too, or a correctly-checksummed 0X address would have failed the very check that was meant to accept it.
A stretch of the v4.13.0 defect work was driven through briefs written in Chinese, and the comments that came out of it followed the brief's language rather than the file's. Chinese landed in the error-code dictionary, the help renderer, the token book, a test's expectations, and CLAUDE.md itself. This repository is English throughout — a comment only works if the next person to open the file can read it. Comments, commit messages, test names and docs stay English regardless of what language the work was discussed in. Comment text only; no behaviour changes.
v4.13.0 removed the standard CLI, and with it the only end-to-end harness the Java client had. The interactive shell is now the single entry point and nothing exercised it, so `./gradlew build` passing said nothing about whether it worked. qa-repl drives a real REPL over a pty with expect: wallet lifecycle, account queries, a signed 1 TRX transfer on Nile, and the not-logged-in guards. A second script pins the other half of the change -- the entry point takes only --version and --help, and points everything else at the TypeScript CLI with exit code 2. Secrets stay out of the repo: the key and mnemonic come from ts/.private/.env.test. Claude-Session: https://claude.ai/code/session_01JByiuXufMHVGvoF4pKYUdr
Skipping an account of an unknown source kind taught `list` to survive one, but every path that NAMES one still reached an accessor the kind cannot satisfy — SOURCE_KINDS[...] or enumerateAddresses — and answered with a redacted internal_error. `delete` was the costly one. It removed the wallet, wrote the file, and only then read SOURCE_KINDS[...].hasSecret to build its receipt, so the throw arrived after the write: the account was already gone from disk while the caller was told the command had failed, leaving an orphaned key blob behind. The address scan was the widest. It walked every wallet through enumerateAddresses, so a single unreadable account cost every OTHER account its address lookup — `use`, `backup` and `delete` by address all failed on a registry that `list` could still show. Both now follow the rule the listing already sets. The scan skips what it cannot read, so an address the caller can see keeps resolving. A command that names an unreadable account gets encoding_error naming the wallet, raised before the operation starts rather than while assembling its result.
`tx broadcast --dry-run` answered "would this go through?" by comparing the nonce against the mined count, but the build path — `tx send`, `contract send` and `contract deploy` — only ever warned about a nonce that was too HIGH. A dry run with a nonce the account had already used reported a fee plan for a transaction that could never be mined, and eth_estimateGas does not catch it because it ignores the nonce entirely. The check goes in tx-build, which all three share, so they inherit it from the same place the gap warning already comes from. Read at "latest", not "pending": a nonce below pending may be a deliberate replacement of a transaction still in the mempool, which is legitimate — only one already mined is spent. The read happens only for --dry-run with an explicit --nonce, since a derived nonce IS the pending count and cannot be behind, so no other mode pays the extra round trip; and it is best-effort, so a dry run that cannot reach the node still builds. Raised before onNonce and before the estimate: a doomed deploy should not hand back a predicted contract address it can never occupy, nor spend an estimate whose failure would report the wrong reason.
`formatAmount` caps text amounts at six fractional digits, drops trailing zeros and reports a non-zero amount below that precision as `<0.000001`. `formatSun`/`formatWei`/`formatGwei` were wired to it; the account path never was, so an 18-decimal coin printed all eighteen digits and 1 wei read as a wall of zeros rather than "small but not nothing". `humanBalance` now scales through `formatAmount` (serving both `account balance` and `token balance`), and the portfolio column formats from the `rawBalance` + `decimals` that `holding()` already reports instead of reprinting the pre-scaled string. json is untouched: it keeps the exact base-unit integer. This also settles a disagreement the two commands had about the same number — `account info` already grouped it as `9,915.80311` while `account balance` printed `9915.80311`. Grouping now comes from the one formatter, and the doc examples are updated to what the renderer emits. The existing tests only exercised `formatAmount` itself, which is why a formatter that was never called still looked covered; the new ones assert rendered output.
`92695f03` wired `--out` into the EVM binding, but sign.md still told readers the option was accepted and ignored there, so the docs were steering people away from a feature that works. While correcting that, state the part the page only ever implied: `--out` adds a file, it does not remove the hex from the result. That is the already-shipped shape — the TRON example prints `hex` and `out` side by side — and a body that appeared or vanished with a flag would force every consumer to branch on it.
The case asserted that `base` does not resolve and called that "EVM is not currently exposed". No such gate exists — both families are registered unconditionally and every builtin EVM network and alias resolves, which the evm-gas and alias-book cases in this same file assert. `base` fails only because it is an L2, deliberately absent from both tables. Naming it for a gate that isn't there invites someone to "restore" one. The rule under test is that a name reaching neither table is rejected.
The skill file was deleted in 13325ab, leaving two dead links and a stale "skills" entry in the npm files list.
TronGrid's anonymous tier admits ~3 requests per second on account-scoped endpoints and rejects the rest with HTTP 429, identically whether they arrive concurrently or strictly sequentially. Serialising alone does not help; the spacing between request starts is what matters. RequestPacer is a FIFO queue that admits at most maxInFlight requests and starts them no closer together than minIntervalMs (measured: 300ms still drew 429s, 350ms ran clean, 400ms ships for margin). PacedHttpTransport decorates a transport so nothing reaching requestText can skip it, and TronRpcClient also installs the pacer on tronweb's own providers, which own a separate axios client that would otherwise bypass it entirely. Pacing is self-imposed, so it must not be charged against a caller's timeout: withDeadline installs the deadline before the work starts and keeps it reachable through AsyncLocalStorage, and the pacer credits every wait back before taking it. Credit reaches every enclosing deadline, since #wrap nests. Queue time behind in-flight requests is the one wait that cannot be credited up front; a request that loses its deadline there is dropped rather than sent, so an abandoned read does not spend node budget and an abandoned broadcast never reaches the node after the caller was told it timed out.
…vider swap tronweb installs a brand-new HttpProvider on setFullNode/setSolidityNode/ setEventServer — as do setHeader/setFullNodeHeader/setEventHeader, which delegate to those three — dropping the pacing patch held on the old instance. Nothing calls them today, so the failure mode was silent: requests keep succeeding, just unpaced, until the endpoint starts answering 429. Those three now re-pace whatever they installed, guarded by a WeakSet so a provider is never wrapped twice (which would make one request take two permits and deadlock the pacer). The pacer, deadline and client tests ran on real timers, which made them both slow and only able to assert "at least the interval, minus slack". On a virtual clock they assert the exact schedule instead — gaps of [400, 400], a fan-out finishing at exactly 3 x 400ms — and the three files went from 6.6s to 0.35s.
feat: v4.13.0 (evm support & java standardCLI deprecate)
matrix-agent116
approved these changes
Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
New Features
Change
The TypeScript CLI now speaks EVM as well as TRON. A network belongs to one chain family,
tronorevm, and that is what decides which commands and flags apply. Twenty-one commands are bound forevm—account balance/info/portfolio,block,chain node/prices,tx send/sign/broadcast/status/info,token balance/info/add/list/remove,contract call/send/deploy, andmessage sign/typed-data sign— against TRON's full surface, which keeps staking, permissions, proposals, TRC10, GasFree and TronLink multi-sig to itself. Four EVM networks ship built in (Ethereum, Sepolia, BNB Smart Chain and its testnet) alongside the three TRON ones. One seed produces a TRON address and a different EVM address, and a key-backed account serves both families; a watch-only or Ledger account holds one address and therefore one family, with--app tron|ethereumchoosing which at import. Fees follow the chain: EIP-1559 gas estimation with--gas-limit/--nonceon EVM, bandwidth and energy on TRON. Calling a command on the wrong family fails withfamily_mismatchat exit2before any node call, using a flag from the other family isinvalid_option, and--helptags every family-scoped row(TRON only)/(EVM only).contract deploy --artifactreads a Foundry / Hardhat / TronBox compiler artifact for both bytecode and ABI, so the same deploy command works on either family with the constructor's types coming from the compiler rather than from you. (#982, #984, #990)Canonical network ids are now CAIP-2, and persisted wallet data upgrades itself at startup. A network is identified by
namespace:reference—tron:728126428,tron:3448148188,eip155:56— the scheme WalletConnect, SIWE and the wider agent tooling already speak; the TRON references are the decimal genesis-hash prefix. Input is unaffected:tron:mainnet,tron:nileandtron:shastaremain permanent aliases, as do short spellings such asnile,sepoliaandbsc. Output is a different matter — the envelope'schain.network, thenetworkslisting'sidand theconfigkeys all now report the CAIP-2 id, so a consumer that string-matches or keys a map bytron:nilemust be updated; an alias resolves once at selection and can never appear in a result. Because the rename would otherwise orphan stored data, every invocation — including--help,--versionand--json-schema— checks the persisted wallet schema first and upgradeswallets.jsonandtokens.jsonbefore anything else runs. An upgrade asks for consent in a terminal, returns a singlemigrationenvelope at exit0withoriginalCommandExecuted: false, and deliberately does not run the command that triggered it, so nothing continues across an unexpected durable-state change; declining is a successful cancellation, and a non-interactive run that needs the master password returnsmigration_requiredat exit2unless it is piped in with--password-stdin. The commit re-reads each file under the lock, so a concurrent process's account cannot be erased by a stale snapshot. (#990, #994)The Java implementation is now the interactive shell and nothing else. The Standard CLI — its command registry, option parser, output formatter, alias book, non-interactive Ledger signer, and their tests and resources — is deleted, together with the
qa/harness that only ever drove it. The only supported invocation is a barejava -jar wallet-cli.jar, which opens the prompt;--versionprints one plain-text line,--helpprints the shell usage plus a pointer to the TypeScript CLI, and any other argument prints that one-line pointer on stderr and exits2. The hint is deliberately not a JSON envelope, so a script cannot mistake it for the removed contract. Non-interactive, scriptable and CI use belongs to the TypeScript CLI, which reached feature parity in 4.12.0. In its place,java/qa-repl/drives the real shell over a pty against a funded Nile account — wallet lifecycle, account queries, a signed transfer, and the not-logged-in guards — and the Gradle build version is now pinned to the version string the shell itself reports, with a test that fails a release build carrying-SNAPSHOTor drifted versioning. (#991, #990)The error-code index is published and machine-readable.
wallet-cli --json-schema | jq '.errorCodes'returns one entry per code —{"rpc_error": {"exit": 1, "retry": "same", "meaning": "the node answered with an error"}}— so an agent no longer has to scrape prose to learn what a failure means.retryanswers "now what":same(resend immediately),later(a lock-up or rate limit — back off first),changed(raise the fee, rebuild the nonce),never. Exit status is now derived from the error code itself rather than from the class that raised it, and a test holds every throw site to its code's declared exit, which settled four codes that had been thrown at both classes. Errors that name a choice rather than a dead end carryerror.details.matches;ambiguous_accountjoinsambiguous_asset_namethere, listing the candidate accounts when--account <address>does not resolve to a single interchangeable signer for the family being acted on. (#990, #994)Every chain HTTP call now goes through one transport, with per-network API keys and request pacing. The TRON and EVM clients share a single transport, and
networks.<id>.apiKeyHeader/.apiKeyare readable and writable throughconfig: both must be set before a header is sent, the header name is validated as an RFC 9110 token so a hand-editedconfig.yamlcannot smuggle in a second one,apiKeyis write-only on every read surface and forces the0600permission check, and a credentialed request refuses to follow redirects so a redirecting endpoint cannot collect the key. On top of it, requests to one endpoint are paced — a FIFO queue that starts them no closer together than 400ms — because TronGrid's anonymous tier admits about three requests per second on account-scoped endpoints and answers the rest with HTTP 429, identically whether they arrive concurrently or strictly sequentially. A wide fan-out such asaccount portfolionow runs clean instead of collecting rejections. Pacing is self-imposed, so the time a request waits is credited back to--timeoutrather than charged against it; a request that loses its deadline while queued is dropped rather than sent, so an abandoned broadcast never reaches the node after the caller was told it timed out. (#990, #996)Bug Fixes
Change
An unreachable node was reported as a fact about the chain.
tx statusanswerednot_foundat exit0when the endpoint simply could not be reached, and in the four-state model that string means "keep polling" — so a script waited forever on a node that was down. TRON'sgetTransactionInfoByIdresolves for an unknown hash and rejects only when the node failed, so its resolution is now the evidence that the node answered, after which "not found" is a fact about the transaction.blockhad the mirror-image defect: a height the node has no record of came back as{block: {}}at exit0, leaving callers to guess what an empty object meant. A real block always carriesblockID, so its absence is nownot_foundfor a specific height, orinvalid_node_responseforlatest— "the chain has no latest block" is not something a node can truthfully state. (#990)The wallet store could be taken down, or quietly lose a secret, by one unusual account. A
wallets.jsonholding a source kind this build does not know tooklistdown with a redactedinternal_error, and the address scan walked every wallet, so a single unreadable account cost every other account its lookup —use,backupanddeleteby address all failed on a registrylistcould still show.deletewas the costly one: it removed the wallet and wrote the file, then threw while building its receipt, so the account was gone from disk while the caller was told the command had failed. Separately, import dedup matched across source kinds, so importing a mnemonic whose account #0 already existed as a raw private key returnedstatus: "existing"at exit0while the seed — the strictly more capable secret — was discarded, andderivethen failed on an import reported as successful. Import validation was hardened alongside: a private key outside secp256k1's valid range is nowinvalid_private_keyrather thaninternal_error, and an imported Web3 keystore's KDF cost is bounded before it runs, since the file's author otherwise chose how much work this process does before any password is checked (a measuredc=10^9runs for about thirteen minutes). Contract reduction:import keystorenow delegates to exactly the pathimport private-keyuses, so theaccount_existserror code has no producer left and is removed. (#990, #996)exchange trade --slippagedid not honour the tolerance that was typed. The floor was derived in basis points, which express two decimal places, so a finer value was rounded to the nearest one in either direction: on a predicted 1,000,000,1.006%produced a floor of 989,900 where 989,940 was asked for — accepting 40 units less than the user set — while1.004%refused a trade they would have taken. Below0.005%the floor snapped to the prediction itself, and since that is an estimate, asking for a very tight tolerance produced no tolerance at all. The arithmetic now runs in millionths of a percent and rounds down, so the floor can only ever come out stricter than requested; every percentage basis points could already express is unchanged. (#990)The Java shell mis-identified a network configured with only a full node.
soliditynode.ip.listis documented as optional and every other consumer honours it, but network identification compared the value the fallback had just copied fromfullnode.ip.list— so aconfig.confnaming only Nile's full node could never match Nile's endpoint pair and was reported asCUSTOM. That is not cosmetic: the GasFree commands refuse any network other than MAIN or NILE, so a user on Nile was told GasFree does not support their network, and the transaction history applied an extra endpoint filter. Identification now compares only the endpoints the config actually supplied; connection behaviour and the fallback are unchanged. (#990)Two paper cuts in the TypeScript CLI's input and output.
tx sign --fileandtx broadcast --filefailed to parse any file produced by an editor,echo, orjq -r, because the trailing newline every one of them writes was passed through verbatim; surrounding whitespace is now trimmed once at the read site, while internal whitespace still fails downstream rather than silently joining two tokens. Andaccount balancenever went through the shared amount formatter, so an 18-decimal coin printed all eighteen fractional digits, 1 wei read as a wall of zeros instead of<0.000001, and the same number appeared grouped inaccount infobut ungrouped inaccount balance. Text output now follows one rule everywhere; JSON is untouched and keeps the exact base-unit integer. (#990, #994)