Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ core.*
!PLAN.md
!DEVIATIONS.md
.antigravitycli/

# lean-ctx local tooling cache
.lean-ctx/
9 changes: 9 additions & 0 deletions CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,5 +267,14 @@ A window sized by whichever of two caps binds first. A count alone is wrong wher
### Identity-bearing key
A key that distinguishes which producer wrote a row, as opposed to one that merely locates it. The index's funding, spending, and txid keys are an 8-byte prefix plus a height, so two blocks at one height that share an output script derive identical keys, and rolling the first back a second time deletes the second's rows. The block-header row is identity-bearing because its key is the 80-byte serialized header and the block hash is the double-SHA256 of exactly those bytes. Checking it before deleting is a proxy for rekeying the other three families, taken because rekeying breaks the electrs-compatible layout and forces a reindex.

### Prefix-row rescan cost
The cost shape of every lossy-prefix index resolver: read the block once per matching row, deserialize it whole, then hash every output script in it to recover what the 8-byte prefix threw away. Measured, not asserted — `resolve_script_history` rises 63.9x for a 64x rise in funding rows and 3.6x for 4x the block bytes, so the two terms are linear and they multiply. The consequence is that per-row work is bounded by block size rather than by how many transactions actually matched, which is why an address funded at 64 heights costs 86.53 ms end-to-end against a 30 ms budget. `resolve_unspent_outputs` pays roughly double because it computes a txid for every transaction before checking any script, while `resolve_script_history` computes one only after a match. The fix is not a cheaper scan: it is carrying the matching transactions' byte positions in the row value, which is unused today, so resolution reads only those ranges and the exact-check survives untouched. See *Identity-bearing key* for what the same lossy prefix costs on the rollback side.

### All-or-scan position fallback
The invariant that lets index row values carry transaction byte positions without carrying block identity. Funding and txid keys are an 8-byte prefix plus a height, so a superseded block at the same height leaves rows whose positions point into a different block's body — and because the reader exact-checks what it decodes, the failure is a silently short result rather than an error, which is the worst shape a read path can have. Rather than pay 8 bytes per row for a block tag (measured: 0.66x on top of the 1.67x the positions already cost), the reader **falls back to a full block scan the moment any single position fails to resolve**, and trusts the position list only when every one of them matches. Stale offsets land at arbitrary points in unrelated bytes and essentially never decode to a matching transaction, so they take the fallback; an 8-byte prefix collision between two scripthashes takes it too, correctly, once per 2^64 pairs. The rule that makes this safe is that a failed position is never *skipped* — skipping one and keeping the rest is exactly how a partial result gets reported as a complete one. Accepts one residual: a stale offset landing precisely on a transaction boundary whose transaction also matches, while a different transaction in that block matches too. See *Prefix-row rescan cost* for what the positions buy and *Identity-bearing key* for the same lossy prefix on the write side.

### Paired-arm benchmark
A Criterion group holding the before and after implementations of one change over one identical fixture, so the ratio comes from a single run. Adopted because a stored baseline cannot be trusted across a rebuild, and because the before implementation is wanted anyway as the equivalence oracle — the same function serves both roles. Its second use is diagnostic: while both arms still call the same code, their spread *is* the harness noise floor, measured rather than assumed. That reading is what disqualified the 64-height `subscribe` and `get_balance` groups, whose identical arms differ by 2.0-2.4x on a laptop and therefore cannot resolve a 1.05x gate, while `get_history` held within 1%. A group that cannot resolve the gate does not get to report a win.

### Resolution-time sampling
Recording a statistic when its outcome is known rather than when the subject arrives. The fee estimator counted a transaction against every confirmation target the moment it entered, so a fresh arrival was already a failure at every target and a burst silenced the estimator before anything had missed a deadline. It also broke the decay: the denominator had been decaying since entry while a confirmation arrived undecayed, reporting 81 successes in 100 as roughly 85%. Sampling numerator and denominator together at the moment a target resolves fixes both, because they then decay from the same block. The counterpart rule is that a subject leaving for an unrelated reason is untracked without being sampled: an eviction says something about the mempool, not about whether the transaction would have confirmed.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions DEVIATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,53 @@ serving remains deferred.
- **G14 empirical validation still deferred.** The `faster than Bitcoin
Core` claim requires multi-day same-window live mainnet IBD against
`bitcoin-rs` and `bitcoind`. Operator responsibility.

## §8 — Task 8: index rows carry transaction byte positions

`PLAN.md` Task 8 specifies porting electrs verbatim, and electrs writes
`(8-byte prefix || height)` keys with **empty** values. This implementation puts
a packed `TxPosition[n]` in that unused value: the `(offset, length)` byte range
of every transaction that produced the row, within its block's serialized body.

### Why

Resolution was `O(funding rows x block size)`. `Indexer::resolve_script_history`
loaded and fully decoded the block once per row, then SHA256-hashed every output
script in it. Measured on synthetic fixtures, the cost rose 63.9x for 64x the
rows and 3.6x for 4x the block bytes — the two terms are linear and they
multiply. End-to-end, Electrum `blockchain.scripthash.get_history` cost 86.53 ms
for an address funded at 64 heights, against a G14 budget of 30 ms.

With positions the resolver reads only the named byte ranges. The block-size term
disappears: at 8 funding heights the same call costs 8.95 µs over 250 KB blocks
and 9.12 µs over 1 MB blocks. Full numbers, method, and mutation coverage are in
[`docs/benchmarks/index-read-path.md`](docs/benchmarks/index-read-path.md).

### What this costs

Funding and `TxConfirmed` row storage goes from 12 bytes per row (key only) to
20 (12 key + 8 value), a measured **1.67x** on those two families, uncompressed.
Spending rows are unchanged: nothing resolves them back to transactions today.

### Compatibility

Keys, key ordering and row counts are untouched, so an existing index keeps
working — a row with an empty value takes the whole-block scan path, which is the
verbatim electrs behaviour and is retained as `*_scan`. Nothing forces a reindex;
clearing the index directory and re-syncing is what earns the fast path.

`ColumnFamily::UtxoMeta` carries an `index:format_version` marker, adopted only
when the index is empty. A populated index without one is reported as
`IndexFormat::Legacy` and the node logs a startup warning naming the directory to
delete. It does not refuse to start: reads stay correct either way.

### The rule that makes it safe

Funding and txid keys carry no block identity, so a superseded block at the same
height leaves rows pointing into a different block's bytes. Rather than pay 8
more bytes per row for a block tag, the reader **falls back to a full block scan
the moment any single position fails to resolve**, and never skips a failed
position while keeping the rest. See the *All-or-scan position fallback* concept
in `CONCEPTS.md`. The residual accepted: a stale offset landing exactly on a
transaction boundary whose transaction also matches, while a different
transaction in that block matches too.
6 changes: 6 additions & 0 deletions crates/electrum/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,10 @@ tracing.workspace = true
[dev-dependencies]
proptest.workspace = true
serde_json.workspace = true
criterion.workspace = true
tempfile = "3"

[[bench]]
name = "electrum_methods"
harness = false
required-features = ["rocksdb"]
Loading
Loading