Skip to content

feat: v4.13.0 (evm support & java standardCLI deprecate) - #990

Merged
gummy789j merged 120 commits into
release_v4.13.0from
feat/v4.13.0
Sep 3, 2026
Merged

feat: v4.13.0 (evm support & java standardCLI deprecate)#990
gummy789j merged 120 commits into
release_v4.13.0from
feat/v4.13.0

Conversation

@gummy789j

Copy link
Copy Markdown
Collaborator

No description provided.

gummy789j and others added 30 commits August 24, 2026 10:03
…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
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
gummy789j and others added 20 commits September 2, 2026 16:37
buildEvmUnsignedTx used to skip the pending-nonce read entirely whenever
--nonce was given, so an explicit nonce ahead of the account's pending
one produced no warning at all — a caller could leave a nonce gap and
have no way to notice from the command's own output.

Always read the pending nonce and compare against it, warning when the
explicit nonce is above it. To keep the explicit-nonce path from becoming
newly fragile, make that read best-effort in that case only: it exists
solely to produce the warning, so a failed read now just skips the
warning instead of failing a build that already has everything it needs.
Without --nonce the same read remains a hard dependency, since there it
IS the nonce.
build.gradle still declared version '1.0-SNAPSHOT', unrelated to the
version the shell itself reports (Utils.VERSION). A release jar could
therefore ship labeled 1.0-SNAPSHOT while the running CLI answers
--version with something else entirely, and nothing caught the drift.

Set the Gradle version to 4.13.0 and add VersionConsistencyTest, which
pins Gradle's project.version (passed through as a system property) to
Utils.VERSION and refuses a -SNAPSHOT suffix outright — so a release build
carrying stale or placeholder versioning fails the test suite instead of
shipping.
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.
@gummy789j
gummy789j merged commit 8248642 into release_v4.13.0 Sep 3, 2026
2 checks passed
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.

2 participants