Skip to content

catch up - #1

Open
Corey-Code wants to merge 1235 commits into
vidulum:masterfrom
trezor:master
Open

catch up#1
Corey-Code wants to merge 1235 commits into
vidulum:masterfrom
trezor:master

Conversation

@Corey-Code

Copy link
Copy Markdown
Member

No description provided.

pragmaxim and others added 30 commits July 1, 2026 13:34
Document, with mermaid diagrams, how EIP-1559 fees are produced across Blockbook
and Trezor Suite and which component owns which decision: blockbook provides
ground-truth inputs (chain base fee, priority-fee tiers, congestion/trend, block
gas), while the wallet owns the fee policy (base-fee source, 2x head-room buffer,
maxFeePerGas composition, per-coin clamps). Covers the pull path
(EthereumTypeGetEip1559Fees), the push path (subscribeNewBlock evmData), Suite's
EthereumFeeLevels, the end-to-end flow, and the maxFeePerGas formula.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the two existing fee panels (estimated fee rate, alternative fee
provider requests) out of General into a new top-level "Fees" section,
so fee observability has one home as EVM EIP-1559 fee metrics are added.

Pure relocation: panels, queries and the metrics they read are unchanged;
only their x-panel-keys move general.* -> fees.* and a row.fees section is
introduced. grafana.json is the git-ignored artifact rendered from these
two sources by contrib/scripts/render_grafana.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EVM EIP-1559 fees were invisible in metrics (blockbook_estimated_fee is
UTXO-only). Add two gauges populated on the successful estimateFee /
EthereumTypeGetEip1559Fees paths (provider cache hit and on-chain estimate):

- blockbook_eth_eip1559_fee{tier,kind}: per-tier maxFeePerGas / priority fee
- blockbook_eth_eip1559_base_fee: the next-block base fee underlying them

Values are raw wei (base units, like estimated_fee); Grafana divides by 1e9
to show Gwei. Emitted only on the two successful returns so error/disabled
paths never write zeros, and nil tiers are skipped so no empty series appear.

Dashboard (Fees section): maxFeePerGas by tier, priority tip by tier (own
axis - tips are ~1000x smaller), and the maxFee/baseFee buffer ratio with a
dashed 2x reference, which separates a congested chain from an estimator
regression - the "fees too high" diagnostic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add blockbook_eth_eip1559_fee_source_total{source} incremented on each served
estimate at the serve boundary: provider (alternative provider cache hit),
onchain_fallback (provider configured but cache stale/unready, so eth_feeHistory
was used) or onchain (no provider configured).

This is the one signal neither alternative_fee_provider_requests (background
fetch outcome) nor the provider cache-age gauge captures: how often the wallet-
facing estimate actually bypassed the provider. Emitted only on the two
successful returns, alongside the fee gauges.

Dashboard: a non-stacked "EIP-1559 fee source" panel so a provider->fallback
transition shows as a visible crossover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add blockbook_eth..._provider_last_sync_timestamp_seconds{provider}, the unix
time of the last successful refresh of the cached EVM provider (infura/1inch)
fees, set via a shared observeSync() called from both providers' processData
(replacing the bare p.lastSync = time.Now()) so lastSync and the metric always
share one instant.

Cache age is plotted as time() - metric. A timestamp gauge, not a computed
*_age_seconds one (the repo's usual form): it is written only on a successful
refresh, so the plotted age keeps climbing when a provider wedges and survives
restarts. This is the leading indicator that explains the onchain_fallback
counted by eth_eip1559_fee_source_total - once age crosses the stale window
(30x the poll period) the pull path falls back to on-chain.

Dashboard: a cache-age panel with a dashed default-cutoff (30m) reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add blockbook_eth_block_gas_used_ratio, gasUsed/gasLimit of the most recently
connected EVM block (0..1) - the congestion signal that drives the next base
fee (>0.5 it rises up to +12.5%/block, <0.5 it falls). This is the push path's
own view of why fees move, independent of any provider.

Set synchronously in OnNewBlock before the async broadcast: OnNewBlock is
invoked in monotonic height order by the single writeBlockWorker, whereas the
per-block broadcast goroutines can reorder and let an older block clobber a
newer gauge value. Last-value semantics, nil-guarded for non-EVM/pre-London.

Dashboard: a "Latest block gas-used ratio" panel (percentunit) with a dashed
50% balance-point reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…verlay

Add blockbook_eth_block_base_fee, the realized base fee of the most recently
connected block (push path), in raw wei. Set in the same OnNewBlock helper as
the gas-used ratio, nil-guarded.

Paired with eth_eip1559_base_fee (the pull path's next-block projection) on one
overlay panel - solid mined vs dashed projected, both in Gwei. The two should
track within a block's +/-12.5% step; a persistent gap means the feeHistory
projection (or a provider's estimatedBaseFee) is drifting from the chain - the
base-fee half of a "fees too high" investigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documentation-only follow-up from an adversarial review of the new Fees
section (no functional change, no Go change):

- order the section pull-estimate -> provider-health -> push -> legacy, and
  label the relocated UTXO "Estimated fee rate" so an EVM $coin selection
  reads as expected-empty rather than an outage
- buffer-ratio: note Infura's high tier sits ~2.5x by design, so the dashed
  2x line is the on-chain estimator's target only
- base-fee overlay & cache-age: scope the "tracks within +/-12.5%" and "30x
  poll period" expectations to the path/provider they actually hold for
  (provider coins serve a cached projection up to the stale window old)
- eth_eip1559_base_fee help: drop the unconditional "next-block projection"
  claim (true only on Erigon-like backends) and Infura's estimatedBaseFee
  field name (1inch feeds the same gauge from baseFee)
- priority-tip: "separate panel", not "own axis"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bounds-check the per-tier column index so a non-compliant backend
returning a reward row shorter than the requested percentile count is
skipped instead of panicking EthereumTypeGetEip1559Fees. Divide the tip
average by the rows that actually contributed, so a skipped short row
does not deflate the tier. Document the accepted zero-tip case on idle
chains, and add a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 1inch alternative fee provider never set staleSyncDuration, so it
defaulted to the zero value (0s), making the cache immediately stale
after every write. GetEip1559Fees always fell back to on-chain estimation
via eth_feeHistory, defeating the purpose of having a provider cache.

Mirror the same pattern used by the Infura provider: introduce
oneInchFeeStalePeriods=30 and oneInchFeeStaleDuration, and set
p.staleSyncDuration in the constructor so cached fees are reused within
the 30×pollPeriod window (e.g. 30min for a 60s poll).
Replace the Infura gas API with 1inch Gas Price API for Ethereum mainnet archive.
The 1inch API returns raw wei integers and uses Bearer token auth (ONE_INCH_API_KEY).
The staleSyncDuration bug that made 1inch cache always immediately stale was fixed in the preceding commit.
Grafana:
- alt_fee_provider_requests tooltip mode: single -> multi (inconsistent with other fee panels)
- eip1559_max_fee description: generalize Infura-only language to mention both providers and
  clarify instant tier behavior differs per provider
- eip1559_buffer_ratio description: generalize Infura-only padding reference
- alt_fee_provider_cache_age description: generalize "Infura (the EVM default)" to mention both
  providers use the same 30x stale window

docs/fees.md:
- Replace Infura-only references with provider-agnostic language throughout
- Keep accurate historical context (Infura as original source of fees-too-high reports)
- Update mermaid flowcharts from "Infura fees" to "provider fees"
- Document both Infura and 1inch behavior in the provider section
1inch returns 4 tiers (low/medium/high/instant) with tighter per-tier values than
Infura (3 tiers). The previous 1:1 mapping (suite low/medium/high ← 1inch
low/medium/high) produced fees that were too conservative compared to Infura,
causing ~2x fee differences between providers.

Remap so the suite's low/medium/high tiers are fed from comparable
aggressiveness:
  1inch medium → suite low
  1inch high   → suite medium
  1inch instant → suite high
1inch's low tier is discarded (too conservative to be useful).

The fee formula (maxFeePerGas = 2×baseFee + tip) is already correct on the
suite side (fix/evm-fee branch) — this change ensures the input tier values are
comparable regardless of which provider serves the response.
op-geth and arbitrum-nitro do not implement the newPendingTransactions
subscription filter. Without disableMempoolSync: true, Blockbook logs:

  initializeMempool EthSubscribe newPendingTransactions:
    invalid subscription type for subscribe

and wastes resources on repeated resubscribe attempts.

This was already fixed for bsc_archive, polygon_archive, and
base_archive. Apply the same fix to the remaining L2 coin configs:

  - optimism, optimism_archive
  - base
  - arbitrum, arbitrum_archive
  - arbitrum_nova, arbitrum_nova_archive
…ncurrency

Trezor Suite's Blockbook websocket client caps concurrent in-flight requests
at 42 (blockchain-link/src/workers/blockbook/index.ts:481). The old default of
0 disabled the server-side limit entirely, making per-connection getAccountInfo
abuse bounded only by the 2500 msgs/10 min rate limiter.

Change the default to 42 so the server-side descriptor-count limit activates
automatically. The env var override is preserved for operators who want a
different value.
… connections, reduce timeouts

- Use Promise.all for cross-coin execution: coins run concurrently
  (they connect to independent backends). Each coin buffers its own
  output and flushes contiguously after completion.
- Add preloadSamples() to TestContext: eagerly resolves block, tx,
  address, and fiat samples once at coin startup so downstream tests
  hit the cache instead of triggering redundant probe chains.
- Replace per-call WebSocket connections with a wsPool: maintains 3
  persistent connections, eliminating the 5s handshake overhead for
  every WS test. Pool acquires/releases per request with automatic
  dead-connection replacement.
- Reduce timeouts: HTTP 30s->15s, WS dial 5s->3s, WS message 15s->10s.
…utput per-coin

- Add a mandatory timeout to wsConnection.connect() using a 'settled'
  guard that rejects after wsDialTimeoutMs even when DNS or TCP stalls
  (the ws library's handshakeTimeout only applies after TCP connect).
- Make wsPool.init() parallel (Promise.allSettled) so all connections
  are attempted concurrently; a partial pool is still usable.
- Remove getSampleAddressTx from preloadSamples() — it probes up to 40
  transactions with paginated lookups and is only needed by a few
  address-listing tests; keep it lazy.
- Flush each coin's output immediately upon completion instead of
  waiting for Promise.all, so results from finished coins are visible
  even if another coin hangs.
- Use Promise.allSettled instead of Promise.all so a rejection in one
  coin does not discard results from other coins.
… init race

E2e-only remainder of the original review-findings commit; its
tests/integration.go part (configurable init retry) was dropped along
with the Go-side test parallelization.
…ions

Promise.allSettled discarded runCoin rejections: a coin whose context
creation or preload threw vanished from the summaries with its buffered
output unflushed, and the run printed 'passed for N coin(s)' with exit
code 0. Catch the rejection per coin and push a failed CoinRun summary
so the aggregate failure path fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
preloadSamples() ran bare inside runCoin, outside the per-test
try/catch, so one transient HTTP or schema-validation error during the
probe chain rejected the whole coin. Handle it like the status
preflight: emit a single SamplePreload failure with the buffered output
preserved and stop the coin there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wsConnection already multiplexes concurrent requests by ID and tests
within a coin run sequentially, so the 3-connection lease pool bought
nothing over one persistent socket while adding an unbounded acquire
retry loop and a permanently-cached failed init. One shared connection
with redial-on-close keeps the handshake-per-coin win; every wait is
bounded by the dial and message timeouts, and a failed dial is not
cached so the next call retries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… and panel

debug_traceBlockByHash is typically sub-100ms, reaches seconds only when
the node is overloaded, and ~10-15s observations are trace_timeout /
rpc_timeout expiries — not routine latency. Say so in the histogram help
text, and exclude failed calls from the Grafana p50/p95 queries so
timeout expiries do not skew the quantiles (failures are already charted
in the sync RPC errors panel).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y deletion

The generated Debian package installs a daily cron wrapper that runs
bin/logrotate.sh as root, while postinst chowns the per-coin log
directory to the unprivileged Blockbook service user. The cleanup
script expanded the discovered log path unquoted in both `fuser` and
`rm -f $log`, so a file whose name contains shell word-splitting tokens
(e.g. "blockbook-bitcoin.log -rf /opt/coins/data/bitcoin/backend")
would be split into multiple operands. On GNU rm, `-rf` is honored even
after an earlier operand, letting the service user cause the root cron
job to recursively delete an attacker-chosen path outside the log tree
(backend/RocksDB data, config, other coins' data).

Quote "$LOGS" and "$log" and pass `--` before operands so an
attacker-controlled path is always treated as a single non-option
argument.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
deploy is a fail-fast:false matrix, but wait-for-sync and e2e-tests were
gated on needs.deploy.result == 'success'. A single failed coin therefore
skipped sync verification and e2e coverage for every coin that DID deploy,
leaving freshly deployed instances unverified. Run both jobs on partial
failure too; only mode=build (skipped) and cancellation keep them off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven *_test.py files (~1000 lines covering runner.py, build/deploy plans,
backend policy and wait_for_sync) existed but were executed by no workflow
or Makefile target, so regressions in the pipeline scripts shipped with
green CI. Run them via unittest discover in the lint job; they are
stdlib-only, need no secrets, and are safe for fork PRs on the
GitHub-hosted runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lower SYNC_TIMEOUT_SECONDS to 300 and the job timeout-minutes to 6,
keeping the one-minute buffer over the script deadline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Only wait-for-sync had a timeout; every other job fell back to GitHub's
360-minute default, so a hung apt lock, unresponsive backend RPC or
stalled install could occupy a self-hosted runner for six hours and block
everything queued behind it. Ceilings are sized well above normal
runtimes so they only trip on genuine hangs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pass matrix.coin to deploy-bb-and-backend.sh via an env var instead of
expanding ${{ }} directly into the run: script, matching how every other
dynamic value in this workflow is handled. Set persist-credentials: false
on all checkouts so the GITHUB_TOKEN is not left in .git/config on the
persistent self-hosted runners when post-step cleanup is skipped
(cancelled job, crashed runner agent) — testing.yml already does this on
the same runner pool. Nothing in the build/deploy path performs
authenticated git operations after checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Without a concurrency group, every push to a PR queued a full
unit->connectivity->integration chain on the limited self-hosted pool
even when a newer commit had already superseded it. Cancel-in-progress
applies only to pull_request events; push runs on master/develop always
run to completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pragmaxim and others added 30 commits August 25, 2026 11:04
…de-off

Document two previously-implicit contract points on the privatePending hint:
- estimateFee URL selection is best-effort (nonceURL, else urls[0]); unlike the
  nonce floor, gas has no client value to compensate a wrong relay node, so a
  declared-but-unknown sender may miss a predecessor held by another relay.
- the deliberate trade-off vs pre-#1629 behavior: hint-less senders whose private
  tx was accepted by another replica are estimated on the primary RPC; widening
  routing for them would reopen the #1629 drain, so declaring the hint is the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The exported contract still promised what the pre-rebase implementation
did: "any entry raises the reported pending nonce to at least value+1".
The floor no longer works that way - a declared nonce is an occupied slot
that the walk crosses only if the slots below it are occupied too, so a
nonce declared above an unfilled slot strands instead of lifting the answer
over the hole. A client integrator reads that guarantee from
blockbook-api.ts, so leaving it stale is the one place the semantic change
could reach a caller.

Says so in the ts_doc (and its generated TypeScript), in the hint section
of docs/evm-send.md, and in the help of the two pending-floor counters,
which a caller-declared nonce can now drive just as a cached one does.

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

Two hint-section claims aged out under the rebase onto the Blinklabs
alignment and its missing eviction:

- Nothing said a declaration is never expired server-side. Now that a
  dropped transaction's cached copy is retired within minutes
  (alternativeMissingTxTimeout), the asymmetry is worth stating: only
  the caller can prune its declaration, and a wallet deriving it from
  Blockbook's own pending answers self-heals on the next re-fetch.

- The no-quota-drain argument leaned on wallets declaring the field
  rarely. The shipped trezor-suite producer (trezor-suite@754da40740)
  declares on every estimate keystroke while any own pending tx
  exists, so the argument now rests on what actually bounds the
  traffic: only senders tracking an in-flight tx are routed, and only
  while it is pending.

Plus the missing full stop the stranded-counter help lost to its own
appended sentence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hint series hand-edited blockbook-api.ts but never taught
openapi.yaml the new field, which the parity typecheck reports as
drift (WsAccountInfoReq: blockbook-api.ts has properties not declared
in openapi.yaml). The PR checks stayed green because only the deploy
workflow runs that typecheck - it would have surfaced at deploy time.
Declare WsPrivatePending as a schema, reference it from
WsAccountInfoReq and WsEstimateFeeReq.specific, and pin it with its
own parity assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rs read it

Adversarial review of the rebase found the series updated the
floor_raised/floor_stranded texts for the declared hint but left every
place describing WHO gets routed on the pre-hint story:

- the nonce/estimate request counters and their panels still claimed
  only 15-min recent private senders reach the relay, and told the
  operator a sustained climb means the gating regressed - under the
  hint a declaring wallet is routed on every account sync and estimate
  keystroke while anything is pending, so the volume legitimately
  tracks pendinghood and the old reading would misdiagnose it.
- EthereumTypeGetNonces' godoc, observeAlternativeNonceRequest and the
  estimate-routing comment still described useForNonces as the only
  gate, and the nonce bound as pending+cached.
- api/types.go, ws_types.go (mirrored in blockbook-api.ts) and one
  nonce_hint_test comment still stated the retired raises-above-value
  contract or the pre-alignment relay model; the walk is the contract
  everywhere else since "state the hint's contract as the walk".
- ws_types.go's getAccountInfo field doc said "When present" where the
  actual gate is a non-empty nonces array - a txids-only declaration
  changes nothing.

Also unbreaks configs/metrics.yaml: the rebase resolution added a help
sentence with an unquoted colon, which YAML rejects and every server
test setup fatals on - caught only under -tags unittest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… case

Two review findings on the hint's test coverage:

- The "declared for another address is invisible" table case declared
  nothing - a byte-for-byte duplicate of the another-sender's-cache
  case above it - and the property its name claimed is not one
  raiseToPendingFloor has: declared nonces are folded in for whichever
  address is queried, unfiltered; scoping them to the request's own
  descriptor is the caller's job. The case now declares and pins that
  actual contract instead of asserting a guarantee nobody implements.

- Nothing exercised the seam between the request and the chain: the
  declared nonces cross websocket.go's AddressFilter and worker.go's
  variadic forward, and dropping either compiles while every test
  stays green, because the fake chain discarded the parameter. The
  fake now walks the pending nonce across the declared slots the way
  production does, and a server-level getAccountInfo test asserts the
  walked answer - deleting either forwarding site now fails it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review of #1639 asked whether routing on the declaration can lower the
answer below the primary's: the declared floor covers the declaring
wallet's own txs, but nothing compensates for pending txs it does not
know about (another device on the same account), and the relay's answer
replaces the primary's entirely. It cannot, but only because of a relay
property the docs never stated: Blink's pending eth_getTransactionCount
answers the greater of the node's pending count and the relay's latest
accepted nonce + 1 - a public-mempool superset - so a routed answer
never falls below what the primary would have said. Say so where the
routing is designed (docs/evm-send.md) and decided (the gate's comment),
along with what a relay without the property would require: a floor-only
nonce path, which the fallback branch already computes for the
declaring wallet at zero relay cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… vector

Review of #1639 pressed on what bounds the relay reads a privatePending
declaration can force. The honest answer is that nothing needs to:
sendTransaction is equally unauthenticated and spends the same relay
quota on every call - fanned out to EVERY relay URL, at no cost for a
rejected transaction - so a declaration buys an attacker strictly less
quota spend per request than the endpoint that has always been there.
The trust-boundary section now says exactly that, replacing the
per-connection-limit reasoning, and notes that relay-quota protection,
if ever wanted, belongs in the server's per-IP rate limiting uniformly
across methods rather than inside this feature.

The same review round called the surrounding prose very verbose; the
routing-gate comments and the relay-superset passage are cut to their
contract statements, with docs/evm-send.md as the single narrative
home. Plus two touch-ups: the env prefix is computed once, and the
template's literal µ is restored from the escape a tooling round-trip
left behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
base_archive's full block-detail response scales with tx count
(250-300+ txs/block) and measured 7-9s live on dev, tripping the
hardcoded 15s AbortSignal.timeout during sample preload while
avalanche/optimism (fewer txs/block) stayed well under it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bumps [github.com/gorilla/websocket](https://github.com/gorilla/websocket) from 1.5.0 to 1.5.3.
- [Release notes](https://github.com/gorilla/websocket/releases)
- [Commits](gorilla/websocket@v1.5.0...v1.5.3)

---
updated-dependencies:
- dependency-name: github.com/gorilla/websocket
  dependency-version: 1.5.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
TestWebsocketShutdownWaitsForInFlightWork closed its completion channel
after workDone(), but workDone() is precisely what releases Shutdown:
requestWg.Done() unblocks the waiter goroutine that closes done and lets
Shutdown return nil. When the worker goroutine was descheduled in that
window, the non-blocking select observed finished as still open and the
test failed with "Shutdown returned before tracked goroutine finished" --
as it did on the production builder for blockbook-arbitrum-archive while
the same suite passed for blockbook-ethereum-archive two minutes earlier
in the same job.

Signal completion before releasing Shutdown, so the ordering the test
asserts is guaranteed rather than hoped for: close(finished) now
happens-before requestWg.Done(), hence Shutdown cannot return while
finished is open. The elapsed >= 50ms assertion still catches a Shutdown
that fails to wait for in-flight work at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Document the protocol enrichment layer behind the ercProtocols column
family: the reusable storage, reorg-safety, probing and caching pieces,
how ERC-4626 uses them, and the steps to add a new protocol.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
chaincfg.SigNetParams is built by CustomSignetParams, which never sets
AddressMagicLen. The zero value made btcutil skip the version byte when
base58-decoding, so every P2PKH and P2SH address on Bitcoin and
Groestlcoin signet failed with "decoded address is of unknown size"
while bech32 addresses worked.

Fixes #1305

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
3.5.2 is exposed to two receipt/log correctness bugs that both land wrong
data in Blockbook's index, since ERC20 transfers are built from eth_getLogs:

- #23064: receipt domains were never rolled back on an in-RAM reorg unwind,
  because GetDiffset filled only the four domains that existed before
  ReceiptDomain was added. Small reorgs near the tip leave eth_getLogs
  serving phantom logs with wrong logIndex while eth_getBlockReceipts stays
  correct; the damage survives restarts and is frozen into snapshot files
  (erigontech/erigon#23062, observed on 3.5.2).
- #22951: wrong logIndex on archive nodes from receipt-domain reads
  bypassing the overlay DomainReader. Affects 3.5.1-3.5.4.

Also picked up on the way: the JSON-RPC handleBatch deadlock (#22459),
which Blockbook can hit through BatchCallContext, and a ~9-15 GiB/day
native memory leak in the RPC gzip path on archive nodes (#22700).

Drop-in at every hop, no re-sync. Checksums taken from the release's own
erigon_v3.5.5_checksums.txt.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tron has no account nonces (replay protection is ref-block + expiration)
and TronClient.NonceAt is a local stub, so EthereumTypeGetNonces was
returning a fabricated confirmedNonce of 0 whenever withConfirmed was
requested. Return constants and never claim a confirmed nonce.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(eth): batch contract metadata reads via Multicall3, negative-cache conclusive misses

Resolve name/symbol/decimals for all index-unknown contracts of an address in
one aggregate3 eth_call instead of up to three serialized calls per contract,
with a gas-starvation canary so a starved sub-call is re-read instead of
recorded as "not a token". Conclusive "no token at this address" verdicts are
kept in an in-memory LRU (15 min TTL, reorg-generation scoped) so repeated
requests stop re-probing them; chain read errors are never cached. The
ERC-4626 negative cache is generalized into the shared negativeProbeCache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(eth): clean up contract-info batch after review

- render the ContractInfo address form in EthereumRPC.GetContractInfo via
  the parser, one owner for single and batched paths; drop the TronRPC
  override and the batch path's field overwrite
- drop the dead filterDesc parameter of prefetchContractInfos: a
  single-contract filter can never fill a batch, so gate at the call site
  instead of paying a db read per contract
- derive the batch metric fields and aggregate3 sub-calls from one
  contractInfoFields table instead of two parallel lists
- collapse the two identical 15-minute negative-probe TTL constants into
  defaultNegativeProbeTTL owned by probe_cache.go
- delete the unreachable aggregate3 short-response guard (the decoder
  already rejects count mismatches) and an inconsistent nil guard
- merge the split nil-verdict handling in getProbedContractDescriptorInfo
- hoist erc4626ContractKey to one computation per token
- tests: t.TempDir, self-contained newContractProbeChain, deduped preambles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(eth): tighten contract-info batch comments

Cut the comments added by the Multicall3 contract-info batch down to
1-2-line why-notes and drop what the code already says.

- fix the negativeProbeCache doc, which sat on negativeProbeCacheEntry
- fix a test comment claiming the chunk size bounds contracts;
  Multicall3MaxCalls bounds sub-calls, contracts are (max-1)/3
- drop the (nil, nil) verdict paragraph repeated on three declarations
  and the wall-clock TTL rationale repeated on two
- drop the worker.go call-site block restating prefetchContractInfos

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* perf(evm): serve GetChainInfo backend identity from a 60s cache

GetChainInfo runs net_version and web3_clientVersion on every call, and it is on
the request path of /, /api/ and /api/v2/ as well as of every sync iteration. On
EVM chains those two are its only backend round trips - the tip already comes
from the subscription-fed bestHeader cache - so an Arbitrum node was answering
~140k of them a day from the sync loop alone, plus two per API request.

Both values change only when the backend is restarted onto a new build, so cache
them behind a 60s TTL: one caller refreshes while the rest keep serving the
previous snapshot, and a failed refresh is not retried for a TTL. This drops the
cost to a fixed two calls a minute, independent of API traffic.

Since these calls were the only thing in GetChainInfo that could observe an
unreachable EVM backend, the cache stops vouching for one after 5 minutes and
returns the error again, so backendError and inSync=false still work. Below that
a transient failure no longer flips a whole instance out of sync over a version
string. A changed chain id is logged as an error, keeping the sanity check the
per-request net_version gave us for free.

Avalanche gets the same treatment for its extra info.getNodeVersion call.

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

* fix(avalanche): do not hold nodeVersionMu across the info.getNodeVersion RPC

The avm version cache keyed its TTL suppression on already having a value, so a
node whose info endpoint is down - or that answers without an avm entry - never
reached the TTL branch and probed on every GetChainInfo. Worse, the probe ran
with nodeVersionMu held, so concurrent /, /api/ and /api/v2/ callers serialized
behind an RPC that can sit until b.Timeout, which is worse than the uncached code
this replaced.

Key suppression on the last attempt instead of on the value, and mirror the
backendIdentityCache lock discipline: one caller probes with the mutex released,
the rest return the current value at once.

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

* refactor(evm): rework the identity cache per review

- one generic TTLValue[T] replaces the eth and avalanche copies of the
  TTL + single-flight + serve-stale state machine
- refreshes of a warm cache run in the background, so no request ever
  blocks on an RPC that can sit until the timeout (and Avalanche no
  longer stacks two such windows)
- liveness keys on how long refreshes have been failing, not on snapshot
  age: a burst after an idle gap no longer reports a healthy backend down
- failed fetches retry after 5s instead of a full TTL, so recovery from
  a >5min outage is noticed in seconds, not up to a minute late
- the in-flight flag is reset in a defer, so a panicking fetch cannot
  wedge the cache; a panic in a background refresh is contained
- a chain id flip is latched and refused as an error instead of being
  adopted and served as healthy
- single-flight and retry pacing now also apply to a cold cache

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(evm): simplify the TTL cache and its call sites

- TTLValue moves to common/ - it is domain-free infrastructure and the
  next likely consumer (btc GetChainInfo) cannot import a coin package
- ttl and fetch are set once at construction: Get(now) is the whole
  call site, the per-request method-value allocation on the hot path is
  gone, and the conditionally-legal nil fetch argument no longer exists
- lastErr and failingSince fold into one ttlFailure struct so the
  invariant between them cannot be half-updated
- stateLocked no longer releases a mutex it did not acquire
- the preset panic error becomes a package sentinel (juju errors.New
  does a runtime.Caller per call)
- avalanche: the cachedNodeVersion/nodeVersionCached ladder collapses
  into probeNodeVersion returning (string, error) with the real RPC
  error instead of a ""-as-error round trip; the four decoded-but-unread
  response fields go away, dropping the repo's only avalanchego import
  (go.mod tidy left for an environment that can write the module cache)
- validatedChainID becomes atomic.Uint64 with CompareAndSwap so its
  safety no longer rests on a cross-file comment
- the avalanche test file re-tested TTLValue mechanics through the
  deleted wrapper; the mechanics tests live in common with the type

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…etention (#1749)

* fix(eth): accept an alternative cache retention equal to the mempool's

mempoolRetentionInverted rejected alternativeMempoolTxTimeout == mempoolTxTimeout,
so a deployment could not hold a private transaction as pending for exactly as long
as its address index survives - it had to give up a margin on one side or the other.

Equality is not the inverted case the guard exists for. SendRawTransaction stores the
cache entry before AddTransactionToMempool, so the cache timestamp is never the
younger one, and the cache's own exits clear the wrapped mempool too. Only a cache
configured to strictly outlive the mempool can leave a private transaction indexed
nowhere while still served as pending (#1573), so that alone stays rejected.

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

* chore(configs): hold private transactions for the whole mempool retention

A privately relayed transaction stuck behind a nonce gap has been mined ~40 hours
after broadcast. At the previous alternativeMempoolTxTimeout of 3h the cache dropped
it long before that, so the sender saw nothing pending and had nothing to cancel
while the transaction was still mineable.

Raise it to each coin's mempoolTxTimeout - 48h on ethereum, 12h elsewhere - which is
the longest the retention ordering permits, so the transaction stays visible and its
nonce slot stays reserved for as long as its address index lives. robinhood_archive
gains the explicit mempoolTxTimeout the others already carried.

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

* feat(eth): log when the alternative provider stops or resumes surfacing a tx

Whether a relay keeps answering eth_getTransactionByHash beyond its advertised
pending window was only inferable from the residence histogram, and not at all while
the cache retention was shorter than that window - every entry left first. It decides
whether a raised alternativeMempoolTxTimeout can keep a nonce-gapped transaction
pending at all, or whether the missing eviction takes it first.

Log the surfacing transitions instead: the first null of a run, a run the relay ends
by answering again, and each eviction, distinguishing a cache timeout reached with the
relay still answering from one reached on nulls. Logged per transition rather than per
probe, with the sender and nonce so the gap holding the transaction back is visible.

markMissing now reports whether it opened the run and clearMissing returns the run it
ended, which is what makes once-per-transition logging possible.

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

* chore(metrics): extend cache residence buckets to the raised retention

The buckets stopped at 12600s, chosen for a 3h cache retention. With the retention now
at 12h and 48h every long-lived exit falls into +Inf, hiding exactly the split the
raise was made to observe - a transaction the relay surfaced until the cache timeout
versus one evicted on nulls hours earlier.

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

* docs(eth): drop the assumption that the cache retention equals the relay window

Three places reasoned from the cache retention being the relay's 3h pending window, so
reaching the timeout meant the window had just closed and a final null was expected.
With the retention at 12h/48h that is inverted: an entry only gets that far if the
relay surfaced it nearly the whole time, so a null on the final probe is a coincidence
- which is exactly what the two timeout log lines now tell apart.

Also correct the alternativeMissingTxTimeout guidance: timeout-only eviction needs it
at or above the cache retention, which the pending window no longer implies.

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

* fix(metrics): remove the colon that broke metrics.yaml parsing

The bucket-extension rationale was appended to a plain YAML scalar with a colon in it,
which ends the scalar and reads as a mapping value. metrics.yaml is embedded and parsed
by every package that builds a metrics registry, so the whole unit-test suite failed on
it. The help texts here use dashes for this reason.

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

* fix(eth): restamp the wrapped mempool entry when a re-send overwrites its cache entry

A re-send of the same raw tx overwrites the cache entry with a fresh
timestamp, while AddTransactionToMempool keeps an existing wrapped
entry's original one. With the equal retentions the configs now ship,
the mempool sweep then drops the address index a whole rebroadcast gap
before the cache stops serving the tx as pending - the #1573 inversion
the removed 45h margin used to absorb.

insertMempoolTx now reports the same-txid overwrite and
cacheMempoolTransaction mirrors it into the wrapped mempool by removing
and re-adding the entry, so both stores age from the re-send. The fix
lives at this call site because the resync path calls
AddTransactionToMempool for every backend tx - refreshing there would
defeat the sweep. The false "written first, never younger" justification
in the guard comment and evm-send-mempools.md now cites the restamp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(eth): make the surfacing boundary logs match the exits reconcile takes

The missing-run transition promised eviction after missingTimeout, but
the check only reruns at the next due probe - 15 min for entries older
than an hour - so the eviction log three lines later routinely reported
7.5x the promised horizon. The transition now names the real one,
max(missingTimeout, probeInterval).

The probe-error branch also evicts at the cache timeout but logged only
the generic failure line, which fires identically for retained entries -
during a relay outage every boundary crossing went unrecorded. It now
logs its own boundary exit like the other two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(eth): back reconcile probes off to hourly past the relay's pending window

The ladder capped at 15 min, a cadence calibrated for the old 3h
retention; at 48h that is ~190 probes for one stuck tx, each a fresh
dial plus an eth_getTransactionCount per URL, against quota treated as
scarce (#1629). The hourly rung cuts the multi-hour tail ~4x for up to
an hour of drop-detection latency on entries already stuck past 3h;
timeout eviction is checked before the backoff gate and is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(eth): point the retention error at the effective knob and log drift

The startup error suggested lowering alternativePendingTxWindow, which
AlternativeMempoolTxTimeoutDuration ignores once an explicit
alternativeMempoolTxTimeout is set - and every shipped config now sets
one, so the suggested remedy could never resolve the failure. Name the
knob that feeds the comparison.

The guard also only rejects one drift direction: a later bump of
mempoolTxTimeout alone silently detaches the aligned retentions. That
direction is safe but shortens private-tx visibility, so log it once at
startup instead of guessing in a config review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(config): scope the raised-retention promise to relay surfacing behavior

With alternativeMissingTxTimeout left at its 2 min default, a relay
that stops answering at its advertised ~3h window has every stuck tx
evicted as provider_missing at roughly that window - the full cache
retention is only reachable when the relay keeps surfacing past it,
which the new transition logs are shipped to establish. Say so instead
of stating the outcome as fact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(eth): let the wrapped mempool own the re-send restamp

The caller-side remove + re-add reimplemented "refresh this entry's
time" through the coarsest API available: it briefly dropped the tx
from the address index, re-ran createTxEntry with a duplicate OnNewTx
push per rebroadcast, and keyed the decision on the provider cache
rather than the store being restamped - missing a tx indexed earlier
from the public feed and then re-sent privately.

AddOrRefreshTransactionInMempool on MempoolEthereumType now restamps an
existing entry in place under one lock; the cache path calls it
unconditionally and insertMempoolTx returns a single bool again. The
resync path keeps calling AddTransactionToMempool, whose doc now states
that an existing entry keeps its original time.

Also from the cleanup pass: the retention-drift startup log is gated on
an explicit mempoolTxTimeout via a mempoolRetentionDrifted predicate
(the derived default is cache + 30m by construction and logged as drift
on every boot), CreateMempool's two cacheEnabled branches merged, the
timed-out probe-error exit logs one line carrying the error instead of
stacking on the generic warning, a hand-rolled maximum became the
builtin max, and an unreachable nil guard in SetupMempool is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(eth): trim branch comments to brief why-focused notes

Condense the comments this branch added to 1-2-line clean-code notes,
dropping arithmetic, cross-references and rationale already stated at
the log sites or in the production comments the tests mirror.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(eth): align stale pending-window guidance with the raised cache retention

The retention raise decoupled the cache timeout from the 3h pending
window, but the alternativeMissingTxTimeout remedy in the ethrpc.go doc
comments and evm-send.md still pointed at the window, and the
evm-send-mempools.md diagrams still hardcoded the 3h/+30min pair, the
AddTransactionToMempool coupling and the pre-backoff probe tiers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(btc): serve mempool.space precise recommended fees (#1754)

Add a mempoolspaceprecise alternative fee provider consuming
mempool.space/api/v1/fees/precise (sub-sat/vB recommended fees) with
block targets mapped to Suite levels: 1->fastestFee, 3->halfHourFee,
6->hourFee, 500->economyFee, 1008->minimumFee. Conversion is exact
(no significant-digit rounding) so served values match the
mempool.space UI. Existing providers are untouched for backwards
compatibility; bitcoin, regtest and testnet4 configs switch to the
new provider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(btc): validate precise fees after conversion, not before

A raw positivity check let non-finite or extreme values through:
1e100 or NaN overflow int(math.Round(fee*1000)) with an
implementation-dependent result (negative on amd64) and values below
0.0005 round to zero, in both cases caching an invalid fee instead of
keeping the previous table. Range-check the rounded sat/kB value
before the float->int conversion and validate the converted entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A Tron send is the only moment Blockbook sees a transaction before it is
indexed, and with no pending-tx feed a later "my tx vanished" report has
nothing to reconcile against.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…routine (#1764)

Records in addrContractsCache are handed out of getUnpackedAddrDescContracts
after addrContractsCacheMux is released, and the block-connect path then mutates
them (Contracts append, Ids/MultiTokenValues insert, Txs and TotalTxs counters)
with no lock held. The 5-minute periodicStoreAddrContractsCache goroutine packed
those same records concurrently, holding only addrContractsCacheMux, which gives
no mutual exclusion against that mutation. BulkConnect's Ethereum path takes no
lock at all.

Because packUnpackedAddrContracts reads a slice length and then ranges the slice
as two separate reads, and cfAddressContracts is a positional varint format
whose element counts the unpacker trusts, an overlapping flush could emit a
record whose remaining bytes are shifted.

Replace the timer goroutine with storeAddrContractsCacheIfDue, called from
ConnectBlock and BulkConnect.connectBlockEthereumType once the period elapses.
The flush now runs on the only goroutine that mutates these records, so no
additional locking or copying is needed. This mirrors flushAddrContractsCacheIfOverCap,
which already flushes synchronously from the same path.

Dropping the goroutine also removes a shutdown hazard: it had no stop channel and
was never joined, so a tick landing inside closeDB() would have used column
family handles that were already destroyed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contract metadata strings are read from name()/symbol()/tokenURI() via
eth_call and decoded without any length limit, so an unusual contract can
return an oversized string that is then decoded and, for name/symbol,
stored in the cfContracts column family unbounded.

Add two independent bounds:
- cap the decoded string in parseSimpleStringProperty at 64 KiB, covering
  every reader including the live, unstored tokenURI path
- clamp stored name/symbol to 256 bytes (rune-safe) at packContractInfo,
  the single funnel for every write path (EVM single, Multicall3 batch,
  Tron)

Add regression tests for both bounds.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
GetXpubUtxo labelled every utxo with its branch position in
data.addresses instead of the change index that branch is configured
with, so any descriptor whose change indexes are not 0,1 in that order
got a path pointing at a different address of the same account.
GetXpubAddress already translates the position (fixed in ca3a023); this
brings the utxo path in line with it.

The two forms that hit it in practice are a single-branch change
descriptor (.../1/*, as exported by bitcoin-cli listdescriptors) and a
reordered or non-default multipath descriptor (.../<1;0>/*). Bare xpubs
and .../<0;1>/* are unaffected because position and index coincide,
which is why the existing coverage never caught it.

Affects /api/v2/utxo/<descriptor> and the websocket getAccountUtxo. Only
the path field was wrong; address, txid, vout and value were not.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd (#1756)

* fix(sync): un-wedge steady-state EVM sync that falls behind the backend

The sequential connect loop could never re-evaluate the parallel gate while
it ran: on chains producing blocks faster than the serial per-block cost
(block + logs + trace), the round never terminated and the index diverged
from the tip indefinitely (~130k blocks/day observed on Robinhood Chain).

- getBlockChain now probes the backend tip every 100 connected blocks and
  yields errResync when it falls more than max(4*workers, 64) blocks behind,
  letting resyncIndex re-enter the parallel path; yields are counted in
  blockbook_index_sync_yields{reason="fell-behind"}
- the steady-state parallel pool size now comes from the -workers flag
  (capped by the block range) instead of a hardcoded 4; the trigger
  threshold stays at 4 so small rounds keep their current behavior

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

* chore(sync): rename fell-behind yield reason to fell_behind and document it

Match the snake_case convention of the metric's other label values and
add the third reason to metrics.yaml, docs/sync.md and the Grafana panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed hash (#1742)

* fix(sync): don't treat a fetchable block as a reorg without an expected hash

getBlockChain passes its loop hash to shouldRestartSyncOnMissingBlock, but
ethereum-type chains leave BlockHeader.Next unset, so that hash is empty for
every block after the first of a sync round. The probe then compares a freshly
fetched hash against "" and always finds them unequal, yielding errResync and
turning normal backend tip lag into a full resync round per block.

Observed on avax.trezor.io, where QuickNode's load-balanced pool answers
eth_getBlockByNumber for the tip with coreth's "cannot query unfinalized data"
until the serving node has accepted the block.

Forks remain covered: getBlockChain's prevHash check catches a replaced block,
resyncIndex compares its own hash at localBestHeight, and the parallel path is
untouched because it always queues a real hash from GetBlockHash.

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

* fix(avalanche): widen tip recheck window for backend acceptance lag

tipRecheckThreshold=3 combined with the sequential path's internal 250 ms cap
allows only ~500 ms before a missing tip block is treated as suspicious, which
sits inside the normal acceptance skew of a load-balanced Avalanche pool. Raise
both thresholds to 14 (~3.25 s, roughly three AVAX blocks); recheckThreshold
has to move as well or ApplyMissingBlockRetryOverride clamps the tip value back
down to 10.

retryDelayMs stays at 1000 on purpose: the 250 ms cap applies only to the
sequential tip path, while getBlockWorker uses the raw value, so lowering it
would quietly speed up bulk-sync retries. avalanche.json carried no override at
all and was inheriting the same too-tight defaults.

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

* fix(eth): keep block-not-found sentinels unwrappable from getBlockRaw

errors.Annotatef from the juju/errors pin has no Unwrap, so annotating a
bchain.ErrBlockNotFound made stdErrors.Is fail for every caller downstream. In
getBlockChain that silently disabled two things: the end-of-chain exit at the
tip, gated on gotNotFound, and the IndexBlockNotFoundRetries counter.

It only shows up where a tip read fails with an error rather than a null result,
which on Avalanche is every tip read ("cannot query unfinalized data"), so the
sequential path never returned. Measured on blockbook-dev:
index_block_not_found_retries=0 next to index_resync_errors{error="get_block"}
=1594 over ~330 blocks — ~4.8 retries per block, one 1.17 s block period divided
by the 250 ms retry delay. With no ResyncIndex iteration ever completing,
updateBackendInfo stopped running, leaving blockbook_synchronized at 0 and
backend_best_height frozen while best_height kept advancing.

Return the sentinel unannotated so it stays comparable, matching what the
null-result branch directly below already does. Genuine tip skew still retries,
since height > bestHeight is false in that case.

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

* fix(eth): keep block-not-found sentinel unwrappable from eth_getLogs path

Review follow-ups for PR #1742: processEventsForBlock gets the same
sentinel carve-out as getBlockRaw so getBlockChain's end-of-chain exit
and retry accounting also work when a lagging pool member rejects
eth_getLogs, and onRetryableMiss no longer logs a chain-state recheck
on the by-height walk where shouldRestartSyncOnMissingBlock is a no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(eth): stop indexing CALLCODE frames as internal transfers

CALLCODE runs foreign code in the caller's own context, exactly like
DELEGATECALL, so the value the callTracer reports on such a frame never
leaves the caller. Indexing from->to credited the code address with ETH
it never received, letting a contract fabricate arbitrary internal
transfers to arbitrary addresses (issue #1225).

Fixes #1225

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

* fix(eth): index internal transfers only for CALL frames, warn on unknown types

Invert the trace-type deny-list into an explicit CALL allowlist so a future
value-carrying frame type cannot silently reopen the fake-transfer vector
(#1225); unknown types with value are logged instead of indexed. STATICCALL
joins the ignore branch so it is safe by design, not by the tracer omitting
its value field. Also replace the test-local hexToBig helper with
hexutil.MustDecodeBig.

Addresses review comments on #1738.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both Ethereum-type confirmation counts are unsigned subtractions with no check
that the reference height is actually ahead. computeConfirmations subtracts the
asynchronously cached tip from a height read straight from the backend, and the
tx cache subtracts the indexed best height from the chain height an
Ethereum-type tx was cached at. Either reference can be behind by a block or
more, and the count then wraps to ~4.29e9 instead of a small number.

Clamp both to 1, the mined minimum, matching the guard Tron's
computeBlockConfirmations already has.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(api): serialize xpub cache updates to prevent data race

The process-wide xpub cache handed out xpubData values whose addresses
backing arrays were shared, then mutated them outside cachedXpubsMux.
Two concurrent getAccountInfo requests (details >= txids) for the same
descriptor could race and, across a block connect, drop the newest
confirmed txid while leaving a matching (too-low) count and a correct
balance - a self-consistent but incomplete history served for roughly
one block interval.

Serialize updates per descriptor with a sharded lock and copy-on-write
the address slices, so every published cache entry is an immutable,
coherent snapshot. Distinct descriptors stay parallel and the caller's
per-tx response building stays concurrent.

Add a -race regression test that reproduces the write/write and
read/write races on the pre-fix code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(api): keep warm xpub cache hits parallel and skip needless clones

Address review comments:
- return the published snapshot without the per-descriptor lock when the
  request cannot mutate it (same tip, same gap, no txid load needed)
- clone the address matrix only when a rescan or txid load will mutate it
- extract needsRescan so the CoW guard and rescan trigger cannot drift
- test: preserve the initXpubCache nil sentinel, use t.TempDir/t.Cleanup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…1736)

* fix(api): match the outpoint when resolving a spending transaction

Without the extended index there is no direct output -> spender index, so
setSpendingTxToVout finds the spender by walking the address index and
matching on address, value and spending txid. It never checked which output
the candidate input references, so two outputs of one transaction paying the
same value to the same address both resolved to whichever spender was found
first - reporting a transaction that does not spend that output, and an equally
arbitrary spentIndex.

Require the input to reference this vout as well. Also return early for an
unspent output: it has no spender, and the scan has no early exit for that
case, so it would walk the address index all the way to the chain tip.
GetTransactionFromBchainTx already guards the same call this way.

Instances built with -extendedindex read the exact per-output SpentTxid and
were never affected. The per-output spent flag was always correct, so this
never affected balances or UTXO selection.

Fixes #1029

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

* test(api): address review comments on the spending txid test

- add the //go:build unittest tag the package's other tests carry
- pin the outpoint match directly: call setSpendingTxToVout on the
  unspent sibling, which the guard in GetSpendingTxid would skip
- use newRefreshTestMetrics instead of uniquified process-wide metrics
- use t.TempDir/t.Cleanup, drop the manual cleanup func
- build fixture blocks via the exported dbtestdata accessors
- pass a nil mempool; the tested path never touches it and the real
  one leaks sync goroutines
- drop cases covering pre-existing behavior (n=0, out-of-range vout)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

8 participants