diff --git a/.github/workflows/fmt.yml b/.github/workflows/fmt.yml new file mode 100644 index 0000000..0405482 --- /dev/null +++ b/.github/workflows/fmt.yml @@ -0,0 +1,50 @@ +name: fmt + +# Gate formatting on every push and PR. +# +# Added 2026-08-13 with the first workspace-wide `cargo fmt` pass. Before that +# nothing enforced formatting and 831 diffs had accumulated across every crate — +# drift that is invisible until someone runs the command, and then arrives as a +# 90-file diff on top of whatever they were actually doing. +# +# Formatting only. Tests and clippy are deliberately NOT gated here: the +# workspace carries a pre-existing enr-p2p doc-lint that would fail a strict +# clippy gate on day one, and a test job needs submodules plus the sigma-rust +# git dependencies, which is a slower and separate decision. A gate that fails +# for reasons unrelated to the change gets ignored, and an ignored gate is worse +# than none. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + rustfmt: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v5 + # No `submodules:` — chain/, p2p/, state/ and store/ were absorbed into + # this repo in 35802a8 and are ordinary directories now. release.yml + # still passes submodules: true, which is a harmless no-op there. + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check workspace formatting + run: cargo fmt --all --check + + # The addons are `exclude`d from the workspace with their own lockfiles, + # so `--all` above does not reach them. They were formatted in the same + # pass and are checked explicitly here — otherwise they are exactly the + # code that drifts again, being the part no workspace command touches. + - name: Check addon formatting + run: | + cargo fmt --manifest-path addons/fastsync/Cargo.toml --all --check + cargo fmt --manifest-path addons/indexer/Cargo.toml --all --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4be5e4c..f56dfc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,21 +5,37 @@ name: release # and attaches both to a GitHub Release for the tag. on: + # Two ways in, and they must stay equivalent. push: tags: - 'v*' + # Merging a release/* PR into main releases what was merged. The tag is + # created by this workflow rather than by hand, so the tag and the merge + # cannot disagree about what shipped. + pull_request: + types: [closed] permissions: - contents: write # required for creating the release + uploading assets + contents: write # release + asset upload, and the tag push on the merge path jobs: build-and-release: runs-on: ubuntu-24.04 + # A closed PR is not a merged one, and only release/* branches release. + # Without this the job would run on every closed PR, including abandoned + # ones. + if: >- + github.event_name == 'push' || + (github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/')) steps: - name: Checkout (with submodules) uses: actions/checkout@v5 with: + # On the merge path the PR ref is gone; take main, which now + # contains the merge commit. + ref: ${{ github.event_name == 'pull_request' && 'main' || github.ref }} # Top-level submodules only — fetches chain, p2p, state, store, # facts but NOT their nested facts/ pointers. The nested # facts/ submodules are docs read by per-submodule Claude @@ -29,6 +45,65 @@ jobs: submodules: true fetch-depth: 0 + # Resolves to the pushed tag on the tag path, or creates one from + # Cargo.toml on the merge path. Everything downstream reads + # steps.tag.outputs.name and does not care which path it came from. + - name: Resolve release tag + id: tag + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "push" ]; then + echo "name=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Read the literal out of [workspace.package] — the one place it + # exists, since every member inherits it via version.workspace. + # Scoped to the section rather than "first ^version line": that was + # correct only by accident of section ordering, and this is the step + # that decides what gets tagged. + version=$(awk ' + /^\[workspace\.package\]/ { in_section = 1; next } + /^\[/ { in_section = 0 } + in_section && /^version[[:space:]]*=/ { + match($0, /"[^"]*"/) + print substr($0, RSTART + 1, RLENGTH - 2) + exit + } + ' Cargo.toml) + if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then + echo "::error::could not read a version from [workspace.package] in Cargo.toml (got '${version}')" + exit 1 + fi + tag="v${version}" + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "::error::tag ${tag} already exists — bump the workspace version before merging a release branch" + exit 1 + fi + # Annotated, with the CHANGELOG section as the message: the release + # body is sourced from the annotation further down, and a lightweight + # tag would leave it empty. + # + # Prefer the release's "### Release summary" subsection: one line per + # change, no reasoning, which is what belongs on a release page. The + # rest of the CHANGELOG entry is the durable record and reads as a + # wall of text there. + # + # Falls back to the whole section when that subsection is absent, so + # re-tagging any older release still produces the body it always did. + section=$(awk -v v="## ${tag}" '$0 ~ "^" v {f=1; next} /^## v/ {f=0} f' CHANGELOG.md) + notes=$(printf '%s\n' "$section" | awk ' + /^### Release summary/ { f=1; next } + /^### / { f=0 } + f + ' | sed '/^$/d') + [ -z "$notes" ] && notes="$section" + [ -z "$notes" ] && notes="Release ${tag}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${tag}" -m "${notes}" + git push origin "${tag}" + echo "name=${tag}" >> "$GITHUB_OUTPUT" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -81,7 +156,7 @@ jobs: git fetch --tags --force { echo 'body<> "$GITHUB_OUTPUT" diff --git a/CHANGELOG.md b/CHANGELOG.md index d10dc14..311e8c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,351 @@ # Changelog +## v0.8.0 — 2026-08-13 + + + +### Release summary + +- Deferred script evaluation removed; scripts always evaluate before persisting. +- Config keys `script_eval`, `eval_backlog_max_mb`, `eval_backlog_max_blocks` removed. +- `cache_mb` is now the total redb cache across both databases, split by the new `cache_store_pct`. +- Default `cache_mb` is 512. +- Cache sizes and flush thresholds are derived from the memory budget when not set. +- New `memory_budget_mb` and `ignore_memory_floor` settings. +- A `utxo` node refuses to start below a 3 GiB memory ceiling; `digest` and `light` are unaffected. +- jemalloc runs with `background_thread:true` and 5 s decay windows. +- Journal events contract 1.6 → 2.1; consumers pinned to major 1 must be updated. +- `validation_stuck.error_kind` is derived from the error variant, not its text. +- `BlockValidator` split into `BlockValidator`, `StatePersistence` and `MiningState`. +- Configuration is layered across `/etc/ergo-node/conf.d/`. +- `/etc/ergo-node/ergo.toml` is migrated to `conf.d/99-local.toml` on upgrade and is no longer a conffile. +- The systemd unit no longer passes a config path. +- New debconf first-install interview for the `.deb`. +- `install.sh` reads the shipped per-network defaults. +- Workspace member crates inherit their version from `[workspace.package]`. +- `/debug/memory` reports `storeCacheBytes`, `stateCacheBytes` and `storeCacheEvictions`. +- Fixed: external miners were served the difficulty as `b` instead of the Autolykos target, so no solution was ever found. +- Fixed: a node restarted at the chain tip served no mining candidate until the next block arrived. +- Fixed: the mempool rejected every transaction offered to it. +- Fixed: the shipped man page had the mainnet and testnet API ports inverted. +- Fixed: `MiningCandidate.b` was documented as a JSON string but is emitted as a number. + +### Removed + +- **BREAKING — deferred script evaluation is gone.** `apply_state` now always + evaluates a block's scripts itself, after the state-root check and **before + persisting**, so `Ok` means the scripts passed and no block whose scripts are + unverified reaches `state.redb`. The config key **`script_eval` is removed** + (a node whose `ergo.toml` still sets it will fail to start with an unknown-key + error), along with `eval_backlog_max_mb` and `eval_backlog_max_blocks`, which + bounded a queue that no longer exists. + + This release both bounded that queue and then deleted it; only the deletion is + in the product. Deferred evaluation let application run ahead of verification, + which bought sync throughput and cost a crash-consistency window plus a second + source of truth for how far the chain was verified. Every bug in it had that + one root: a drain-local reorder buffer that froze a watermark for 190,000 + blocks; an in-flight counter zeroed while its tasks still held their heap; and + an unbounded backlog that killed a 4-thread 1.8 GHz host at 10.62 GiB. The + lag is gone, so the class is gone. + + Nodes upgrading keep a stale `script_verified_height` key in `chain_meta`. + Nothing reads it; there is no migration step. + +### Changed + +- **BREAKING (packaging) — `/etc/ergo-node/ergo.toml` is no longer a conffile.** + On upgrade it is moved, once, to `/etc/ergo-node/conf.d/99-local.toml`, and + keeps winning over the package defaults exactly as before. Your settings are + preserved byte for byte; nothing is merged into or rewritten in that file. + + The systemd unit no longer passes a config path — it would name a file that + the upgrade has just moved. The node searches `./ergo.toml`, + `~/.config/ergo-node/ergo.toml` and `/etc/ergo-node/ergo.toml`, then layers a + sibling `conf.d/` on top, and either half may stand alone. Tarball installs + with a single `ergo.toml` and no `conf.d` keep working unchanged. + + Verified as an in-place upgrade of a live mainnet node at tip: the migrated + file was byte-identical, every configured value survived the merge, and the + service came back up on the merged document. + +- Workspace member crates inherit their version from `[workspace.package]` + instead of each pinning `0.1.0`. They are internal path dependencies and the + numbers had not moved since each crate was absorbed, so `cargo metadata` + reported a 0.1.0 workspace for a 0.8.0 node. The excluded addons keep their + own versions and cadence. + +- **BREAKING — `cache_mb` now means the TOTAL redb page cache across both + databases**, split by the new `cache_store_pct` (default 50, valid 1–99). + It previously sized `state.redb` alone while `modifiers.redb` silently took + redb's built-in 1 GiB default, so a config saying `cache_mb = 1024` actually + used 2048 MB. Existing configs will use **less** memory than before; raise + `cache_mb` if sync throughput regresses. `synced_cache_mb` is likewise a + total and the same split applies to the at-tip resize. + +- Default `cache_mb` is now 512. In real terms that is a reduction: the old + effective default was 256 (state) + 1024 (modifiers, unchosen) = 1280 MB. + +- jemalloc now runs with `background_thread:true` and 5 s decay windows. + Without a background thread an arena's dirty pages decay only when a thread + next touches that arena, and with 4 × ncpu arenas the quiet ones never get + touched — measured at 1021 MB of freed-but-unreturned memory during a + genesis sync. + +- **BREAKING — the journal-events contract goes 1.6 → 2.1** (`journalEventsVersion` + in `/info`). **2.0 is the breaking step**; 2.1 is additive on top of it and + adds `memory_budget_derived` (see the memory-sizing entry above), so a + consumer updated for major 2 needs nothing further. + + Three **stable** events were removed — `deferred_eval_backlog` + (replaced by `catchup_progress`), `deferred_eval_gate_engaged` and + `eval_frontier_hole` — and two field domains narrowed: + `validation_rollback_failed.path` loses `eval_failure`, and + `validation_stuck.error_kind` loses `script_eval` in favour of + `transaction_invalid`. All of them described the deferred queue. + + **Consumers pinned to major 1 will refuse to parse a v0.8.0 node until they + are updated**, the Ergo Node Doctor adapter among them. The contract's own + rules also call for a deprecation release, which this did not get: emitting a + deprecated `eval_frontier_hole` from a node that has no frontier would mean + fabricating its fields. + +- `validation_stuck.error_kind` is now derived from the `ValidationError` + **variant** rather than by matching its `Display` string. The string-based + classifier is why the script-failure case silently stopped being distinguished + when deferred evaluation was removed — nothing referenced the variant, so + nothing broke. `missing_key` reaches the classifier through two variants and + means different things in each: local storage damage in UTXO mode, a deficient + proof in digest mode. Check `stateType` before recommending a resync on it. + +- `BlockValidator` is split into three traits: the consensus core, plus + `StatePersistence` (`flush`, `resize_cache`) and `MiningState` + (`proofs_for_transactions`, `emission_box_id`), both implemented only by the + UTXO validator. Digest mode signals itself by *not* implementing them rather + than by returning a harmless value — the shape that let a missing + `resize_cache` forward return `Ok(())` and log success for a resize that never + happened. Affects anyone building against `ergo-validation`. + +- **The node sizes its own memory.** Every key in the memory section is now + optional and **derived when absent**: the node reads its ceiling from its + cgroup limit (systemd `MemoryMax`, container `--memory`) or, failing that, + takes a conservative share of `MemTotal`, and sizes caches and the flush + threshold from what is left after the parts no knob governs. A key that IS + set is obeyed exactly, and disables derivation for that key alone — partial + configuration is normal. + + New optional `memory_budget_mb` states the budget directly. Prefer + `MemoryMax` on the unit: a cgroup limit bounds the node for real, whereas + the config key only tells it what to aim for. + + This closes a gap that was invisible until it was measured: a node run under + `MemoryMax=1500M` had no idea, sizing itself from a TOML file while the + kernel knew the answer exactly. It also fixes `flush_heap_threshold_mb` + defaulting to 4096 — above total RAM on any box small enough to care, so the + trigger it governs was inert exactly where it was needed. + + Derivation is logged at startup as `memory budget derived` with every input + and output (journal-events **2.1**), because auto-sizing's failure mode is + not being wrong, it is being wrong invisibly. + + `flush_max_blocks` and `flush_min_blocks` are **not** derived — they bound + crash-recovery work in blocks, and nothing measured relates a block count to + a memory budget. + +### Fixed + +- **A node restarted at the chain tip served no mining candidate at all.** + `/mining/candidate` returned 503 until a peer delivered the next block — + observed in the field as over an hour with three peers connected. Where the + restarted node is the only miner, it never recovered: no candidate, so no + block, so no application, so no candidate. + + `UtxoValidator` reconstructed none of its derived mining state on resume. + `emission_box_id` is discovered by scanning a block's insertions during + `apply_state`, so a freshly constructed validator reported `None` — which is + also the documented value for "all ERG has been emitted", making the two + indistinguishable. The mining proof cache had the same shape one level up. + + Both are now recovered at startup: the emission box from the block at the + resume height (the coinbase recreates it every block, so one block carries + it), and the proof cache seeded from the restored tip. Verified against a + parked mainnet tip at height 1,851,751 with no reachable peers: 503 for 40s + before, a candidate two seconds after startup now. + + Predates this release. It could not be hit before it, because external + mining did not work at all — see the entry below. + +- **The shipped man page had the REST API ports inverted.** `ergo-node-rust(8)` + said mainnet 9052 / testnet 9053; it is the other way round. The markdown + source was corrected in v0.6.10 and `man/build` was never re-run, so the + installed `.gz` carried the wrong values from v0.6.0 onward. + +- **External mining could never find a block — in any release before this one.** + `GET /mining/candidate` served the **difficulty** in the `b` field where the + Autolykos **target** belongs. The target is `q / difficulty` for the secp256k1 + group order `q`; we sent the undivided difficulty, a bound tens of orders of + magnitude tighter than the one the node itself enforces. A miner given it + searches for a hash that will effectively never occur. + + The symptom is silence, which is why it survived so long. The candidate is + well-formed, a real miner parses and accepts it, and then simply never submits + anything. Against a live node at testnet height 485,897 we served + `3912040448` where the Scala node served + `29598898778389163379010897437604384363675568080188445020547283242588`. A GPU + at 143 MH/s ran nine minutes and submitted **zero shares** — not zero accepted, + zero found. The same rig against a Scala node had a block accepted in 16 + seconds. + + Solution *validation* was never affected: it goes through `check_pow`, which + divides correctly. Serve-side and verify-side disagreed and verify-side was + right, so the node stood ready the whole time to accept a block it had made + impossible to find — confirmed by patching only the miner's bridge to + substitute the correct target, with no change to the node: block found and + accepted 50 seconds later. + + **This is not a v0.8.0 regression.** It dates to the commit that completed the + mining crate and is present in every tagged release, `v0.1.0` through + `v0.7.11`. If you have ever pointed an external miner at this node and + concluded your hardware or pool setup was at fault, it was not. Block + validation, consensus and solo-mining-via-the-node's-own-candidate-assembly + were never affected — only the number handed to external miners. + + The target is now a named primitive, `enr_chain::pow_target(n_bits)`, called + by both the serve path and PoW verification, rather than a division open-coded + at each call site. Regression tests pin the served `b` to the value observed + on a Scala node and assert that it is the same bound the solution path + enforces. + +- **`MiningCandidate.b` was documented as a JSON string** in + `facts/openapi.yaml` while the wire has always emitted a bare number. A client + generated from the published spec did not lose precision, it failed to parse. + Now `number`, with the arbitrary-precision caveat stated — a real target is + ~68 digits and must be read as a raw token, not an IEEE-754 double. The + schema's `proof` field was also described as "sigma-proof fields"; it is + `msgPreimage` plus Merkle membership proofs, and is omitted rather than empty + because a nested object overflows the reference miner's fixed jsmn token + buffer. + +- **The mempool accepted nothing.** A synced node rejected every transaction the + network offered it with `Creation height H+1 > preheader height`, so it held a + permanently empty pool: no relay, no fees, and mining candidates carrying only + the emission transaction. + + The state context published to the mempool and the REST API was built with the + block just applied as its preheader, leaving it at the current tip. Wallets set + `creationHeight` to the block they expect to land in, and ergo-lib enforces + `creationHeight <= preHeader.height`, so the check failed by exactly one block + for every well-formed transaction. The node now publishes an **upcoming** + context — `build_upcoming_state_context`, preheader at tip+1 — matching the + JVM, which validates mempool transactions against + `ErgoStateContext.simplifiedUpcoming()`. + + Block validation was never affected: there the block's own header is the + correct preheader, so consensus was not at risk. Mining was never affected + either; it builds its own stub at height+1, which is why candidate assembly + kept working while the mempool starved. + +- **The fee was read as zero for every transaction.** `extract_fee` computed + `input_sum - output_sum`, but ergo-lib enforces exact ERG preservation, so that + expression is structurally zero for anything that validates — Ergo has no + implicit change-to-fee remainder. Every transaction was then declined against + `min_fee`. Fees are now summed from outputs guarded by the fee proposition, + matching `ErgoMemPool.extractFee`, with the tree built once at construction. + + This was masked by the preheader defect: validation failed first, so nothing + ever reached the fee check. Fixing either alone would have left the mempool + empty. + +- **A transaction one block ahead of us was cached as invalid** for + `invalidation_ttl` (1800 s ≈ 15 blocks), suppressing every rebroadcast without + re-validating it. The condition is transient — the sender is simply ahead, and + the transaction is valid once the next block applies — so it is now `Declined` + rather than `Invalidated`, the same treatment a not-yet-visible input box + already received. `cleanup.rs` carried the same flaw on the reorg path, where a + reorg moves the preheader backwards over transactions re-pooled unvalidated. + + Verified on live mainnet traffic at tip: the pool fills and drains with each + block, and candidates reached 47 transactions. + +- `ergo_avltree_rust` moves to fork rev `b955790`. `BatchAVLProver::restore_root` + now clears `base.modified_nodes` itself, and `PersistentBatchAVLProver::rollback` + delegates to it rather than hand-rolling a second rewind. Previously every + rejected block pinned its touched node set for the life of the process. + Upstream as PR #27. + +- `addons/indexer/Cargo.lock` recorded `0.2.7` against a manifest at `0.2.8`, + left behind by the previous release. The addons are excluded from the + workspace with their own lockfiles, so no workspace build would ever have + caught it. + +### Added + +- `/debug/memory` reports `storeCacheBytes`, `stateCacheBytes` and + `storeCacheEvictions`. redb was the largest single consumer during cold sync + and was entirely invisible to this endpoint. + + Read `storeCacheEvictions`, not `storeCacheBytes`, to tell whether a cache + is under pressure: occupancy tracks the live working set, not the configured + ceiling, and reads identically at 8 MiB and 1 GiB under the same load. + +- **Layered configuration under `/etc/ergo-node/conf.d/`**, and a debconf + first-install interview for the `.deb`. + + Three layers, merged in filename order: `00-defaults.toml` (per-network + package defaults, refreshed from `/usr/share/ergo-node-rust/defaults/` on + every upgrade), `50-debconf.toml` (your interview answers, rewritten on + every upgrade and `dpkg-reconfigure`), and `99-local.toml` (**yours** — the + package never writes it). Put your changes in `99-local.toml`; edits to the + other two are lost on the next upgrade. + + Merging is per key, not per file: setting `max_peers` in a later layer does + not drop `seed_peers` from the same section. A bare array replaces; three + fields — `seed_peers`, and `include_ips`/`exclude_ips` under + `[debug.p2p_capture]` — also accept an `_add` suffix that appends to the + shipped list instead. + + The interview asks two things unconditionally — network, then a checklist of + topics — and then only the questions belonging to checked topics. Skip + everything and you still get a working mainnet node. + `DEBIAN_FRONTEND=noninteractive` completes without prompting. + + This exists because "what a first config looks like" was written down in + four places that had already drifted, and a debconf template hardcoding + ports and seeds would have become the fifth. Network-dependent values now + have exactly one home. `install.sh` reads the same files. + +- **Startup memory floor.** A **utxo** node now refuses to start when its + resolved memory ceiling is below **3 GiB**, and warns below 4 GiB. `digest` + and `light` hold no AVL prover tree and are never refused — on a small + machine, those are the modes to run. `ignore_memory_floor = true` under + `[node]` downgrades the refusal to a warning. + + The check is on the ceiling, not on free memory at the moment you start, so + it gives the same answer every time rather than one that depends on what the + page cache happens to be holding. + + A second warning fires when the *derived* budget lands under the measured + cold-sync peak. These are not the same test: with nothing stating a budget + the node takes a conservative share of `MemTotal`, so 4 GB of RAM becomes a + 2 GB budget — it clears the ceiling check and is the configuration most + likely to struggle. Setting `MemoryMax` on the unit, or `memory_budget_mb`, + roughly doubles what the node will allow itself on the same hardware. + +### Known limitation + +- An **in-place** at-tip cache resize moves only ~90% of the budget. + `set_cache_size` splits its argument 90% read / 10% write, and the in-place + path reaches only the read half; the write buffer is fixed at open time and + redb exposes no setter for it. `synced_cache_mb = 128` therefore yields + roughly 115 MB of read cache plus a write buffer sized from the cold-sync + total. A full restart applies both halves. + ## v0.7.11 — 2026-08-03 - **Fix demoted headers clobbering `BEST_CHAIN` after a reorg — the node diff --git a/CLAUDE.md b/CLAUDE.md index 748e4f0..58074ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,12 +26,13 @@ Full Ergo blockchain node in Rust. Not a port of the JVM node — a ground-up im **Main session does not edit crate-internal source files.** All work inside `sync/`, `validation/`, `state/`, `store/`, `mempool/`, `api/`, -`mining/`, `p2p/`, `chain/`, or `addons/*/` is dispatched to a +`mining/`, `p2p/`, or `chain/` is dispatched to a per-crate Claude session via the `dispatching-prompts` skill. Main session edits are limited to top-level orchestration: `Cargo.toml` (workspace), `README.md`, `CHANGELOG.md`, `LICENSE`, `build-deb`, `deploy/`, `man/`, top-level docs, scripts under repo root, the -prompt files in `prompts/`, **and the `facts/` directory** — the +prompt files in `prompts/`, **the `addons/` tree** (see below), +**and the `facts/` directory** — the contracts are main session's responsibility. Dispatched sessions read contracts via `../facts/` and deliver code that conforms to them; they do not edit those contracts. When a contract needs @@ -43,6 +44,17 @@ directory's boundary — it does not edit parent or sibling directories. The repo-root `facts/` directory is the single source of truth for cross-crate contracts. +**`addons/` is main session's, including source.** `addons/fastsync` +and `addons/indexer` are small, focused packages that don't warrant +their own session and the scaffolding it needs — and they have none +of it: no per-crate `CLAUDE.md`, no `SPECIAL.md`, no contract in +`facts/`. They are also `exclude`d from the workspace with their own +lockfiles. Dispatching into them would land a session in a directory +with none of the context the dispatch protocol assumes. Main session +edits them directly, source included. (Their version bumps and +lockfile re-syncs at release time were always main's anyway, under +the mechanical-housekeeping exception below.) + **Exception — mechanical housekeeping crosses boundaries.** Uniform, cross-cutting metadata edits applied identically across the workspace — dependency pin/rev bumps (e.g. bumping the sigma-rust git rev in every @@ -60,7 +72,7 @@ dependency decision — which still dispatches. ## Goal -Replace the JVM reference node with a Rust implementation that is memory-safe, efficient, and IPv6-native. The P2P layer (`ergo-proxy-node`) is complete and running on testnet. This project builds everything above it: header validation, block validation, UTXO state management, mempool, chain sync, and storage. +Replace the JVM reference node with a Rust implementation that is memory-safe, efficient, and IPv6-native. The P2P layer lives in this repo as the `p2p/` crate — handshake, framing, routing, IPv4/IPv6 — and everything above it is built here too: header validation, block validation, UTXO state management, mempool, chain sync, and storage. ## Architecture @@ -79,8 +91,8 @@ Single-repo, multi-session development: | Chain sync | `sync/` | **Done** | State machine + snapshot bootstrap | | Block/modifier storage | `store/` | **Done** | redb backend, height-indexed | | Mempool | `mempool/` | **Done** | Validate-on-entry, replace-by-fee | -| REST API | `api/` | **Done** | 23 endpoints + `/debug/memory` | -| Mining | `mining/` | **Done** | Autolykos v2 candidate assembly | +| REST API | `api/` | **Done** | 43 endpoints — 38 JVM-compatible + `/debug/memory`, 3 × `/debug/p2p-capture/*`, `/stats/p2p` | +| Mining | `mining/` | **Done** | Autolykos v2 candidate assembly: mempool selection bounded by cost and serialized section size, fee collection capped by measured box size, candidate regenerated on TTL expiry. Verified at runtime against mainnet tip | | Soft-fork voting | (in `chain/`, `validation/`) | **Done** | Epoch-boundary parameter tracking, v6.0.3-compatible | | At-tip memory tuning | (in `sync/`) | **Done** | Runtime AVL DB cache resize on synced() (v0.4.0+) | | Contracts | `facts/` | — | Per-component contract markdown | @@ -90,26 +102,33 @@ Single-repo, multi-session development: - **Design by Contract**: every component boundary has explicit preconditions, postconditions, and invariants. Contracts are documented in `facts/` and enforced via `debug_assert!`. - **The wire is the spec**: the Ergo P2P protocol has no formal specification. Protocol behavior was reverse-engineered from the JVM reference node and verified against pcap captures. See `docs/protocol/` for the wire format spec. - **Reuse before building**: the Rust Ergo ecosystem has substantial existing components. Use them. See the ecosystem inventory below. -- **Incremental validation**: each phase adds one capability without breaking what came before. The node starts as a proxy and gains validation layers progressively. +- **Incremental validation**: each phase adds one capability without breaking what came before. Early builds forwarded traffic without understanding it and gained validation layers progressively; the `[proxy]` config section is a leftover of that era, not a statement about what the node does now. ## Existing Rust Ecosystem (Inventory) ### Ready to Use -| Component | Crate | Version | Last Active | What it does | +*Dates are the upstream repo's last push, checked 2026-08-13 via the GitHub API +— not the crates.io release date, which is much older: `ergo-lib-v0.28.0` was +published 2024-08-09. Every sigma-rust crate shares one repo and therefore one +date. We consume them from `mwaddip/sigma-rust` pinned by rev, not from +crates.io, so the version column is the last published release rather than what +the build actually resolves.* + + +| Component | Crate | Version | Upstream last push | What it does | |---|---|---|---|---| -| ErgoTree interpreter | `ergotree-interpreter` | 0.28.0 | Feb 2026 | Full script evaluator, 70+ opcodes, sigma protocols | -| Transaction validation | `ergo-lib` | 0.28.0 | Feb 2026 | Stateful: ERG/token preservation, script verification, storage rent | -| Transaction signing | `ergo-lib` | 0.28.0 | Feb 2026 | Wallet, multi-sig, BIP-39/44, coin selection, tx builder | -| Box/UTXO primitives | `ergo-lib` | 0.28.0 | Feb 2026 | ErgoBox, registers, tokens, ErgoStateContext | -| Block header types | `ergo-chain-types` | 0.15.0 | Feb 2026 | Full Header struct with Autolykos solution | -| Autolykos v2 PoW | `ergo-chain-types` | 0.15.0 | Feb 2026 | `pow_hit()`, compact bits, table size growth | -| NiPoPoW verification | `ergo-nipopow` | 0.15.0 | Dec 2021 | Full KMZ17 algorithm, proof comparison, best chain | -| AVL+ authenticated tree | `ergo_avltree_rust` | fork | Apr 2026 | Prover + verifier, batch operations — forked to fix `Resolver` type for persistence ([PR #10](https://github.com/ergoplatform/ergo_avltree_rust/pull/10)) | -| Merkle proofs | `ergo-merkle-tree` | 0.15.0 | Feb 2026 | Tree, proof, batch multiproof | -| ErgoScript compiler | `ergoscript-compiler` | 0.24.0 | Feb 2026 | Source to ErgoTree | -| Scorex serialization | `sigma-ser` | — | Feb 2026 | VLQ, ZigZag, binary encoding | -| P2P networking | `ergo-proxy-node` | 0.1.0 | Mar 2026 | Handshake, framing, message routing, IPv6 | +| ErgoTree interpreter | `ergotree-interpreter` | 0.28.0 | 2026-08-04 | Full script evaluator, 70+ opcodes, sigma protocols | +| Transaction validation | `ergo-lib` | 0.28.0 | 2026-08-04 | Stateful: ERG/token preservation, script verification, storage rent | +| Transaction signing | `ergo-lib` | 0.28.0 | 2026-08-04 | Wallet, multi-sig, BIP-39/44, coin selection, tx builder | +| Box/UTXO primitives | `ergo-lib` | 0.28.0 | 2026-08-04 | ErgoBox, registers, tokens, ErgoStateContext | +| Block header types | `ergo-chain-types` | 0.15.0 | 2026-08-04 | Full Header struct with Autolykos solution | +| Autolykos v2 PoW | `ergo-chain-types` | 0.15.0 | 2026-08-04 | `pow_hit()`, compact bits, table size growth | +| NiPoPoW verification | `ergo-nipopow` | 0.15.0 | 2026-08-04 | Full KMZ17 algorithm, proof comparison, best chain | +| AVL+ authenticated tree | `ergo_avltree_rust` | fork, pinned by rev | 2026-08-03 | Prover + verifier, batch operations. Fork carries persistent-prover support ([PR #27](https://github.com/ergoplatform/ergo_avltree_rust/pull/27)); `Resolver` is [#16](https://github.com/ergoplatform/ergo_avltree_rust/pull/16), superseding the closed #10 | +| Merkle proofs | `ergo-merkle-tree` | 0.15.0 | 2026-08-04 | Tree, proof, batch multiproof | +| ErgoScript compiler | `ergoscript-compiler` | 0.24.0 | 2026-08-04 | Source to ErgoTree. **Available, not a dependency** — the node never compiles source | +| Scorex serialization | `sigma-ser` | 0.19.0 | 2026-08-04 | VLQ, ZigZag, binary encoding | ### Built (this project) @@ -144,7 +163,8 @@ operators today. Listed for awareness: | Crate | Status | |---|---| | `ergo-utilities-rust` | Abandoned, pinned to ergo-lib 0.13 (current: 0.28) | -| sigma-rust `ergo-p2p` | Architecture only, codec is `todo!()`. Our proxy supersedes this. | +| sigma-rust `ergo-p2p` | Architecture only, codec is `todo!()`. This repo's `p2p/` crate supersedes it. | +| P2P **relay proxy** (the original `ergo-proxy-node` concept) | Abandoned as impractical. Several nodes sharing one proxy share one peer identity, so a single misbehaving node behind it gets **the proxy banned for everybody** — the well-behaved nodes lose their peers for someone else's conduct, with no way to attribute or isolate the offender. The networking code was worth keeping and is now `p2p/`; the relay in front of it was not. | | `ogre` (TypeScript) | Abandoned light node attempt (April 2023) | ## Phased Build Order @@ -165,9 +185,11 @@ Validate blocks in digest mode (AD proofs, `BatchAVLVerifier`) and UTXO mode (`P Persistent AVL+ tree over redb (`enr-state` crate). Implements `VersionedAVLStorage` from forked `ergo_avltree_rust`. Undo-log rollback, configurable version retention, crash-safe atomic writes. Genesis bootstrap from chain parameters via ported `ErgoTreePredef`. Sliding 192-block download window for sequential sync. **Done.** ### Phase 6: Full Node — **Done** -Mempool, REST API (23 endpoints + `/debug/memory`), mining API, +Mempool, REST API (43 endpoints, 38 of them JVM-compatible), mining API, soft-fork voting, NiPoPoW serve/verify, UTXO snapshot bootstrap, light -client mode, at-tip memory tuning. Released as v0.4.x. +client mode, at-tip memory tuning. First released as v0.4.x; current release +is **v0.8.0**, which removed deferred script evaluation and split +`BlockValidator` into three traits (see `CHANGELOG.md`). ## Protocol Reference @@ -184,5 +206,5 @@ A local checkout for reference is at `~/projects/ergo-node-build` (v6.0.3 branch ## Related Projects -- `~/projects/ergo-proxy-node` — P2P relay proxy (this project's networking layer). GitHub: `mwaddip/ergo-proxy` +- `~/projects/ergo-proxy-node` — where the P2P layer was originally written, now vendored here as `p2p/`. GitHub: `mwaddip/ergo-proxy`. The **relay** idea it was named for is abandoned; see "Dead / Superseded". - `~/projects/blockhost-ergo/ergo-relay` — BlockHost signing service and peer discovery diff --git a/Cargo.lock b/Cargo.lock index 03a2d69..eb48eac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,15 +435,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -640,7 +631,7 @@ dependencies = [ [[package]] name = "enr-chain" -version = "0.1.0" +version = "0.8.0" dependencies = [ "ergo-chain-types", "ergo-lib", @@ -655,7 +646,7 @@ dependencies = [ [[package]] name = "enr-p2p" -version = "0.1.0" +version = "0.8.0" dependencies = [ "blake2", "if-addrs", @@ -674,7 +665,7 @@ dependencies = [ [[package]] name = "enr-state" -version = "0.1.0" +version = "0.8.0" dependencies = [ "anyhow", "blake2", @@ -688,7 +679,7 @@ dependencies = [ [[package]] name = "enr-store" -version = "0.1.0" +version = "0.8.0" dependencies = [ "parking_lot", "redb", @@ -704,7 +695,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "ergo-api" -version = "0.1.0" +version = "0.8.0" dependencies = [ "axum", "blake2", @@ -780,7 +771,7 @@ dependencies = [ [[package]] name = "ergo-mempool" -version = "0.1.0" +version = "0.8.0" dependencies = [ "ergo-chain-types", "ergo-lib", @@ -810,9 +801,10 @@ dependencies = [ [[package]] name = "ergo-mining" -version = "0.1.0" +version = "0.8.0" dependencies = [ "blake2", + "enr-chain", "ergo-chain-types", "ergo-lib", "ergo-merkle-tree", @@ -851,7 +843,7 @@ dependencies = [ [[package]] name = "ergo-node-rust" -version = "0.7.11" +version = "0.8.0" dependencies = [ "anyhow", "bytes", @@ -890,18 +882,16 @@ dependencies = [ [[package]] name = "ergo-sync" -version = "0.1.0" +version = "0.8.0" dependencies = [ "blake2", "bytes", - "crossbeam-channel", "enr-chain", "enr-p2p", "ergo-chain-types", "ergo-validation", "ergo_avltree_rust", "hex", - "rayon", "redb", "tempfile", "thiserror", @@ -912,7 +902,7 @@ dependencies = [ [[package]] name = "ergo-validation" -version = "0.1.0" +version = "0.8.0" dependencies = [ "blake2", "bytes", @@ -928,12 +918,13 @@ dependencies = [ "tempfile", "thiserror", "tracing", + "tracing-test", ] [[package]] name = "ergo_avltree_rust" version = "0.1.1" -source = "git+https://github.com/mwaddip/ergo_avltree_rust.git?rev=568e7c3#568e7c31e6efeaeba1dbdd2ed5a52fb93e920d80" +source = "git+https://github.com/mwaddip/ergo_avltree_rust.git?rev=b955790#b95579006eb5629d154293c449210053afbf49a4" dependencies = [ "anyhow", "base16", diff --git a/Cargo.toml b/Cargo.toml index 7bbe8f2..6ce8455 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,25 @@ members = [".", "sync", "validation", "state", "mempool", "api", "mining", "p2p", "chain", "store"] exclude = ["addons/fastsync", "addons/indexer"] +# THE version literal. Every workspace member inherits it via +# `version.workspace = true`, so a release bump is this one line. +# +# The members used to carry their own `version = "0.1.0"`, set once when each +# was absorbed from a submodule and never moved again through eight releases. +# They are internal path dependencies, never published, and nothing pins a +# version on them — so the number was pure decoration that disagreed with the +# thing it looked like it described. +# +# ⚠ The `exclude`d addons do NOT inherit this and keep their own versions +# (fastsync 0.1.8, indexer 0.2.8). They ship separately, on their own cadence, +# with their own lockfiles. That is deliberate — do not "fix" it by pulling +# them into the workspace. +[workspace.package] +version = "0.8.0" + [package] name = "ergo-node-rust" -version = "0.7.11" +version.workspace = true edition = "2021" license = "MIT" description = "Full Ergo blockchain node in Rust" @@ -47,7 +63,12 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } lru = "0.16" anyhow = "1" parking_lot = "0.12" -redb = "4" +# cache_metrics is REQUIRED, not optional tuning: without it cache_stats() +# returns zeros and /debug/memory would confidently report an empty cache for +# the largest consumer during sync. Declared per-crate rather than only at the +# workspace root so `cargo test -p ` — which does not build the root +# crate — still collects them. +redb = { version = "4", features = ["cache_metrics"] } thiserror = "2" [dev-dependencies] @@ -105,5 +126,5 @@ secp256k1 = "0.29" # breath. The durable fix is santa-run deriving this whole table from the staged # enr's manifest; until that lands, this comment is the only guardrail. [patch.crates-io] -ergo_avltree_rust = { git = "https://github.com/mwaddip/ergo_avltree_rust.git", rev = "568e7c3" } +ergo_avltree_rust = { git = "https://github.com/mwaddip/ergo_avltree_rust.git", rev = "b955790" } redb = { path = "patches/redb" } diff --git a/README.md b/README.md index 0d7821f..e569f5c 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,17 @@ Validated from genesis through mainnet with no checkpoints. ## Features - **Full validation** — UTXO mode (persistent AVL+ tree) and digest mode (AD-proof verification) -- **Parallel validation** — concurrent transaction evaluation within blocks, pipelined across blocks +- **Parallel validation** — transactions within a block are evaluated concurrently. Script evaluation runs *inside* block application, before anything is persisted, so a block that reaches `state.redb` has already had its scripts checked - **P2P** — IPv4/IPv6, peer discovery, deep reorg support - **Mempool** — validate-on-entry, replace-by-fee, family weighting, fee statistics, P2P relay - **REST API** — 38 JVM-compatible endpoints (blocks, transactions, UTXO, peers, mining, NiPoPoW) plus `/debug/memory`. `/info` advertises `journalEventsVersion` (always) and `statsVersion` (when the optional `[stats]` section is configured) so downstream tooling can detect contract drift. - **Operator stats endpoint** — opt-in `[stats]` section binds a loopback-only `/stats/p2p` endpoint with cumulative P2P traffic counters by message type. Supports external diagnostics (e.g. the Ergo Node Doctor) and an RRD harness under `tools/`. - **Stable journal-event contract** — `facts/journal-events.md` names a versioned set of structured tracing events (startup phases, validation sweeps, reorgs, peer penalties, etc.) so log-parsing tools don't break on refactors. -- **Mining** — Autolykos v2 candidate assembly with EIP-27 re-emission, solution validation +- **Mining** — Autolykos v2 candidate assembly with EIP-27 re-emission, solution validation. Mempool transactions are selected under both protocol limits, fees collected into a reward box capped by measured serialized size, and the candidate is rebuilt on TTL expiry so work stays available between blocks - **Soft-fork voting** — epoch-boundary parameter tracking, v6.0.3-compatible - **NiPoPoW** — build and verify proofs, light-client bootstrap mode - **UTXO snapshot sync** — bootstrap from peer snapshots; serve snapshots to peers -- **At-tip memory tuning** — opt-in `synced_*` config swaps to a smaller redb cache once at tip (~80% RSS reduction on mainnet, 7.3 GB → 1.35 GB) +- **At-tip memory tuning** — opt-in `synced_*` config swaps to a smaller redb cache once at tip. Measured on mainnet at 1.85M blocks with `cache_mb = 1024` / `synced_cache_mb = 128`: **1.9 GB during cold sync → 950 MB at tip** - **Fast crash recovery** — header chain state is reconstructable from the store; redb writes use `quick_repair` so `Database::open` after `kill -9` skips the full-file allocator scan. Restart-to-API on a fully-synced mainnet node is sub-second. ## Addons @@ -136,8 +136,12 @@ network = "mainnet" data_dir = "/var/lib/ergo-node/data" state_type = "utxo" # "utxo" | "digest" | "light" -# Cold-sync (initial sync from genesis or snapshot) +# Cold-sync (initial sync from genesis or snapshot). +# cache_mb is the TOTAL redb page cache across both databases, +# split by cache_store_pct (modifiers.redb gets that %, state.redb +# the rest). It sized state.redb alone before v0.8.0. cache_mb = 1024 +cache_store_pct = 50 flush_heap_threshold_mb = 2048 # At-tip mirrors. Once sync reaches tip, the AVL state DB reopens @@ -179,27 +183,37 @@ The node depends on two upstream Rust crates for consensus-critical primitives, [`mwaddip/sigma-rust`](https://github.com/mwaddip/sigma-rust) — `ergo-chain-types`, `ergo-lib`, `ergo-nipopow`, `sigma-ser` -Open PRs against [ergoplatform/sigma-rust](https://github.com/ergoplatform/sigma-rust): +**45 open, 10 merged** against [ergoplatform/sigma-rust](https://github.com/ergoplatform/sigma-rust) — +[live list](https://github.com/ergoplatform/sigma-rust/pulls?q=is%3Apr+author%3Amwaddip). +They are not enumerated here because the set turns over faster than a README +does; the query above is always current. -- [#847](https://github.com/ergoplatform/sigma-rust/pull/847) — Fix panic in `gen_indexes` when index modulo N equals zero -- [#848](https://github.com/ergoplatform/sigma-rust/pull/848) — `ErgoTreePredef` port + genesis construction -- [#850](https://github.com/ergoplatform/sigma-rust/pull/850) — Soft-fork parameter variants -- [#851](https://github.com/ergoplatform/sigma-rust/pull/851) — `NipopowAlgos::prove_with_reader` for efficient proof serving -- [#852](https://github.com/ergoplatform/sigma-rust/pull/852) — NiPoPoW `has_valid_connections` tolerates skipped prefix entries -- [#854](https://github.com/ergoplatform/sigma-rust/pull/854) — Port JIT costing from sigmastate-interpreter -- [#855](https://github.com/ergoplatform/sigma-rust/pull/855) — Allocation bomb guard for VLQ-decoded message sizes -- [#857](https://github.com/ergoplatform/sigma-rust/pull/857) — BigInt modulo semantics (`mod` vs `rem`) -- [#858](https://github.com/ergoplatform/sigma-rust/pull/858) — Lazy constant resolution in ErgoTree evaluation -- [#859](https://github.com/ergoplatform/sigma-rust/pull/859) — Pre-JIT ErgoScript leniency for v0/v1 scripts -- [#860](https://github.com/ergoplatform/sigma-rust/pull/860) — Parse-time `SOption(T)` leniency in `check_post_eval_tpe` (matches JVM `OneArgumentOperationSerializer`) +Merged so far: NiPoPoW prefix-connection lookback (#852) and `pack_interlinks` +key encoding (#866), non-pair tuple rejection (#868), `Global.powHit` return +type (#877), checked ERG summation (#891), `Option` data non-zero tag (#895), +`checkType` ingress divergences (#897), context-extension key domain (#906), +signed header-version gate (#909), and v0.30.0 style gating (#915). + +⚠ Upstream's `develop` branch is frozen; **`v0.30.0` is the live integration +branch**, and every merge above landed there. Open PRs still target `develop`, +so GitHub reporting them all as `MERGEABLE` means only "mergeable into a branch +nobody moves" — it is not a readiness signal. ### ergo_avltree_rust [`mwaddip/ergo_avltree_rust`](https://github.com/mwaddip/ergo_avltree_rust) -- [#10](https://github.com/ergoplatform/ergo_avltree_rust/pull/10) — `Resolver` type change to support disk-backed storage -- [#11](https://github.com/ergoplatform/ergo_avltree_rust/pull/11) — `VersionedAVLStorage::flush()` for durable commits on demand -- [#13](https://github.com/ergoplatform/ergo_avltree_rust/pull/13) — `contains_recursive` fail-safes on unresolvable `LabelOnly` (prevents `removed_nodes()` over-deletion with persistent backends) +**5 open, 6 merged** against [ergoplatform/ergo_avltree_rust](https://github.com/ergoplatform/ergo_avltree_rust) — +[live list](https://github.com/ergoplatform/ergo_avltree_rust/pulls?q=is%3Apr+author%3Amwaddip). + +- [#27](https://github.com/ergoplatform/ergo_avltree_rust/pull/27) — persistent-prover support: proof cycle, rewind path, storage flush. Supersedes #11/#18/#19/#22 and carries the `restore_root` fix this node pins for +- [#13](https://github.com/ergoplatform/ergo_avltree_rust/pull/13) — `contains_recursive` fail-safes on unresolvable `LabelOnly` +- [#14](https://github.com/ergoplatform/ergo_avltree_rust/pull/14), [#24](https://github.com/ergoplatform/ergo_avltree_rust/pull/24) — `Err` instead of abort on malformed proofs and out-of-range params +- [#16](https://github.com/ergoplatform/ergo_avltree_rust/pull/16) — `Resolver` as `Arc` with a non-breaking constructor + +Merged: JVM-oracle proof comparison tests (#17), proof-deserialization input +guards (#20), `is_new = false` on deserialized nodes (#21), rustfmt (#23), and +checked `UpdateLongBy` delta (#25). ## Credits diff --git a/addons/fastsync/Cargo.lock b/addons/fastsync/Cargo.lock index b068c9e..cd092c2 100644 --- a/addons/fastsync/Cargo.lock +++ b/addons/fastsync/Cargo.lock @@ -557,7 +557,7 @@ dependencies = [ [[package]] name = "ergo-fastsync" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "blake2", diff --git a/addons/fastsync/Cargo.toml b/addons/fastsync/Cargo.toml index dbd6e40..28ff954 100644 --- a/addons/fastsync/Cargo.toml +++ b/addons/fastsync/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "ergo-fastsync" -version = "0.1.7" +version = "0.1.8" edition = "2021" description = "Fast bootstrap for ergo-node-rust via peer REST API — based on Arkadia Network's fast header sync (https://github.com/arkadianet/ergo)" diff --git a/addons/fastsync/src/fetcher.rs b/addons/fastsync/src/fetcher.rs index 2b1a416..2fe2be1 100644 --- a/addons/fastsync/src/fetcher.rs +++ b/addons/fastsync/src/fetcher.rs @@ -6,7 +6,7 @@ use std::time::Duration; -use anyhow::{Context, Result, bail}; +use anyhow::{bail, Context, Result}; use ergo_chain_types::Header; use reqwest::Client; @@ -38,12 +38,7 @@ impl PeerFetcher { /// GET /info — peer's current chain state. pub async fn peer_info(&self) -> Result { let url = format!("{}/info", self.base_url); - let resp = self - .client - .get(&url) - .send() - .await - .context("GET /info")?; + let resp = self.client.get(&url).send().await.context("GET /info")?; if !resp.status().is_success() { bail!("GET /info returned {}", resp.status()); } @@ -54,11 +49,7 @@ impl PeerFetcher { /// /// Returns parsed Headers. The JVM returns header JSON objects; /// sigma-rust deserializes them directly. - pub async fn chain_slice( - &self, - from_height: u32, - to_height: u32, - ) -> Result> { + pub async fn chain_slice(&self, from_height: u32, to_height: u32) -> Result> { let url = format!( "{}/blocks/chainSlice?fromHeight={}&toHeight={}", self.base_url, from_height, to_height @@ -85,10 +76,7 @@ impl PeerFetcher { /// POST /blocks/headerIds — fetch full blocks by header ID batch. /// /// JVM returns `Seq[ErgoFullBlock]` as JSON array. - pub async fn blocks_by_ids( - &self, - header_ids: &[String], - ) -> Result> { + pub async fn blocks_by_ids(&self, header_ids: &[String]) -> Result> { let url = format!("{}/blocks/headerIds", self.base_url); let resp = self .client @@ -96,9 +84,7 @@ impl PeerFetcher { .json(header_ids) .send() .await - .with_context(|| { - format!("POST /blocks/headerIds ({} ids)", header_ids.len()) - })?; + .with_context(|| format!("POST /blocks/headerIds ({} ids)", header_ids.len()))?; if !resp.status().is_success() { bail!( "POST /blocks/headerIds returned {} ({} ids)", @@ -110,7 +96,6 @@ impl PeerFetcher { .await .with_context(|| format!("parse headerIds response ({} ids)", header_ids.len())) } - } /// Batch size for POST /blocks/headerIds. diff --git a/addons/fastsync/src/ingest.rs b/addons/fastsync/src/ingest.rs index 324e6cb..9e747d9 100644 --- a/addons/fastsync/src/ingest.rs +++ b/addons/fastsync/src/ingest.rs @@ -2,11 +2,11 @@ use std::time::Duration; -use anyhow::{Context, Result, bail}; +use anyhow::{bail, Context, Result}; use reqwest::Client; use crate::types::IngestResponse; -use crate::wire::{Modifier, encode_ingest_body}; +use crate::wire::{encode_ingest_body, Modifier}; /// Ingest client targeting the local node. pub struct IngestClient { diff --git a/addons/fastsync/src/main.rs b/addons/fastsync/src/main.rs index c3abedc..e397659 100644 --- a/addons/fastsync/src/main.rs +++ b/addons/fastsync/src/main.rs @@ -177,7 +177,11 @@ async fn run(cli: Cli) -> Result<()> { // but downloaded_height may be much higher if sections persist. let sections_from = node_info.downloaded_height.max(node_info.full_height) + 1; if sections_from <= target { - info!(from = sections_from, to = target, "building block ID list for sections gap"); + info!( + from = sections_from, + to = target, + "building block ID list for sections gap" + ); header_ids = pool.header_ids_for_range(sections_from, target).await?; } } @@ -188,10 +192,13 @@ async fn run(cli: Cli) -> Result<()> { return Ok(()); } - info!(blocks = header_ids.len(), "phase 2: fetching block sections"); + let block_count = header_ids.len(); + info!(blocks = block_count, "phase 2: fetching block sections"); let phase2_start = Instant::now(); - let sections_pushed = pool.fetch_blocks(&header_ids, &ingest).await?; + // Moved, not borrowed — `fetch_blocks` drains the ids into its batch queue + // rather than cloning them, so only one copy of the id list exists. + let sections_pushed = pool.fetch_blocks(header_ids, &ingest).await?; let phase2_elapsed = phase2_start.elapsed(); info!( diff --git a/addons/fastsync/src/pool.rs b/addons/fastsync/src/pool.rs index 048ad32..c4c6575 100644 --- a/addons/fastsync/src/pool.rs +++ b/addons/fastsync/src/pool.rs @@ -11,6 +11,7 @@ use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; use ergo_chain_types::Header; +use reqwest::Client; use tokio::task::JoinSet; use tracing::{error, info, warn}; @@ -83,6 +84,14 @@ pub struct PeerPool { /// None when peers came from `--peer-url` (single-peer mode). node_url: Option, last_refresh: Instant, + /// Client for the peer-discovery query, built once. + /// + /// Previously `maybe_refresh()` constructed a fresh `Client` on every + /// call. Each one owns a connection pool, and reqwest's documentation is + /// explicit that a single client should be reused — at one refresh per + /// 30 s that was ~480 pools created and dropped over a 4-hour header + /// phase. + refresh_client: Client, } /// Minimum time between /peers/api-urls refresh queries. @@ -112,6 +121,10 @@ impl PeerPool { last_refresh: Instant::now() .checked_sub(Duration::from_secs(3600)) .unwrap_or_else(Instant::now), + refresh_client: Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .context("build peer-refresh client")?, }) } @@ -136,14 +149,8 @@ impl PeerPool { } self.last_refresh = Instant::now(); - let client = match reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build() - { - Ok(c) => c, - Err(_) => return 0, - }; - let resp = match client + let resp = match self + .refresh_client .get(format!("{node_url}/peers/api-urls")) .send() .await @@ -331,10 +338,7 @@ impl PeerPool { chunks.push_back((from, to)); if total_empties > max_empties || self.healthy_count() == 0 { - warn!( - height = next_flush, - "all peers behind target — truncating" - ); + warn!(height = next_flush, "all peers behind target — truncating"); break; } @@ -364,9 +368,10 @@ impl PeerPool { for header in &hdrs { // Autolykos v1 headers (version < 2) — skip PoW if header.version >= 2 { - if !header.check_pow().with_context(|| { - format!("PoW at height {}", header.height) - })? { + if !header + .check_pow() + .with_context(|| format!("PoW at height {}", header.height))? + { bail!("PoW failed at height {}", header.height); } } @@ -375,8 +380,7 @@ impl PeerPool { if collect_ids { for header in &hdrs { - header_ids - .push((header.height, hex::encode(header.id.0 .0))); + header_ids.push((header.height, hex::encode(header.id.0 .0))); } } @@ -386,10 +390,8 @@ impl PeerPool { Err(e) => { warn!(error = %e, "ingest failed, backing off"); tokio::time::sleep(Duration::from_secs(1)).await; - headers_pushed += ingest - .push(&batch) - .await - .context("ingest retry failed")?; + headers_pushed += + ingest.push(&batch).await.context("ingest retry failed")?; } } @@ -473,10 +475,7 @@ impl PeerPool { chunks.push_back((from, to)); if self.healthy_count() == 0 { - bail!( - "all peers unhealthy — stuck at height {}", - next_flush - ); + bail!("all peers unhealthy — stuck at height {}", next_flush); } // Reassign to a healthy peer @@ -528,9 +527,20 @@ impl PeerPool { /// /// Batches are distributed round-robin across healthy peers. Order /// doesn't matter for block sections — the node accepts them in any order. + /// Takes `header_ids` BY VALUE and moves the strings into the batch queue. + /// + /// It previously took a slice and cloned every id, so the caller's + /// `Vec<(u32, String)>` and the queue's copies were alive simultaneously + /// for the whole of phase 2 — at 1.82M headers that is ~167 MiB plus a + /// ~153 MiB duplicate. Consuming the vec moves each `String` instead + /// (pointer move, no heap copy) and frees the tuple array as the + /// iterator drains. + /// + /// Nothing needs the ids after this call; the caller's only other use is + /// a `len()` for a log line, which it now captures beforehand. pub async fn fetch_blocks( &mut self, - header_ids: &[(u32, String)], + header_ids: Vec<(u32, String)>, ingest: &IngestClient, ) -> Result { let batch_size = block_batch_size(); @@ -538,19 +548,30 @@ impl PeerPool { let phase_start = Instant::now(); let mut last_log = phase_start; - // Pre-build owned batches: (min_height, max_height, header_ids) + // Build owned batches: (min_height, max_height, header_ids). + // The queue is still materialised up front because a failed batch is + // re-queued for another peer, so the ids must outlive their task. let mut queue: VecDeque<(u32, u32, Vec)> = VecDeque::new(); - for chunk in header_ids.chunks(batch_size) { + let mut it = header_ids.into_iter().peekable(); + while it.peek().is_some() { + let chunk: Vec<(u32, String)> = it.by_ref().take(batch_size).collect(); let min_h = chunk.first().map(|(h, _)| *h).unwrap_or(0); let max_h = chunk.last().map(|(h, _)| *h).unwrap_or(0); - let ids: Vec = chunk.iter().map(|(_, id)| id.clone()).collect(); + let ids: Vec = chunk.into_iter().map(|(_, id)| id).collect(); queue.push_back((min_h, max_h, ids)); } let total_batches = queue.len() as u32; let mut completed = 0u32; // Return ids from task so we can re-queue on failure - type Task = (usize, u32, u32, Vec, Duration, Result>); + type Task = ( + usize, + u32, + u32, + Vec, + Duration, + Result>, + ); let mut tasks: JoinSet = JoinSet::new(); // Track in-flight peer indices so new peers discovered mid-fetch @@ -579,8 +600,7 @@ impl PeerPool { match result { Ok(blocks) => { - self.peers[peer_idx] - .record_success(latency, blocks.len() as u32, false); + self.peers[peer_idx].record_success(latency, blocks.len() as u32, false); for block in &blocks { let header: Header = parse_header_json(&block.header) @@ -669,9 +689,7 @@ impl PeerPool { queue.push_back((min_h, max_h, ids)); if self.healthy_count() == 0 { - bail!( - "all peers unhealthy — block fetch at {completed}/{total_batches}" - ); + bail!("all peers unhealthy — block fetch at {completed}/{total_batches}"); } if let Some(alt) = self.next_healthy_excluding(peer_idx) { @@ -712,11 +730,7 @@ impl PeerPool { /// Fetch header IDs for a height range without pushing headers. /// Used when headers are already synced but block sections are missing. - pub async fn header_ids_for_range( - &mut self, - from: u32, - to: u32, - ) -> Result> { + pub async fn header_ids_for_range(&mut self, from: u32, to: u32) -> Result> { let mut ids = Vec::with_capacity((to - from + 1) as usize); let chunk_size = self.chunk_size; let mut height = from; @@ -727,9 +741,10 @@ impl PeerPool { while height <= to { let chunk_to = (height + chunk_size - 1).min(to); - let peer_idx = *self.healthy_indices().first().ok_or_else(|| { - anyhow::anyhow!("no healthy peers for header ID fetch") - })?; + let peer_idx = *self + .healthy_indices() + .first() + .ok_or_else(|| anyhow::anyhow!("no healthy peers for header ID fetch"))?; let fetcher = self.peers[peer_idx].fetcher.clone(); let start = Instant::now(); diff --git a/addons/fastsync/src/wire.rs b/addons/fastsync/src/wire.rs index 277f954..fcde7d4 100644 --- a/addons/fastsync/src/wire.rs +++ b/addons/fastsync/src/wire.rs @@ -3,7 +3,7 @@ //! Each modifier is `(type_id, modifier_id, data)` where `data` is the raw //! Scorex-serialized body — the same bytes the node would receive over P2P. -use anyhow::{Context, Result, bail}; +use anyhow::{bail, Context, Result}; use blake2::digest::{Update, VariableOutput}; use blake2::Blake2bVar; use ergo_chain_types::Header; @@ -80,10 +80,7 @@ pub fn header_to_modifier(header: &Header) -> Result { /// The header should be pushed separately (it was already sent during the /// chainSlice phase). This returns BlockTransactions, ADProofs (if present), /// and Extension modifiers. -pub fn block_sections_to_modifiers( - block: &JvmFullBlock, - header: &Header, -) -> Result> { +pub fn block_sections_to_modifiers(block: &JvmFullBlock, header: &Header) -> Result> { let header_id: [u8; 32] = header.id.0 .0; let mut mods = Vec::with_capacity(3); @@ -95,15 +92,11 @@ pub fn block_sections_to_modifiers( // ADProofs (optional — UTXO-mode peers may not serve them) if let Some(ref ad_proofs) = block.ad_proofs { - mods.push( - ad_proofs_to_modifier(ad_proofs, &header_id, header).context("ad proofs")?, - ); + mods.push(ad_proofs_to_modifier(ad_proofs, &header_id, header).context("ad proofs")?); } // Extension - mods.push( - extension_to_modifier(&block.extension, &header_id, header).context("extension")?, - ); + mods.push(extension_to_modifier(&block.extension, &header_id, header).context("extension")?); Ok(mods) } @@ -176,8 +169,7 @@ fn ad_proofs_to_modifier( header_id: &[u8; 32], header: &Header, ) -> Result { - let proof_bytes = - hex::decode(&ap.proof_bytes).context("hex decode proofBytes")?; + let proof_bytes = hex::decode(&ap.proof_bytes).context("hex decode proofBytes")?; let mut out = Vec::with_capacity(32 + 5 + proof_bytes.len()); out.extend_from_slice(header_id); @@ -187,11 +179,7 @@ fn ad_proofs_to_modifier( out.extend_from_slice(&buf); out.extend_from_slice(&proof_bytes); - let modifier_id = prefixed_hash( - AD_PROOFS_TYPE_ID, - header_id, - &header.ad_proofs_root.0, - ); + let modifier_id = prefixed_hash(AD_PROOFS_TYPE_ID, header_id, &header.ad_proofs_root.0); Ok(Modifier { type_id: AD_PROOFS_TYPE_ID, @@ -222,13 +210,11 @@ fn extension_to_modifier( out.extend_from_slice(&buf); for (i, (key_hex, val_hex)) in ext.fields.iter().enumerate() { - let key = hex::decode(key_hex) - .with_context(|| format!("field {i}: hex decode key"))?; + let key = hex::decode(key_hex).with_context(|| format!("field {i}: hex decode key"))?; if key.len() != 2 { bail!("field {i}: key length {} != 2", key.len()); } - let value = hex::decode(val_hex) - .with_context(|| format!("field {i}: hex decode value"))?; + let value = hex::decode(val_hex).with_context(|| format!("field {i}: hex decode value"))?; if value.len() > 255 { bail!("field {i}: value length {} > 255", value.len()); } @@ -237,11 +223,7 @@ fn extension_to_modifier( out.extend_from_slice(&value); } - let modifier_id = prefixed_hash( - EXTENSION_TYPE_ID, - header_id, - &header.extension_root.0, - ); + let modifier_id = prefixed_hash(EXTENSION_TYPE_ID, header_id, &header.extension_root.0); Ok(Modifier { type_id: EXTENSION_TYPE_ID, diff --git a/addons/indexer/Cargo.lock b/addons/indexer/Cargo.lock index 3e5d31c..3e03173 100644 --- a/addons/indexer/Cargo.lock +++ b/addons/indexer/Cargo.lock @@ -738,7 +738,7 @@ dependencies = [ [[package]] name = "ergo-indexer" -version = "0.2.7" +version = "0.2.8" dependencies = [ "anyhow", "assert_cmd", diff --git a/addons/indexer/src/api/block_tx_cache.rs b/addons/indexer/src/api/block_tx_cache.rs index ad84891..abd7b0a 100644 --- a/addons/indexer/src/api/block_tx_cache.rs +++ b/addons/indexer/src/api/block_tx_cache.rs @@ -67,11 +67,7 @@ impl BlockTxCache { /// left uninitialized so the next caller retries — a transient node failure /// never poisons the cache, and a persistent one degrades to *serialized* /// retries rather than the concurrent burst this layer exists to prevent. - pub async fn get_or_fetch( - &self, - header_id: &str, - fetch: F, - ) -> anyhow::Result + pub async fn get_or_fetch(&self, header_id: &str, fetch: F) -> anyhow::Result where F: FnOnce() -> Fut, Fut: Future>, @@ -229,7 +225,11 @@ mod tests { }) .await .unwrap(); - assert_eq!(block.transactions[0]["id"].as_str(), Some(k), "pass {pass} key {k}"); + assert_eq!( + block.transactions[0]["id"].as_str(), + Some(k), + "pass {pass} key {k}" + ); } } diff --git a/addons/indexer/src/main.rs b/addons/indexer/src/main.rs index 566a7fc..9267832 100644 --- a/addons/indexer/src/main.rs +++ b/addons/indexer/src/main.rs @@ -129,7 +129,15 @@ async fn main() -> anyhow::Result<()> { } Some(Mode::Serve) => { tracing::info!(bind = %cfg.bind, "starting in serve-only mode"); - api::serve(db, cfg.bind, start, cfg.node_url.clone(), health, shutdown_rx).await + api::serve( + db, + cfg.bind, + start, + cfg.node_url.clone(), + health, + shutdown_rx, + ) + .await } None => { tracing::info!(bind = %cfg.bind, "starting in combined mode (sync + serve)"); diff --git a/addons/indexer/src/sync.rs b/addons/indexer/src/sync.rs index 74bad6b..e74801e 100644 --- a/addons/indexer/src/sync.rs +++ b/addons/indexer/src/sync.rs @@ -281,9 +281,8 @@ async fn check_parent_linkage( return Ok(None); } let stored_parent = db.get_block_id_at(last_indexed).await?; - let target_parent_bytes = hex::decode(target_parent_id).with_context(|| { - format!("invalid parent_id hex on block {target}: {target_parent_id}") - })?; + let target_parent_bytes = hex::decode(target_parent_id) + .with_context(|| format!("invalid parent_id hex on block {target}: {target_parent_id}"))?; if stored_parent.as_deref() == Some(target_parent_bytes.as_slice()) { return Ok(None); } @@ -388,7 +387,13 @@ mod tests { AxumState(state): AxumState>>, ) -> AxumJson> { let s = state.lock().await; - AxumJson(s.canonical_at_height.get(&height).cloned().into_iter().collect()) + AxumJson( + s.canonical_at_height + .get(&height) + .cloned() + .into_iter() + .collect(), + ) } async fn handle_header( @@ -445,7 +450,9 @@ mod tests { async fn parent_linkage_match_is_noop() { let (db, _td) = open_test_db(); let header_at_100 = [0xaa; 32]; - db.insert_block(&empty_block(header_at_100, 100)).await.unwrap(); + db.insert_block(&empty_block(header_at_100, 100)) + .await + .unwrap(); // Match path returns early — the NodeClient is never touched. let client = NodeClient::new("http://127.0.0.1:1").unwrap(); @@ -472,8 +479,12 @@ mod tests { let canonical_100 = [0xaa; 32]; let orphan_101 = [0xbb; 32]; let canonical_101 = [0xcc; 32]; - db.insert_block(&empty_block(canonical_100, 100)).await.unwrap(); - db.insert_block(&empty_block(orphan_101, 101)).await.unwrap(); + db.insert_block(&empty_block(canonical_100, 100)) + .await + .unwrap(); + db.insert_block(&empty_block(orphan_101, 101)) + .await + .unwrap(); let mock_state = Arc::new(TokioMutex::new(MockNode::default())); { @@ -486,10 +497,9 @@ mod tests { let url = start_mock_node(mock_state).await; let client = NodeClient::new(&url).unwrap(); - let result = - check_parent_linkage(&db, &client, 101, 102, &hex::encode(canonical_101)) - .await - .unwrap(); + let result = check_parent_linkage(&db, &client, 101, 102, &hex::encode(canonical_101)) + .await + .unwrap(); assert_eq!(result, Some(100)); assert_eq!(db.get_indexed_height().await.unwrap(), Some(100)); assert_eq!(db.get_block_id_at(101).await.unwrap(), None); diff --git a/api/Cargo.toml b/api/Cargo.toml index cb4efc1..87de4b9 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergo-api" -version = "0.1.0" +version.workspace = true edition = "2021" license = "MIT" description = "REST API for ergo-node-rust" diff --git a/api/src/handlers.rs b/api/src/handlers.rs index 5e518f3..9e55eb6 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -165,6 +165,21 @@ fn subtle_constant_eq(a: &[u8; 32], b: &[u8; 32]) -> bool { // GET /info // --------------------------------------------------------------------------- +/// Map the raw `max_peer_height` atomic onto the `/info` field. +/// +/// `0` is an unambiguous "not known yet" sentinel: the sync layer only ever +/// stores a value strictly greater than the previous one, and Ergo's genesis +/// is height 1, so a genuinely announced height is never 0. Reporting `0` +/// would assert the network is at height 0 — omitting the field says +/// "unknown", which is the truth. See `facts/api.md` § "Synced-state +/// semantics". +fn max_peer_height_field(raw: u32) -> Option { + match raw { + 0 => None, + h => Some(h), + } +} + pub async fn get_info(State(state): State) -> Json { let headers_height = state.chain.height(); let full_height = state @@ -203,6 +218,12 @@ pub async fn get_info(State(state): State) -> Json { unconfirmed_count: pool_size, is_mining: false, current_time: now, + launch_time: state.node_info.launch_time, + max_peer_height: max_peer_height_field( + state + .max_peer_height + .load(std::sync::atomic::Ordering::Relaxed), + ), journal_events_version: crate::JOURNAL_EVENTS_VERSION.to_string(), stats_version: state .stats_enabled @@ -912,7 +933,9 @@ pub async fn get_mining_candidate( // there fails `cached.tip_height == current_tip_height` and returns 503 // even though a valid candidate for the validated tip is cached. // (facts/mining.md — "GET /mining/candidate", height-source paragraph.) - let tip_height = state.validated_height.load(std::sync::atomic::Ordering::Relaxed); + let tip_height = state + .validated_height + .load(std::sync::atomic::Ordering::Relaxed); match mining.cached_work(tip_height) { Some(work) => Ok(Json(work)), None => err( @@ -1221,12 +1244,15 @@ pub async fn info_wait( // GET /debug/memory // --------------------------------------------------------------------------- -/// Average bytes per header in the in-memory chain. Real headers vary with -/// interlink vector size; 800 is a coarse working estimate. Good enough to -/// know whether chain is 0.5 GB or 2 GB of the total — don't use for anything -/// that requires precision. -const AVG_HEADER_BYTES: u64 = 800; - +/// Memory breakdown: kernel counters, allocator counters, and per-component +/// figures reported by the crates that own the structures. +/// +/// The component figures are transported, not computed. This handler must +/// never size another crate's structure from a constant of its own: the +/// removed `chainHeaderEstimateBytes` multiplied the header count by a local +/// 800-bytes-per-header constant describing a `Vec
` that `chain/` +/// retired in Phase 3, and reported 1.48 GB for a structure that did not exist. +/// See `facts/api.md` § "Component memory attribution". pub async fn get_debug_memory(State(state): State) -> Json { // Process memory from /proc/self/status. Fall back to zeros if any field // is missing — the endpoint is diagnostic, not mission-critical. @@ -1244,12 +1270,30 @@ pub async fn get_debug_memory(State(state): State) -> Json u32 { + Self::HEIGHT + } + fn header_at(&self, _h: u32) -> Option
{ + None + } + fn header_by_id(&self, _id: &[u8; 32]) -> Option
{ + None + } + fn tip(&self) -> Option
{ + None + } + fn build_nipopow_proof( + &self, + _m: u32, + _k: u32, + _id: Option<[u8; 32]>, + ) -> Result, String> { + Err("unused".into()) + } + fn header_ids(&self, _offset: u32, _limit: u32) -> Vec<[u8; 32]> { + vec![] + } + fn popow_header_by_id(&self, _id: &[u8; 32]) -> Result>, String> { + Ok(None) + } + fn memory_estimate(&self) -> ChainMemory { + ChainMemory { + index_bytes: Self::INDEX_BYTES, + header_cache_bytes: Self::HEADER_CACHE_BYTES, + score_cache_bytes: Self::SCORE_CACHE_BYTES, + } + } + } + + fn debug_memory_components_of(state: ApiState) -> serde_json::Value { + let rt = build_runtime(); + let Json(body) = rt.block_on(get_debug_memory(State(state))); + serde_json::to_value(&body).unwrap()["components"].clone() + } + + fn debug_memory_components(chain: Arc) -> serde_json::Value { + debug_memory_components_of(test_state(chain)) + } + + /// The point of the whole change: what `ChainAccess::memory_estimate()` + /// reports is what the JSON carries — same values, same fields, no + /// arithmetic in this crate. + #[test] + fn debug_memory_transports_chain_figures_unmodified() { + let c = debug_memory_components(Arc::new(MemoryChain)); + assert_eq!( + c["chainIndexBytes"], + serde_json::json!(MemoryChain::INDEX_BYTES) + ); + assert_eq!( + c["chainHeaderCacheBytes"], + serde_json::json!(MemoryChain::HEADER_CACHE_BYTES) + ); + assert_eq!( + c["chainScoreCacheBytes"], + serde_json::json!(MemoryChain::SCORE_CACHE_BYTES) + ); + assert_eq!( + c["chainHeaderCount"], + serde_json::json!(MemoryChain::HEIGHT) + ); + assert_eq!(c["mempoolTxCount"], serde_json::json!(0)); + } + + /// An accessor reporting `None` must produce JSON with NO such key — not + /// a zero, which would assert "the cache is empty". redb was the largest + /// single consumer during the 2026-08-11 genesis sync and a zero here + /// would have read as "not it", exactly the wrong answer. + #[test] + fn debug_memory_omits_cache_fields_when_unavailable() { + let c = debug_memory_components_of(test_state_with_cache(None, None, None)); + assert!(c.get("storeCacheBytes").is_none()); + assert!(c.get("stateCacheBytes").is_none()); + assert!(c.get("storeCacheEvictions").is_none()); + } + + /// Three distinct values deliberately: a copy-paste wiring error where + /// all three fields read the same accessor would pass with identical + /// values and fail here. Also pins the three serialized key names + /// empirically rather than trusting what `rename_all` is assumed to do. + #[test] + fn debug_memory_transports_cache_figures_unmodified() { + let c = debug_memory_components_of(test_state_with_cache(Some(111), Some(222), Some(333))); + assert_eq!(c["storeCacheBytes"], serde_json::json!(111)); + assert_eq!(c["stateCacheBytes"], serde_json::json!(222)); + assert_eq!(c["storeCacheEvictions"], serde_json::json!(333)); + } + + /// The published gauges carry values into the JSON unchanged, and the + /// pulled figures beside them are undisturbed. Three distinct values: a + /// field wired to a sibling's gauge passes with identical ones. + #[test] + fn debug_memory_transports_published_figures_unmodified() { + let c = debug_memory_components_of(test_state_with_published( + Some(444_000_444), + Some(555_000_555), + Some(666_000_666), + )); + assert_eq!( + c["proverModifiedNodesBytes"], + serde_json::json!(444_000_444u64) + ); + assert_eq!( + c["proverResidentNodesBytes"], + serde_json::json!(555_000_555u64) + ); + assert_eq!(c["syncWindowBytes"], serde_json::json!(666_000_666u64)); + assert_eq!( + c["chainIndexBytes"], + serde_json::json!(MemoryChain::INDEX_BYTES), + "publishing must not disturb the pulled figures" + ); + assert_eq!(c["mempoolTxCount"], serde_json::json!(0)); + } + + /// Nothing has published: all three keys absent. This is every node's + /// state at startup, and the moment a zero here would read as "the prover + /// is empty, look elsewhere" — the answer that cost a day on 2026-08-11. + #[test] + fn debug_memory_omits_published_fields_until_written() { + let c = debug_memory_components_of(test_state_with_published(None, None, None)); + assert!(c.get("proverModifiedNodesBytes").is_none()); + assert!(c.get("proverResidentNodesBytes").is_none()); + assert!(c.get("syncWindowBytes").is_none()); + } + + /// Absence is per-gauge. One publisher that never runs must not drag the + /// others out of the response, and a field reading a sibling's gauge would + /// surface here as a key that refuses to disappear. + #[test] + fn debug_memory_published_fields_are_individually_absent() { + let c = debug_memory_components_of(test_state_with_published(None, Some(2), Some(3))); + assert!(c.get("proverModifiedNodesBytes").is_none()); + assert_eq!(c["proverResidentNodesBytes"], serde_json::json!(2)); + assert_eq!(c["syncWindowBytes"], serde_json::json!(3)); + + let c = debug_memory_components_of(test_state_with_published(Some(1), None, Some(3))); + assert_eq!(c["proverModifiedNodesBytes"], serde_json::json!(1)); + assert!(c.get("proverResidentNodesBytes").is_none()); + assert_eq!(c["syncWindowBytes"], serde_json::json!(3)); + + let c = debug_memory_components_of(test_state_with_published(Some(1), Some(2), None)); + assert_eq!(c["proverModifiedNodesBytes"], serde_json::json!(1)); + assert_eq!(c["proverResidentNodesBytes"], serde_json::json!(2)); + assert!(c.get("syncWindowBytes").is_none()); + } + + /// The whole reason these are gauges and not plain atomics: a measured + /// zero is a fact and renders as `0`; an unmeasured gauge renders as + /// nothing. Collapsing the two is the bug this field set exists to avoid. + #[test] + fn debug_memory_published_zero_renders_as_zero_not_absent() { + let c = debug_memory_components_of(test_state_with_published(Some(0), Some(0), Some(0))); + assert_eq!(c["proverModifiedNodesBytes"], serde_json::json!(0)); + assert_eq!(c["proverResidentNodesBytes"], serde_json::json!(0)); + assert_eq!(c["syncWindowBytes"], serde_json::json!(0)); + } + + /// The sentinel directly, independent of the handler: `unset()` is not a + /// zero, and a published zero is not unset. + #[test] + fn published_gauge_separates_unwritten_from_zero() { + let gauge = PublishedGauge::unset(); + assert_eq!(gauge.get(), None); + gauge.publish(0); + assert_eq!(gauge.get(), Some(0)); + gauge.publish(9_876_543_210); + assert_eq!(gauge.get(), Some(9_876_543_210)); + } + + /// The property the whole design rests on: gauges over one + /// `Arc` are two views of one value, not two values. The + /// producer lives in a crate that cannot name `PublishedGauge` and writes + /// the raw atomic; if adopting the `Arc` ever produced a separate + /// allocation, `/debug/memory` would read a gauge nothing writes and omit + /// the field forever — indistinguishable from a producer that never ran. + #[test] + fn published_gauges_over_one_storage_share_it() { + // How the main crate mints it: allocate through the gauge, because the + // sentinel is private and a hand-rolled atomic starts at a false zero. + let storage = PublishedGauge::unset().storage(); + let reader = PublishedGauge::from_storage(Arc::clone(&storage)); + let second = PublishedGauge::from_storage(Arc::clone(&storage)); + + assert_eq!(reader.get(), None, "minted storage is unpublished"); + assert_eq!(second.get(), None, "and so is every view of it"); + + second.publish(1_234); + assert_eq!( + reader.get(), + Some(1_234), + "a publish through one view is visible through the other" + ); + + // What `sync/` actually does — it only ever sees the atomic. + storage.store(4_096, std::sync::atomic::Ordering::Relaxed); + assert_eq!(reader.get(), Some(4_096)); + assert_eq!(second.get(), Some(4_096)); + } + + /// Pins the serialized key set empirically rather than trusting what + /// `rename_all = "camelCase"` is assumed to produce, and keeps + /// `chainHeaderEstimateBytes` from returning — including as an alias. It + /// was not a misnamed field, it was a wrong number: 1.48 GB attributed to + /// a `Vec
` that `chain/` retired in Phase 3. + #[test] + fn debug_memory_component_keys_are_exactly_the_contract() { + fn sorted_keys(c: &serde_json::Value) -> Vec<&str> { + let mut keys: Vec<&str> = c + .as_object() + .expect("components serializes as object") + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + keys + } + + let everything = test_state_with_cache(Some(1), Some(2), Some(3)); + everything.prover_modified_nodes_bytes.publish(4); + everything.prover_resident_nodes_bytes.publish(5); + everything.sync_window_bytes.publish(6); + assert_eq!( + sorted_keys(&debug_memory_components_of(everything)), + [ + "chainHeaderCacheBytes", + "chainHeaderCount", + "chainIndexBytes", + "chainScoreCacheBytes", + "mempoolTxCount", + "proverModifiedNodesBytes", + "proverResidentNodesBytes", + "stateCacheBytes", + "storeCacheBytes", + "storeCacheEvictions", + "syncWindowBytes", + ], + "component key set must match facts/openapi.yaml § ComponentMemory" + ); + + assert_eq!( + sorted_keys(&debug_memory_components_of(test_state_with_cache( + Some(1), + Some(2), + Some(3) + ))), + [ + "chainHeaderCacheBytes", + "chainHeaderCount", + "chainIndexBytes", + "chainScoreCacheBytes", + "mempoolTxCount", + "stateCacheBytes", + "storeCacheBytes", + "storeCacheEvictions", + ], + "redb stats available but nothing published: the three published keys stay out" + ); + + assert_eq!( + sorted_keys(&debug_memory_components(Arc::new(MemoryChain))), + [ + "chainHeaderCacheBytes", + "chainHeaderCount", + "chainIndexBytes", + "chainScoreCacheBytes", + "mempoolTxCount", + ], + "the three cache and three published keys are the only optional ones; \ + the rest are required" + ); + } + // ----------------------------------------------------------------------- // NiPoPoW handler tests // ----------------------------------------------------------------------- - use crate::ChainAccess; + use crate::{ChainAccess, ChainMemory}; use ergo_chain_types::{ ADDigest, AutolykosSolution, BlockId, Digest32, EcPoint, Header, Votes, }; @@ -2168,6 +2563,21 @@ mod tests { fn popow_header_by_id(&self, _id: &[u8; 32]) -> Result>, String> { Ok(None) } + fn memory_estimate(&self) -> ChainMemory { + unreported_memory() + } + } + + /// `ChainMemory` for doubles that never serve `/debug/memory`. The + /// transport assertions live on `MemoryChain`, which reports a distinct + /// nonzero value per field — zeros here mean "this double is not the one + /// under test", not "the chain uses no memory". + fn unreported_memory() -> ChainMemory { + ChainMemory { + index_bytes: 0, + header_cache_bytes: 0, + score_cache_bytes: 0, + } } fn make_minimal_header(height: u32) -> Header { @@ -2398,20 +2808,33 @@ mod tests { use crate::{ BlockSubmitter, NodeMeta, PeerCounts, PeerInfo, PeerRestInfo, PeerStatusSummary, - SnapshotInfoEntry, StoreAccess, UtxoAccess, + PublishedGauge, SnapshotInfoEntry, StoreAccess, UtxoAccess, }; use std::sync::atomic::AtomicU32; - /// Empty UTXO reader — every lookup returns None. - struct EmptyUtxoReader; + /// Empty UTXO reader — every lookup returns None. `cache_bytes` is + /// configurable so `/debug/memory` can be exercised both with redb stats + /// available and without; `Default` is the unavailable case. + #[derive(Default)] + struct EmptyUtxoReader { + cache_bytes: Option, + } impl UtxoAccess for EmptyUtxoReader { fn box_by_id(&self, _id: &[u8; 32]) -> Option { None } + fn cache_bytes_used(&self) -> Option { + self.cache_bytes + } } - /// Empty store — every lookup returns None. - struct EmptyStore; + /// Empty store — every lookup returns None. Cache figures configurable, + /// `Default` reporting neither. + #[derive(Default)] + struct EmptyStore { + cache_bytes: Option, + cache_evictions: Option, + } impl StoreAccess for EmptyStore { fn get(&self, _type_id: u8, _id: &[u8; 32]) -> Option> { None @@ -2419,6 +2842,12 @@ mod tests { fn get_at_height(&self, _type_id: u8, _height: u32) -> Option> { None } + fn cache_bytes_used(&self) -> Option { + self.cache_bytes + } + fn cache_evictions(&self) -> Option { + self.cache_evictions + } } struct UnusedSubmitter; @@ -2428,17 +2857,21 @@ mod tests { } } + /// Fixed `launch_time` for `test_state`, so `/info` assertions have + /// something deterministic to compare against. + const TEST_LAUNCH_TIME: u64 = 1_700_000_000_000; + /// Build a minimal `ApiState` suitable for unit-testing handlers. /// Callers override the relevant fields before invoking handlers. fn test_state(chain: Arc) -> ApiState { let (_tx, rx) = tokio::sync::watch::channel(0u32); ApiState { chain, - store: Arc::new(EmptyStore), + store: Arc::new(EmptyStore::default()), mempool: Arc::new(tokio::sync::Mutex::new(ergo_mempool::Mempool::new( ergo_mempool::types::MempoolConfig::default(), ))), - utxo_reader: Arc::new(EmptyUtxoReader), + utxo_reader: Arc::new(EmptyUtxoReader::default()), state_context: Arc::new(tokio::sync::RwLock::new(None)), peer_count: Arc::new(|| PeerCounts { connected: 0 }), node_info: Arc::new(NodeMeta { @@ -2446,11 +2879,13 @@ mod tests { version: "0.0.0".into(), network: "testnet".into(), state_type: "utxo".into(), + launch_time: TEST_LAUNCH_TIME, }), mining: None, block_submitter: Some(Arc::new(UnusedSubmitter)), validated_height: Arc::new(AtomicU32::new(0)), downloaded_height: Arc::new(AtomicU32::new(0)), + max_peer_height: Arc::new(AtomicU32::new(0)), peer_api_urls: Arc::new(Vec::::new) as _, peer_all: Arc::new(Vec::::new) as _, peer_status: Arc::new(|| PeerStatusSummary { @@ -2464,11 +2899,55 @@ mod tests { modifier_tx: None, height_watch: rx, jemalloc_probe: None, + prover_modified_nodes_bytes: Arc::new(PublishedGauge::unset()), + prover_resident_nodes_bytes: Arc::new(PublishedGauge::unset()), + sync_window_bytes: Arc::new(PublishedGauge::unset()), stats_enabled: false, capture: None, } } + /// `test_state` over `MemoryChain`, with the store/state redb cache + /// figures forced to the given values. `None` models redb stats being + /// unavailable, which must omit the field rather than report a zero. + fn test_state_with_cache( + store_bytes: Option, + state_bytes: Option, + evictions: Option, + ) -> ApiState { + let mut state = test_state(Arc::new(MemoryChain)); + state.store = Arc::new(EmptyStore { + cache_bytes: store_bytes, + cache_evictions: evictions, + }); + state.utxo_reader = Arc::new(EmptyUtxoReader { + cache_bytes: state_bytes, + }); + state + } + + /// `test_state` over `MemoryChain` with the published gauges written to + /// the given values. A `None` leaves the gauge untouched — the state a + /// node is in before the owning crate has measured anything, which must + /// omit the key rather than report a zero. + fn test_state_with_published( + prover_modified: Option, + prover_resident: Option, + sync_window: Option, + ) -> ApiState { + let state = test_state(Arc::new(MemoryChain)); + for (gauge, value) in [ + (&state.prover_modified_nodes_bytes, prover_modified), + (&state.prover_resident_nodes_bytes, prover_resident), + (&state.sync_window_bytes, sync_window), + ] { + if let Some(bytes) = value { + gauge.publish(bytes); + } + } + state + } + /// Mock chain that returns a configurable list of header IDs. struct PaginatingChain { ids: Vec<[u8; 32]>, @@ -2505,6 +2984,9 @@ mod tests { fn popow_header_by_id(&self, _id: &[u8; 32]) -> Result>, String> { Ok(None) } + fn memory_estimate(&self) -> ChainMemory { + unreported_memory() + } } #[test] @@ -2993,6 +3475,9 @@ mod tests { fn popow_header_by_id(&self, _id: &[u8; 32]) -> Result>, String> { Ok(None) } + fn memory_estimate(&self) -> ChainMemory { + unreported_memory() + } } /// Store mock: returns pre-loaded bytes keyed by `(type_id, modifier_id)`. @@ -3006,6 +3491,13 @@ mod tests { fn get_at_height(&self, _t: u8, _h: u32) -> Option> { None } + /// Models no cache: unavailable, not empty. + fn cache_bytes_used(&self) -> Option { + None + } + fn cache_evictions(&self) -> Option { + None + } } /// Build a synthetic header at `height` with the given `parent_id`. @@ -3178,7 +3670,10 @@ mod tests { assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(body.error, 404); assert_eq!(body.reason, "block-not-found"); - assert_eq!(body.detail.as_deref(), Some(format!("headerId={}", "aa".repeat(32)).as_str())); + assert_eq!( + body.detail.as_deref(), + Some(format!("headerId={}", "aa".repeat(32)).as_str()) + ); } Ok(_) => panic!("expected 404 for unknown headerId"), } @@ -3249,8 +3744,8 @@ mod tests { // full canonical bytes are non-empty hex and re-parse to the same tx assert!(!frag.bytes.is_empty(), "tx[{i}] bytes must be non-empty"); let raw = hex::decode(&frag.bytes).expect("bytes is hex"); - let reparsed = Transaction::sigma_parse_bytes(&raw) - .expect("bytes must re-parse to a Transaction"); + let reparsed = + Transaction::sigma_parse_bytes(&raw).expect("bytes must re-parse to a Transaction"); assert_eq!( reparsed.id().0 .0, parsed.transactions[i].id().0 .0, @@ -3634,6 +4129,9 @@ mod tests { fn popow_header_by_id(&self, _id: &[u8; 32]) -> Result>, String> { Ok(None) } + fn memory_estimate(&self) -> ChainMemory { + unreported_memory() + } } /// Submitter that accepts everything — the happy-path stand-in. @@ -3854,6 +4352,9 @@ mod tests { fn popow_header_by_id(&self, _id: &[u8; 32]) -> Result>, String> { Ok(None) } + fn memory_estimate(&self) -> ChainMemory { + unreported_memory() + } } /// Submitter that counts invocations — for asserting a block never diff --git a/api/src/lib.rs b/api/src/lib.rs index 72b75b2..4fdfb76 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -2,7 +2,7 @@ mod handlers; pub mod stats; pub mod types; -use std::sync::atomic::AtomicU32; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use axum::Router; @@ -18,7 +18,7 @@ pub use stats::{ /// Version of the journal-events contract this build promises. /// Bumped atomically with `facts/journal-events.md`. -pub const JOURNAL_EVENTS_VERSION: &str = "1.3"; +pub const JOURNAL_EVENTS_VERSION: &str = "2.1"; /// Version of the operator stats endpoint schema this build promises. /// Bumped atomically with `facts/stats.md`. @@ -57,6 +57,14 @@ pub struct ApiState { pub validated_height: Arc, /// Highest height with all block sections downloaded (updated by sync layer). pub downloaded_height: Arc, + /// Highest header height announced by any peer in a `SyncInfo` since + /// process start (updated by the sync layer). Monotonic high-water mark — + /// never decreases, including on peer disconnect or reorg. `0` means no + /// `SyncInfo` has been parsed yet; `/info` reports that as an absent + /// field rather than height 0. Peer-supplied and unverified — advisory + /// display data only, never an input to a consensus, validation, or + /// storage decision. + pub max_peer_height: Arc, /// Peer REST URL callback — returns connected peers with their socket addr and REST URL. pub peer_api_urls: Arc Vec + Send + Sync>, /// All known peers (connected + disconnected). For `GET /peers/all`. @@ -82,6 +90,15 @@ pub struct ApiState { /// jemalloc; None with mimalloc or system allocator. The `/debug/memory` /// handler calls this to read live allocator counters. pub jemalloc_probe: Option JemallocSnapshot + Send + Sync>>, + /// AVL prover modified-node working set, published after each applied + /// block by the crate that owns the prover. Absent from `/debug/memory` + /// until something publishes — see [`PublishedGauge`]. + pub prover_modified_nodes_bytes: Arc, + /// AVL prover resident-node working set, published by the same writer as + /// [`ApiState::prover_modified_nodes_bytes`]. + pub prover_resident_nodes_bytes: Arc, + /// `sync/`'s in-flight download window, published by `sync/`. + pub sync_window_bytes: Arc, /// Whether the operator stats endpoint is enabled. Overwritten by /// `serve()` based on its `stats_config` + `p2p_counters` arguments; /// callers should leave this `false`. When `true`, `/info` emits @@ -94,6 +111,109 @@ pub struct ApiState { pub capture: Option>, } +/// A byte figure **published** into shared state by the crate that owns the +/// structure it measures, rather than read through an accessor. +/// +/// Every other `/debug/memory` component figure is pulled synchronously via a +/// trait method. These cannot be: the AVL prover lives inside the UTXO +/// validator, which `sync/` owns and deliberately does not wrap in an +/// `Arc` (`facts/validation.md`), so there is nothing for an HTTP +/// handler to call. The owning crate computes its estimate and stores it here +/// after each applied block — the mechanism already used for `shared_height` — +/// and this crate reads it. Do not "simplify" this into a direct accessor: a +/// synchronous read needs either a lock on an HTTP path or the `Arc` +/// that was rejected on its own merits. +/// +/// The `Option` discipline of the pulled figures survives intact. A gauge +/// nothing has published to reports `None`, and `/debug/memory` omits the key; +/// a published `0` reports `Some(0)` and renders as `0`. A node that has +/// applied no blocks must not claim an empty prover — that is the same class of +/// falsehood as the removed `chainHeaderEstimateBytes`. The two states are kept +/// apart by a sentinel, so a gauge cannot report zero before anything has +/// measured it — provided its storage was minted by +/// [`unset`](PublishedGauge::unset), which is what sets the sentinel. +/// +/// **The gauge is a view over an `Arc`, not the owner of an +/// atomic.** The producer lives in a crate that cannot name this type — `api/` +/// and `sync/` do not depend on each other, and none of the crates they share +/// is a sensible home for an observability primitive. So the storage is a plain +/// `Arc` that the main crate allocates once and hands to both ends: +/// the producer publishes into it, and a gauge +/// [adopts](PublishedGauge::from_storage) the same `Arc` to read it. Two views, +/// one allocation. +/// +/// See `facts/api.md` § "Component memory attribution". +pub struct PublishedGauge(Arc); + +impl PublishedGauge { + /// Sentinel for "nothing has published yet". 16 EiB is not a byte count any + /// structure in this process can reach; see [`PublishedGauge::publish`]. + const UNSET: u64 = u64::MAX; + + /// A gauge over freshly allocated storage nothing has published to. Reports + /// `None` until [`publish`](PublishedGauge::publish) is called. + /// + /// This is also how the shared storage gets minted. The sentinel is + /// private, so an `Arc` allocated anywhere else starts at `0` + /// and reads as a published zero. Allocate here, then hand + /// [`storage`](PublishedGauge::storage) to the producer. + pub fn unset() -> Self { + Self(Arc::new(AtomicU64::new(Self::UNSET))) + } + + /// A gauge over storage that already exists — the reading end of an `Arc` + /// whose writing end is a producer in another crate. + /// + /// The storage must have come from [`unset`](PublishedGauge::unset), or at + /// minimum have been initialised to the never-published sentinel. An + /// `AtomicU64::new(0)` adopted here reports `Some(0)`: a claim that + /// something measured an empty structure, which is precisely the falsehood + /// the sentinel exists to prevent. + pub fn from_storage(storage: Arc) -> Self { + Self(storage) + } + + /// The storage this gauge reads, for handing to the producer in the crate + /// that owns the measured structure. Every view of one allocation sees + /// every [`publish`](PublishedGauge::publish) through any other. + pub fn storage(&self) -> Arc { + Arc::clone(&self.0) + } + + /// Publish a freshly measured figure. Called by the owning crate after each + /// applied block; never called from this crate. + /// + /// Publishing `u64::MAX` is indistinguishable from never publishing and is + /// a caller bug. Debug builds assert; release builds report the field as + /// absent, which is a refusal to answer rather than a wrong answer. + pub fn publish(&self, bytes: u64) { + debug_assert!( + bytes != Self::UNSET, + "u64::MAX is the never-published sentinel; publishing it reports as absent" + ); + self.0.store(bytes, Ordering::Relaxed); + } + + /// The last published figure, or `None` if nothing has published yet. + /// + /// `Relaxed` throughout: each gauge is an independent diagnostic reading + /// with no ordering relationship to any other value, and a reader that + /// catches the previous block's figure has caught a figure that was true + /// two seconds ago. + pub fn get(&self) -> Option { + match self.0.load(Ordering::Relaxed) { + Self::UNSET => None, + bytes => Some(bytes), + } + } +} + +impl Default for PublishedGauge { + fn default() -> Self { + Self::unset() + } +} + /// Snapshot of jemalloc stats at a moment in time. The probe calls /// `epoch::advance()` then reads each stat. Byte-valued fields are u64. #[derive(Clone, Copy, Default)] @@ -112,6 +232,9 @@ pub struct NodeMeta { pub version: String, pub network: String, pub state_type: String, + /// Unix epoch ms at which this process began serving. Constant for the + /// process lifetime — consumers derive uptime as `currentTime - launchTime`. + pub launch_time: u64, } /// Peer count summary. @@ -150,6 +273,34 @@ pub struct SnapshotInfoEntry { pub digest: [u8; 32], } +/// Memory attribution for the header chain's in-process structures, as +/// reported by the crate that owns them. +/// +/// Every field is a formula over a live count or capacity — never a +/// measurement — and it is computed by `chain/`, not here. The API layer +/// transports these numbers into `GET /debug/memory`; it must never derive +/// them from a local constant describing another crate's internals. The +/// removed `chainHeaderEstimateBytes` did exactly that (`header_count * 800` +/// for a `Vec
` that `chain/` retired in Phase 3) and reported 1.48 GB +/// for a structure that no longer existed. +/// +/// Deliberately api-local: this crate does not depend on `enr-chain`, and it +/// does not acquire that dependency just to share a struct. The main crate's +/// adapter maps `chain/`'s `ChainMemoryEstimate` onto this type — that mapping +/// is the integration seam, not licence to reintroduce sizing arithmetic here. +/// See `facts/api.md` § "Component memory attribution". +pub struct ChainMemory { + /// `HeaderChain::by_id` (BlockId → height). Unbounded — grows with chain + /// length for the life of the node and is never evicted. + pub index_bytes: u64, + /// `LazyHeaderStore` header LRU, current occupancy. Bounded by the + /// configured cache capacity. + pub header_cache_bytes: u64, + /// `LazyHeaderStore` cumulative-score LRU, current occupancy. Bounded by + /// the configured cache capacity. + pub score_cache_bytes: u64, +} + /// Trait for chain access — avoids API depending on enr-chain internals. /// Implementations handle their own locking. pub trait ChainAccess: Send + Sync { @@ -196,6 +347,15 @@ pub trait ChainAccess: Send + Sync { /// - `Ok(None)` — header not in chain (handler returns 404). /// - `Err(reason)` — chain-internal failure (e.g., extension missing for an interlink ancestor); handler returns 500. fn popow_header_by_id(&self, id: &[u8; 32]) -> Result>, String>; + + /// Memory attribution for the chain's in-process structures. + /// + /// The implementor computes these figures; `GET /debug/memory` reports + /// them verbatim and performs no arithmetic on them. Deliberately has no + /// default body — a default would let an implementor that forgot to + /// override it silently report zeros, which is precisely how the wrong + /// `chainHeaderEstimateBytes` survived unnoticed. + fn memory_estimate(&self) -> ChainMemory; } /// Trait for block store access. @@ -205,12 +365,38 @@ pub trait StoreAccess: Send + Sync { /// Get raw modifier bytes by type ID and block height. /// Looks up the modifier ID at that height first, then fetches the data. fn get_at_height(&self, type_id: u8, height: u32) -> Option>; + + /// `modifiers.redb` page cache occupancy, `None` when unavailable. + /// + /// NEVER return 0 for "unknown" — a zero asserts the cache is empty, the + /// same class of falsehood as the removed `chainHeaderEstimateBytes`. + /// `GET /debug/memory` omits the field entirely when this is `None`. + /// + /// Deliberately has no default body: a default would let an implementor + /// that forgot to override it silently report a wrong value, which is + /// structurally how `AVG_HEADER_BYTES` survived four months describing a + /// struct that had been deleted. A compile error at every implementation + /// site is the point. + fn cache_bytes_used(&self) -> Option; + + /// Cumulative `modifiers.redb` cache evictions, `None` when unavailable. + /// + /// A rising count is the signal that the cache is undersized — otherwise + /// an undersized cache is indistinguishable from a comfortably large one. + /// No default body, for the reason given on [`StoreAccess::cache_bytes_used`]. + fn cache_evictions(&self) -> Option; } /// Trait for UTXO lookups. pub trait UtxoAccess: Send + Sync { /// Look up a box by its ID in the confirmed UTXO set. fn box_by_id(&self, box_id: &[u8; 32]) -> Option; + + /// `state.redb` page cache occupancy, `None` when unavailable. + /// + /// NEVER return 0 for "unknown". No default body, for the reason given on + /// [`StoreAccess::cache_bytes_used`]. + fn cache_bytes_used(&self) -> Option; } /// Trait for submitting locally-mined blocks to the node's processing pipeline. diff --git a/api/src/stats.rs b/api/src/stats.rs index 36258a5..d8c60b0 100644 --- a/api/src/stats.rs +++ b/api/src/stats.rs @@ -359,6 +359,8 @@ mod tests { unconfirmed_count: 0, is_mining: false, current_time: 0, + launch_time: 0, + max_peer_height: None, journal_events_version: JOURNAL_EVENTS_VERSION.to_string(), stats_version, } diff --git a/api/src/types.rs b/api/src/types.rs index 77fa0ca..e4172cc 100644 --- a/api/src/types.rs +++ b/api/src/types.rs @@ -27,6 +27,16 @@ pub struct NodeInfo { pub unconfirmed_count: usize, pub is_mining: bool, pub current_time: u64, + /// Unix epoch ms at which this process began serving. Constant for the + /// process lifetime; consumers derive uptime as `currentTime - launchTime`. + pub launch_time: u64, + /// Highest header height announced by any peer in a `SyncInfo` since + /// process start. Omitted — not reported as `0` — until the first + /// `SyncInfo` is parsed, because a zero would assert the network is at + /// height 0. Peer-supplied and unverified: advisory display data only. + /// See `facts/api.md` § "Synced-state semantics". + #[serde(skip_serializing_if = "Option::is_none")] + pub max_peer_height: Option, /// Always present — see `facts/journal-events.md`. pub journal_events_version: String, /// Present only when the operator stats endpoint is enabled — see `facts/stats.md`. @@ -155,14 +165,68 @@ pub struct JemallocMemory { pub metadata_bytes: u64, } +/// Per-component memory attribution. +/// +/// Every `*Bytes` field is a FORMULA over a live count or capacity, never a +/// measurement — ground truth for "where did the memory go" is a heap profile, +/// not this endpoint. Each formula is computed by the crate that owns the +/// structure it models and is transported here unmodified; this crate performs +/// no arithmetic on them. See `facts/api.md` § "Component memory attribution". +/// +/// Most figures are pulled through a trait method at request time. The prover +/// and sync-window ones are pushed instead — the structures they measure sit +/// behind an owner that holds no shared handle — but the ownership rule is the +/// same either way: the crate that owns the structure produces the number. +/// +/// Headers themselves are NOT resident — they have been served lazily from +/// storage since `chain/` retired the header `Vec` in Phase 3. Do not +/// reintroduce a field claiming to size resident headers. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ComponentMemory { - /// Estimated bytes held by the in-memory header chain - /// (`chain.by_height.len() * avg_header_bytes`). Coarse — real headers - /// vary in size from ~220 bytes to ~1 KB depending on interlink vector. - pub chain_header_estimate_bytes: u64, + /// `HeaderChain::by_id` (BlockId → height). **Unbounded** — grows with + /// chain length for the life of the node and is never evicted. The one + /// chain structure whose growth an operator must plan for. + pub chain_index_bytes: u64, + /// `LazyHeaderStore` header LRU, current occupancy. Bounded by the + /// configured cache capacity. + pub chain_header_cache_bytes: u64, + /// `LazyHeaderStore` cumulative-score LRU, current occupancy. Bounded by + /// the configured cache capacity. + pub chain_score_cache_bytes: u64, + /// Chain length. A count, not an estimate. pub chain_header_count: u32, + /// `modifiers.redb` page cache occupancy, from redb's own `cache_stats()`. + /// Omitted — never zero — when unavailable; a zero would assert the cache + /// is empty. Historically the largest single consumer during cold sync and + /// entirely invisible here. + #[serde(skip_serializing_if = "Option::is_none")] + pub store_cache_bytes: Option, + /// `state.redb` page cache occupancy. Omitted, never zero, when + /// unavailable. + #[serde(skip_serializing_if = "Option::is_none")] + pub state_cache_bytes: Option, + /// Cumulative `modifiers.redb` cache evictions. A rising count is the + /// signal that the cache is undersized — otherwise indistinguishable from + /// one that is comfortably large. Omitted when unavailable. + #[serde(skip_serializing_if = "Option::is_none")] + pub store_cache_evictions: Option, + /// AVL prover modified-node working set, as published by the crate that + /// owns the prover — its contract defines the formula, not this one. + /// Unlike every field above it is not readable synchronously; see + /// `crate::PublishedGauge`. Omitted, never zero, until published: a zero + /// would assert an empty prover on a node that has simply measured nothing. + #[serde(skip_serializing_if = "Option::is_none")] + pub prover_modified_nodes_bytes: Option, + /// AVL prover resident-node working set, published alongside + /// [`ComponentMemory::prover_modified_nodes_bytes`]. Omitted, never zero, + /// until published. + #[serde(skip_serializing_if = "Option::is_none")] + pub prover_resident_nodes_bytes: Option, + /// `sync/`'s in-flight download window, as published by `sync/`. Omitted, + /// never zero, until published. + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_window_bytes: Option, /// Mempool transaction count. pub mempool_tx_count: u32, } diff --git a/build-deb b/build-deb index 369205a..2fad832 100755 --- a/build-deb +++ b/build-deb @@ -1,7 +1,28 @@ #!/bin/bash set -euo pipefail -VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') +# Read the literal out of [workspace.package], which is the one place it +# exists — every member inherits it via `version.workspace = true`. +# +# Scoped to the section rather than "first ^version line": the old grep|head -1 +# happened to be right only because [workspace.package] sits above [package], +# and would have silently produced "true" from `version.workspace = true` if +# anyone reordered the file. +VERSION=$(awk ' + /^\[workspace\.package\]/ { in_section = 1; next } + /^\[/ { in_section = 0 } + in_section && /^version[[:space:]]*=/ { + match($0, /"[^"]*"/) + print substr($0, RSTART + 1, RLENGTH - 2) + exit + } +' Cargo.toml) + +if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "error: could not read a version from [workspace.package] in Cargo.toml" >&2 + echo " got: '${VERSION}'" >&2 + exit 1 +fi ARCH=$(dpkg --print-architecture 2>/dev/null || echo "amd64") PKG="ergo-node-rust_${VERSION}_${ARCH}" @@ -16,7 +37,10 @@ cargo build --locked --release --bin ergo-node-rust --bin sharpen rm -rf "target/${PKG}" mkdir -p "target/${PKG}/DEBIAN" mkdir -p "target/${PKG}/usr/bin" -mkdir -p "target/${PKG}/etc/ergo-node" +# Shipped empty. postinst fills conf.d/ with 00-defaults.toml and +# 50-debconf.toml; the operator's 99-local.toml is never the package's to write +# (the one exception being preinst's legacy migration). +mkdir -p "target/${PKG}/etc/ergo-node/conf.d" mkdir -p "target/${PKG}/usr/lib/systemd/system" cp target/release/ergo-node-rust "target/${PKG}/usr/bin/" @@ -24,7 +48,17 @@ cp target/release/sharpen "target/${PKG}/usr/bin/" strip "target/${PKG}/usr/bin/ergo-node-rust" strip "target/${PKG}/usr/bin/sharpen" -cp deploy/ergo.toml "target/${PKG}/etc/ergo-node/ergo.toml" +# Per-network defaults, package-owned. postinst copies the one matching the +# debconf answer to /etc/ergo-node/conf.d/00-defaults.toml on every configure. +# +# NOT installed to /etc and NOT a conffile — that is the point. The old +# /etc/ergo-node/ergo.toml was a conffile, which froze its seed-peer list on +# every existing install forever; refreshing from /usr/share is what lets a +# correction actually reach operators. preinst migrates the legacy file to +# conf.d/99-local.toml so nobody's edits are lost on the way. +mkdir -p "target/${PKG}/usr/share/ergo-node-rust/defaults" +cp deploy/defaults/mainnet.toml deploy/defaults/testnet.toml \ + "target/${PKG}/usr/share/ergo-node-rust/defaults/" cp deploy/ergo-node-rust.service "target/${PKG}/usr/lib/systemd/system/" @@ -47,6 +81,14 @@ cp ergo.toml.example "target/${PKG}/usr/share/doc/ergo-node-rust/examples/" cp tools/rrd-create.sh tools/rrd-update.sh tools/rrd-graph.sh tools/rrd-demo-fill.sh \ "target/${PKG}/usr/share/doc/ergo-node-rust/examples/" +# Architecture and Version are substituted from the build, so the values in +# debian/control are only what a reader sees — keep them current anyway. +# Version sat at 0.1.0 there through eight releases, which made every naive +# read of that file wrong. +# +# ⚠ debian/control is a binary package control file: NO `#` comments. dpkg-deb +# rejects them with "field name '#' must be followed by colon". Explanations +# about that file belong here instead. sed "s/^Architecture:.*/Architecture: ${ARCH}/" debian/control > "target/${PKG}/DEBIAN/control" sed -i "s/^Version:.*/Version: ${VERSION}/" "target/${PKG}/DEBIAN/control" SIZE=$(du -sk "target/${PKG}" | cut -f1) @@ -54,7 +96,13 @@ echo "Installed-Size: ${SIZE}" >> "target/${PKG}/DEBIAN/control" cp debian/conffiles "target/${PKG}/DEBIAN/" -for script in preinst postinst prerm postrm; do +# `templates` is data, not a script: 644 and never executed. `config` IS a +# script and runs before unpack — it must be 755 or debconf silently skips the +# interview and every answer falls through to its default. +cp debian/templates "target/${PKG}/DEBIAN/" +chmod 644 "target/${PKG}/DEBIAN/templates" + +for script in config preinst postinst prerm postrm; do cp "debian/${script}" "target/${PKG}/DEBIAN/" chmod 755 "target/${PKG}/DEBIAN/${script}" done diff --git a/chain/Cargo.toml b/chain/Cargo.toml index a7c28c0..d74852e 100644 --- a/chain/Cargo.toml +++ b/chain/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "enr-chain" -version = "0.1.0" +version.workspace = true edition = "2021" description = "Header chain validation for the Ergo Rust node" diff --git a/chain/src/cache.rs b/chain/src/cache.rs index 828afbf..8fbba11 100644 --- a/chain/src/cache.rs +++ b/chain/src/cache.rs @@ -2,20 +2,21 @@ //! //! Backs the migration from in-memory `Vec
` / `Vec` //! to an LRU cache + lazy load from persistent storage. At mainnet -//! scale (1.76M headers) the Vec alternative is ~1.4 GB; the cache at -//! the default 16k capacity is ~10 MB across both caches. The -//! integrator (main crate) wires [`HeaderLoader`] and [`ScoreLoader`] -//! against `enr-store`. +//! scale (1.76M headers) the Vec alternative was ~1.4 GB; the cache is +//! bounded by [`DEFAULT_CACHE_CAPACITY`] regardless of chain length. +//! The integrator (main crate) wires [`HeaderLoader`] and +//! [`ScoreLoader`] against `enr-store`. //! //! Phase 1 role: cache and loaders are installed on //! [`crate::HeaderChain`] and kept coherent via write-through on //! push/pop/reorg. No reads are routed through the cache yet — that //! arrives in Phase 2. +use std::mem::size_of; use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; -use ergo_chain_types::Header; +use ergo_chain_types::{EcPoint, Header}; use lru::LruCache; use num_bigint::BigUint; @@ -26,24 +27,90 @@ use num_bigint::BigUint; /// epoch_length` = 8 × 1024 = 8192 headers per incoming header), and /// - the deepest finalization-depth reorg (1440 blocks). /// -/// At ~500 B/header + ~80 B/score this is ~10 MB total across both -/// caches. +/// Full at this capacity the two caches hold roughly 11 MB — but do not +/// hand-carry that number anywhere. `header_entry_bytes` and +/// `score_entry_bytes` below derive it from the actual types, and +/// `LazyHeaderStore::header_cache_bytes` reports it at live occupancy. pub const DEFAULT_CACHE_CAPACITY: usize = 16_384; +// ---- Memory attribution ---- +// +// Formulas over type sizes and live counts, never measurements. They +// live here, beside the caches they model, so a refactor of those +// caches is editing the same file. See `facts/chain.md` § "Memory +// attribution" and [`crate::ChainMemoryEstimate`]. + +/// Bytes the `lru` crate spends on an entry regardless of its value: +/// the `u32` height key and the two intrusive list pointers inside the +/// boxed `LruEntry` node it allocates per entry. +/// +/// `LruEntry` is private to `lru`, so the node is reconstructed from +/// its fields rather than measured; the reconstruction ignores the few +/// bytes of padding the real layout adds. +const LRU_NODE_OVERHEAD_BYTES: u64 = (size_of::() + 2 * size_of::()) as u64; + +/// Bytes one entry occupies in the LRU's internal index, a +/// `HashMap, NonNull>>`: the two pointers of +/// the slot plus hashbrown's one control byte per slot. +const LRU_INDEX_SLOT_BYTES: u64 = (2 * size_of::() + 1) as u64; + +/// Payload of `AutolykosSolution::nonce`, a `Vec` that carries the +/// 8-byte Autolykos nonce. +const AUTOLYKOS_NONCE_BYTES: u64 = 8; + +/// Heap digits of one cumulative-difficulty `BigUint`. +/// +/// `num-bigint` stores 64-bit digits on a 64-bit target. Mainnet total +/// work sits around 2^71 — two digits, a 16-byte request — and this +/// leaves room for the allocator's rounding of that request plus +/// growth as the chain's total work climbs. +const SCORE_DIGIT_BYTES: u64 = 32; + +/// Estimated bytes held by one resident entry of the header LRU. +/// +/// Formula, not a measurement: +/// - `size_of::
()` — the value, stored inline in the boxed +/// `LruEntry` node. +/// - `2 × size_of::()` — `Header::autolykos_solution`'s +/// `miner_pk` and `pow_onetime_pk`, each a `Box` pointing +/// out of line. Both are populated in practice: parsing an Autolykos +/// v2 solution materializes the group generator into +/// `pow_onetime_pk`, mirroring the JVM's `wForV2`. +/// - the nonce payload, the LRU node overhead, and the entry's slot in +/// the LRU's index. +/// +/// `Header::unparsed_bytes` and `AutolykosSolution::pow_distance` are +/// not counted: both are empty / `None` in every header version the +/// node accepts today. +pub(crate) const fn header_entry_bytes() -> u64 { + size_of::
() as u64 + + 2 * size_of::() as u64 + + AUTOLYKOS_NONCE_BYTES + + LRU_NODE_OVERHEAD_BYTES + + LRU_INDEX_SLOT_BYTES +} + +/// Estimated bytes held by one resident entry of the score LRU. +/// +/// Formula, not a measurement: `size_of::()` for the value +/// inline in the boxed `LruEntry` node, plus its heap digits, plus the +/// node overhead and the entry's slot in the LRU's index. +pub(crate) const fn score_entry_bytes() -> u64 { + size_of::() as u64 + SCORE_DIGIT_BYTES + LRU_NODE_OVERHEAD_BYTES + LRU_INDEX_SLOT_BYTES +} + /// Callback for loading a header by height from persistent storage. /// /// Returns `None` if no header is stored at that height. Wired by the /// integrator (main crate) to bridge `enr-store`. -pub type HeaderLoader = - Arc Option
+ Send + Sync + 'static>; +pub type HeaderLoader = Arc Option
+ Send + Sync + 'static>; /// Callback for loading a cumulative-difficulty score by height. /// /// Split from [`HeaderLoader`] so consumers that only need the header /// (NiPoPoW build, difficulty walk) don't pay `BigUint` /// deserialization on every lookup. -pub type ScoreLoader = - Arc Option + Send + Sync + 'static>; +pub type ScoreLoader = Arc Option + Send + Sync + 'static>; /// Paired LRU caches + loaders for headers and cumulative scores. /// @@ -64,8 +131,7 @@ impl LazyHeaderStore { /// ([`DEFAULT_CACHE_CAPACITY`]). pub fn with_default_capacity() -> Self { Self::with_capacity( - NonZeroUsize::new(DEFAULT_CACHE_CAPACITY) - .expect("DEFAULT_CACHE_CAPACITY is nonzero"), + NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("DEFAULT_CACHE_CAPACITY is nonzero"), ) } @@ -151,6 +217,30 @@ impl LazyHeaderStore { self.scores.lock().unwrap().clear(); } + /// Estimated bytes held by the header LRU **at its current + /// occupancy** — `entries × header_entry_bytes()`. A half-full + /// cache reports half; the capacity ceiling is not the answer. + /// + /// Constant time: reads `LruCache::len` and touches no entry. + /// + /// Not counted: the LRU's index table, which `LruCache::new` + /// preallocates at full capacity. Below full occupancy the real + /// footprint therefore exceeds this by the unused slots — roughly + /// half a megabyte for an empty default-capacity cache. The + /// contract asks for occupancy, and each resident entry's share of + /// that table is already included above. + pub fn header_cache_bytes(&self) -> u64 { + self.headers.lock().unwrap().len() as u64 * header_entry_bytes() + } + + /// Estimated bytes held by the score LRU at its current occupancy — + /// `entries × score_entry_bytes()`. Same occupancy semantics, + /// constant-time guarantee, and index-table caveat as + /// [`Self::header_cache_bytes`]. + pub fn score_cache_bytes(&self) -> u64 { + self.scores.lock().unwrap().len() as u64 * score_entry_bytes() + } + // ---- Test-only observers ---- /// Peek at the cached header for `height` without updating diff --git a/chain/src/chain.rs b/chain/src/chain.rs index dead316..62a9f8c 100644 --- a/chain/src/chain.rs +++ b/chain/src/chain.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::mem::size_of; use std::num::NonZeroUsize; use std::sync::Arc; @@ -11,8 +12,7 @@ use crate::cache::LazyHeaderStore; use crate::config::ChainConfig; use crate::error::{ChainError, RestoreError}; use crate::voting::{ - default_parameters, default_proposed_update_bytes, ValidationSettingsUpdate, - SOFT_FORK_VOTE, + default_parameters, default_proposed_update_bytes, ValidationSettingsUpdate, SOFT_FORK_VOTE, }; /// Map a signed parameter ID (1-8) to its [`Parameter`] enum variant. @@ -36,8 +36,7 @@ fn ordinary_param(signed_id: i8) -> Option { /// /// Wired by the integrator (main crate) to bridge `enr-store`. Returns /// `None` if no extension is available at that height. -pub type ExtensionLoader = - Arc Option> + Send + Sync + 'static>; +pub type ExtensionLoader = Arc Option> + Send + Sync + 'static>; /// Result of a successful `try_append` call. #[derive(Debug)] @@ -94,11 +93,14 @@ pub struct HeaderChain { base_height: Option, /// Map from header ID to height for O(1) containment / height /// lookup. Kept as an in-memory index because hitting storage on - /// every `contains()` / `height_of()` check is not worth the save - /// (~64 MB at mainnet scale — an order of magnitude below the - /// retired header Vec, which was ~1.4 GB). Also the canonical - /// source for [`Self::height`] / [`Self::len`] — chain length is - /// `by_id.len()` and the tip height is `base_height + by_id.len() - 1`. + /// every `contains()` / `height_of()` check is not worth the save — + /// it costs ~75 MB at mainnet scale, an order of magnitude below + /// the retired header Vec's ~1.4 GB. That figure is derived, not + /// asserted: [`by_id_bytes`] computes it from this map's live + /// capacity, and it is the unbounded term in + /// [`HeaderChain::memory_estimate`]. Also the canonical source for + /// [`Self::height`] / [`Self::len`] — chain length is `by_id.len()` + /// and the tip height is `base_height + by_id.len() - 1`. by_id: HashMap, /// Currently active blockchain parameters (Phase 6: Soft-Fork Voting). /// Updated only at epoch-boundary block validation via @@ -156,6 +158,77 @@ pub struct HeaderChain { lazy: LazyHeaderStore, } +/// Estimated in-memory footprint of a [`HeaderChain`], broken down by +/// the structure that holds it. +/// +/// **Every field is a formula over live counts and capacities, never a +/// measurement.** Read it as attribution guidance — *which structure is +/// the memory in* — not as truth; a heap profile is the ground truth. +/// Each field names the member it models, so a grep for that member +/// reaches the formula. +/// +/// Headers themselves are **not resident** — they are served by the +/// registered `HeaderLoader` through the LRU — and so do not appear +/// here. If a future change makes them resident again, that is a new +/// field with a new name, decided deliberately; it is not slack folded +/// into one of these. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ChainMemoryEstimate { + /// `HeaderChain::by_id` — the `HashMap` header index. + /// The only unbounded structure in this crate: it grows with chain + /// length and is never evicted, so it is the figure an operator + /// actually needs. See `by_id_bytes` for the accounting. + pub index_bytes: u64, + /// The header LRU inside `LazyHeaderStore`, at its current + /// occupancy. Bounded by the cache capacity + /// ([`crate::DEFAULT_CACHE_CAPACITY`] unless retuned). + pub header_cache_bytes: u64, + /// The cumulative-score LRU inside `LazyHeaderStore`, at its + /// current occupancy. Bounded by the same capacity. + pub score_cache_bytes: u64, +} + +/// Buckets `std::collections::HashMap` has allocated for a table +/// reporting `capacity` usable slots. +/// +/// `capacity()` is the only O(1) window into the allocation from +/// outside hashbrown, and it reports the *usable* slot count, not the +/// allocated one. hashbrown sizes tables in powers of two and keeps +/// 12.5% of the slots empty above 8 buckets (`capacity = buckets / 8 * +/// 7`); at or below that it reserves exactly one (`capacity = buckets - +/// 1`). Inverting both branches recovers the bucket count exactly. +/// +/// Saturates rather than panicking on a capacity no real chain can +/// reach — this backs an operator diagnostic, which must not be the +/// thing that takes the node down. +fn hashbrown_buckets(capacity: usize) -> usize { + let unrounded = match capacity { + 0 => return 0, + c if c < 8 => c + 1, + c => c.div_ceil(7).saturating_mul(8), + }; + unrounded.checked_next_power_of_two().unwrap_or(usize::MAX) +} + +/// Estimated bytes held by [`HeaderChain::by_id`]. +/// +/// Formula, not a measurement: `buckets × (slot + 1 control byte)`, +/// where `slot` is the `(BlockId, u32)` pair hashbrown stores per +/// bucket and the bucket count comes from [`hashbrown_buckets`]. +/// +/// Counting `entries × size_of::<(BlockId, u32)>()` instead would +/// under-report by the control bytes and by the 12.5% of slots the load +/// factor keeps empty — and under-reporting the one unbounded structure +/// is the failure mode that matters here. Note also that the map never +/// shrinks on removal, so a chain that reorged down still holds its +/// high-water allocation; reporting from `capacity()` rather than +/// `len()` is what makes that visible. +fn by_id_bytes(by_id: &HashMap) -> u64 { + let buckets = hashbrown_buckets(by_id.capacity()) as u64; + let slot_bytes = size_of::<(BlockId, u32)>() as u64; + buckets * (slot_bytes + 1) +} + /// All-zeros parent ID expected for the genesis header. fn genesis_parent_id() -> BlockId { BlockId(Digest32::zero()) @@ -290,7 +363,9 @@ impl HeaderChain { got: header.height, }); } - Ok(AppendResult::Forked { fork_height: parent_height }) + Ok(AppendResult::Forked { + fork_height: parent_height, + }) } else { Err(ChainError::ParentNotFound { parent_id: header.parent_id, @@ -420,6 +495,32 @@ impl HeaderChain { self.by_id.is_empty() } + /// Estimated in-memory footprint of this chain, by structure. + /// + /// Constant time: reads `HashMap::capacity` and `LruCache::len`, and + /// walks neither the chain, the caches, nor storage. Backs + /// `GET /debug/memory` through the integrator's `ChainAccess`. + /// + /// The values are formulas — see [`ChainMemoryEstimate`] for what + /// that does and does not entitle a caller to conclude. + /// + /// This lives here, beside the fields it models, deliberately. The + /// previous arrangement kept a constant (`AVG_HEADER_BYTES = 800`) + /// in the API crate sizing a `Vec
` that this crate had + /// already retired; nothing connected the two, so the endpoint went + /// on reporting ~1.48 GB for a structure that no longer existed — + /// more than the whole process RSS — and that figure was taken as a + /// leading suspect during a real memory investigation. A refactor + /// that reshapes `by_id` or the caches is now editing the same + /// files as the estimate. + pub fn memory_estimate(&self) -> ChainMemoryEstimate { + ChainMemoryEstimate { + index_bytes: by_id_bytes(&self.by_id), + header_cache_bytes: self.lazy.header_cache_bytes(), + score_cache_bytes: self.lazy.score_cache_bytes(), + } + } + /// Cumulative difficulty score at the chain tip. /// /// Returns `BigUint::ZERO` on an empty chain. Otherwise delegates @@ -600,9 +701,7 @@ impl HeaderChain { } let loader = self.extension_loader.as_ref().ok_or_else(|| { - ChainError::Voting( - "extension loader not set; cannot recompute parameters".into(), - ) + ChainError::Voting("extension loader not set; cannot recompute parameters".into()) })?; let extension_bytes = loader(boundary_height).ok_or_else(|| { @@ -613,9 +712,7 @@ impl HeaderChain { let (header_id, fields) = crate::voting::parse_extension_bytes(&extension_bytes)?; let expected = self.header_at(boundary_height).ok_or_else(|| { - ChainError::Voting(format!( - "no header at boundary height {boundary_height}" - )) + ChainError::Voting(format!("no header at boundary height {boundary_height}")) })?; if header_id != expected.id { return Err(ChainError::Voting(format!( @@ -774,8 +871,7 @@ impl HeaderChain { return Ok(Vec::new()); } - let mut window: Vec<(u32, [u8; 3])> = - Vec::with_capacity((end - start + 1) as usize); + let mut window: Vec<(u32, [u8; 3])> = Vec::with_capacity((end - start + 1) as usize); for h in start..=end { let header = self.header_at(h).ok_or_else(|| { ChainError::Voting(format!( @@ -934,9 +1030,7 @@ impl HeaderChain { ) -> Result, ChainError> { let voting_length = self.config.voting.voting_length; if voting_length == 0 { - return Err(ChainError::Voting( - "voting_length must be > 0".into(), - )); + return Err(ChainError::Voting("voting_length must be > 0".into())); } let boundary_height = epoch_end_height.checked_add(1).ok_or_else(|| { ChainError::Voting(format!( @@ -1028,8 +1122,7 @@ impl HeaderChain { crate::verify_pow(&suffix_head)?; } - let mut installed: Vec = - Vec::with_capacity(1 + suffix_tail.len()); + let mut installed: Vec = Vec::with_capacity(1 + suffix_tail.len()); // Push suffix_head bypassing validate_genesis: it is rarely actually // genesis (height 1) and its parent_id is whatever the upstream chain @@ -1172,10 +1265,7 @@ impl HeaderChain { /// `height` field disagrees with its slot), or not in `by_id`. /// /// Empty and single-header chains verify trivially. - pub fn verify_best_chain_linkage( - &self, - max_depth: Option, - ) -> Result<(), ChainError> { + pub fn verify_best_chain_linkage(&self, max_depth: Option) -> Result<(), ChainError> { let Some(base) = self.base_height else { return Ok(()); }; @@ -1218,13 +1308,13 @@ impl HeaderChain { /// silent-`None` divergence masking so the walk can DESCRIBE the /// divergence instead of reporting a bare gap. fn load_for_linkage_walk(&self, height: u32) -> Result { - let header = self.lazy.get_header(height).ok_or_else(|| { - ChainError::IndexInconsistency { - height, - detail: "header unresolvable (cache miss and loader returned None)" - .into(), - } - })?; + let header = + self.lazy + .get_header(height) + .ok_or_else(|| ChainError::IndexInconsistency { + height, + detail: "header unresolvable (cache miss and loader returned None)".into(), + })?; if header.height != height { return Err(ChainError::IndexInconsistency { height, @@ -1521,14 +1611,10 @@ impl HeaderChain { // header would save the wrong branch for rollback and // remove nothing from `by_id` below. Bail pre-mutation. let hdr = self.header_at(h).ok_or_else(|| { - ChainError::Reorg(format!( - "header at height {h} unavailable for reorg drain" - )) + ChainError::Reorg(format!("header at height {h} unavailable for reorg drain")) })?; let score = self.score_at(h).ok_or_else(|| { - ChainError::Reorg(format!( - "score at height {h} unavailable for reorg drain" - )) + ChainError::Reorg(format!("score at height {h} unavailable for reorg drain")) })?; saved_headers.push(hdr); saved_scores.push(score); @@ -1644,10 +1730,7 @@ impl HeaderChain { /// Append a header skipping PoW verification. /// For tests that need to validate chain logic without real mining solutions. #[cfg(test)] - pub(crate) fn try_append_no_pow( - &mut self, - header: Header, - ) -> Result { + pub(crate) fn try_append_no_pow(&mut self, header: Header) -> Result { if self.is_empty() { self.validate_genesis_no_pow(&header)?; self.push_header(header); @@ -1666,7 +1749,9 @@ impl HeaderChain { got: header.height, }); } - Ok(AppendResult::Forked { fork_height: parent_height }) + Ok(AppendResult::Forked { + fork_height: parent_height, + }) } else { Err(ChainError::ParentNotFound { parent_id: header.parent_id, @@ -1697,7 +1782,9 @@ impl HeaderChain { #[cfg(test)] fn validate_genesis_no_pow(&self, header: &Header) -> Result<(), ChainError> { if header.parent_id != genesis_parent_id() { - return Err(ChainError::InvalidGenesisParent { got: header.parent_id }); + return Err(ChainError::InvalidGenesisParent { + got: header.parent_id, + }); } if header.height != 1 { return Err(ChainError::InvalidGenesisHeight { got: header.height }); @@ -1725,7 +1812,9 @@ impl HeaderChain { let tip = self.tip(); if header.parent_id != tip.id { - return Err(ChainError::ParentNotFound { parent_id: header.parent_id }); + return Err(ChainError::ParentNotFound { + parent_id: header.parent_id, + }); } if header.height != tip.height + 1 { @@ -1767,9 +1856,7 @@ impl HeaderChain { } if header.height != 1 { - return Err(ChainError::InvalidGenesisHeight { - got: header.height, - }); + return Err(ChainError::InvalidGenesisHeight { got: header.height }); } if header.n_bits != self.config.initial_n_bits { diff --git a/chain/src/config.rs b/chain/src/config.rs index cfedd23..352ea88 100644 --- a/chain/src/config.rs +++ b/chain/src/config.rs @@ -98,7 +98,6 @@ impl ChainConfig { /// Whether EIP-37 difficulty adjustment is active at the given height. pub fn eip37_active(&self, height: u32) -> bool { - self.eip37_activation_height - .is_some_and(|h| height >= h) + self.eip37_activation_height.is_some_and(|h| height >= h) } } diff --git a/chain/src/difficulty.rs b/chain/src/difficulty.rs index 4377d52..45d897e 100644 --- a/chain/src/difficulty.rs +++ b/chain/src/difficulty.rs @@ -38,10 +38,8 @@ pub fn expected_difficulty(parent: &Header, chain: &HeaderChain) -> Result = heights - .iter() - .filter_map(|&h| chain.header_at(h)) - .collect(); + let owned_headers: Vec
= + heights.iter().filter_map(|&h| chain.header_at(h)).collect(); if owned_headers.is_empty() { return Ok(config.initial_n_bits); @@ -49,9 +47,19 @@ pub fn expected_difficulty(parent: &Header, chain: &HeaderChain) -> Result = owned_headers.iter().collect(); if config.eip37_active(parent.height + 1) { - eip37_calculate(&headers, epoch_length, config.block_interval_ms, config.initial_n_bits) + eip37_calculate( + &headers, + epoch_length, + config.block_interval_ms, + config.initial_n_bits, + ) } else { - calculate(&headers, epoch_length, config.block_interval_ms, config.initial_n_bits) + calculate( + &headers, + epoch_length, + config.block_interval_ms, + config.initial_n_bits, + ) } } else { // Within an epoch: carry forward parent's difficulty @@ -73,9 +81,7 @@ fn previous_heights_for_recalculation( .collect(); heights.reverse(); heights - } else if (height - 1).is_multiple_of(epoch_length) - && height > epoch_length * use_last_epochs - { + } else if (height - 1).is_multiple_of(epoch_length) && height > epoch_length * use_last_epochs { // Branch 2: epoch boundary with epoch_length <= 1 (i.e. epoch_length == 1) // and enough history. All heights are guaranteed non-negative by the guard. let mut heights: Vec = (0..=use_last_epochs) @@ -103,7 +109,11 @@ pub fn normalize_to_n_bits(difficulty: &BigInt, initial_n_bits: u32) -> u32 { let encoded = encode_compact_bits(difficulty); let n_bits = encoded as u32; // If roundtrip produces 0, use initial difficulty - if n_bits == 0 { initial_n_bits } else { n_bits } + if n_bits == 0 { + initial_n_bits + } else { + n_bits + } } /// Pre-EIP-37 difficulty calculation using linear regression. @@ -128,9 +138,7 @@ pub fn calculate( ChainError::DifficultyCalc("no headers for difficulty calculation".into()) })?; - let uncompressed = if headers.len() == 1 - || first_header.timestamp >= last_header.timestamp - { + let uncompressed = if headers.len() == 1 || first_header.timestamp >= last_header.timestamp { // Single header or timestamps not increasing: return first header's difficulty required_difficulty(first_header) } else { @@ -143,8 +151,7 @@ pub fn calculate( let start = pair[0]; let end = pair[1]; let time_delta = end.timestamp as i64 - start.timestamp as i64; - let diff = - required_difficulty(end) * &desired_ms / BigInt::from(time_delta); + let diff = required_difficulty(end) * &desired_ms / BigInt::from(time_delta); (end.height as i64, diff) }) .collect(); @@ -163,11 +170,7 @@ pub fn calculate( /// Classic Bitcoin-style difficulty adjustment (no capping). /// /// Matches JVM `DifficultyAdjustment.bitcoinCalculate()`. -fn bitcoin_calculate( - headers: &[&Header], - epoch_length: u32, - block_interval_ms: u64, -) -> BigInt { +fn bitcoin_calculate(headers: &[&Header], epoch_length: u32, block_interval_ms: u64) -> BigInt { let last_two: Vec<&Header> = headers.iter().rev().take(2).rev().cloned().collect(); let start = last_two[0]; let end = last_two[1]; @@ -256,7 +259,10 @@ pub fn interpolate(data: &[(i64, BigInt)], epoch_length: i64) -> BigInt { // Sums for linear regression let xy_sum: BigInt = data.iter().map(|(x, y)| BigInt::from(*x) * y).sum(); let x_sum: BigInt = data.iter().map(|(x, _)| BigInt::from(*x)).sum(); - let x2_sum: BigInt = data.iter().map(|(x, _)| BigInt::from(*x) * BigInt::from(*x)).sum(); + let x2_sum: BigInt = data + .iter() + .map(|(x, _)| BigInt::from(*x) * BigInt::from(*x)) + .sum(); let y_sum: BigInt = data.iter().map(|(_, y)| y.clone()).sum(); let n_big = BigInt::from(n); @@ -415,13 +421,22 @@ mod tests { let reference = BigInt::from(1000); // Within range: unchanged - assert_eq!(cap_difficulty(&BigInt::from(1200), &reference), BigInt::from(1200)); + assert_eq!( + cap_difficulty(&BigInt::from(1200), &reference), + BigInt::from(1200) + ); // Above 150%: capped to 1500 - assert_eq!(cap_difficulty(&BigInt::from(2000), &reference), BigInt::from(1500)); + assert_eq!( + cap_difficulty(&BigInt::from(2000), &reference), + BigInt::from(1500) + ); // Below 50%: floored to 500 - assert_eq!(cap_difficulty(&BigInt::from(100), &reference), BigInt::from(500)); + assert_eq!( + cap_difficulty(&BigInt::from(100), &reference), + BigInt::from(500) + ); } #[test] diff --git a/chain/src/lib.rs b/chain/src/lib.rs index 1dfad1d..ec69c7b 100644 --- a/chain/src/lib.rs +++ b/chain/src/lib.rs @@ -20,30 +20,28 @@ mod tracker; pub mod voting; pub use cache::{HeaderLoader, ScoreLoader, DEFAULT_CACHE_CAPACITY}; -pub use chain::{AppendResult, HeaderChain, InstalledHeader}; +pub use chain::{AppendResult, ChainMemoryEstimate, HeaderChain, InstalledHeader}; pub use config::{ChainConfig, Network}; pub use ergo_chain_types::autolykos_pow_scheme::decode_compact_bits; pub use ergo_chain_types::{ADDigest, BlockId, Header}; pub use error::{ChainError, RestoreError}; -pub use pow::verify_pow; +pub use nipopow_proof::{ + build_nipopow_proof, compare_nipopow_proof_bytes, popow_header_by_id, + verify_nipopow_proof_bytes, NipopowVerificationResult, +}; +pub use num_bigint::{BigInt, BigUint}; +pub use pow::{pow_target, verify_pow}; pub use section::{ - required_section_ids, section_ids, - AD_PROOFS_TYPE_ID, BLOCK_TRANSACTIONS_TYPE_ID, EXTENSION_TYPE_ID, HEADER_TYPE_ID, - TRANSACTION_TYPE_ID, + required_section_ids, section_ids, AD_PROOFS_TYPE_ID, BLOCK_TRANSACTIONS_TYPE_ID, + EXTENSION_TYPE_ID, HEADER_TYPE_ID, TRANSACTION_TYPE_ID, }; pub use state_type::StateType; pub use sync_info::{build_sync_info, parse_sync_info, SyncInfo}; -pub use num_bigint::{BigInt, BigUint}; pub use tracker::HeaderTracker; -pub use nipopow_proof::{ - build_nipopow_proof, compare_nipopow_proof_bytes, popow_header_by_id, - verify_nipopow_proof_bytes, NipopowVerificationResult, -}; pub use voting::{ check_fork_vote, compute_boundary_parameters, encode_validation_settings_update, - extract_disabling_rules_from_kv, - pack_extension_bytes, pack_parameters_to_kv, parse_extension_bytes, - parse_parameters_from_kv, parse_validation_settings_update, + extract_disabling_rules_from_kv, pack_extension_bytes, pack_parameters_to_kv, + parse_extension_bytes, parse_parameters_from_kv, parse_validation_settings_update, tally_votes_seeded, ExtensionField, RuleStatus, ValidationSettingsUpdate, VotingConfig, ID_BLOCK_VERSION, ID_SOFT_FORK_DISABLING_RULES, ID_SOFT_FORK_STARTING_HEIGHT, ID_SOFT_FORK_VOTES_COLLECTED, SOFT_FORK_VOTE, diff --git a/chain/src/nipopow_proof.rs b/chain/src/nipopow_proof.rs index b5671ec..944c513 100644 --- a/chain/src/nipopow_proof.rs +++ b/chain/src/nipopow_proof.rs @@ -115,8 +115,7 @@ impl<'a> PopowHeaderReader for ChainPopowReader<'a> { // h >= 2: load real extension bytes, unpack canonical interlinks. let loader = self.chain.extension_loader()?; let ext_bytes = loader(height)?; - let (parsed_header_id, fields) = - crate::voting::parse_extension_bytes(&ext_bytes).ok()?; + let (parsed_header_id, fields) = crate::voting::parse_extension_bytes(&ext_bytes).ok()?; // The loader is not trusted to return matching data: an upstream // backward-walk recovery (e.g., enr-store papering over BEST_CHAIN // holes) can return extension bytes for a different block at the @@ -192,9 +191,7 @@ pub fn build_nipopow_proof( return Err(ChainError::Nipopow("m and k must be >= 1".into())); } if m > MAX_M_K || k > MAX_M_K { - return Err(ChainError::Nipopow(format!( - "m and k must be <= {MAX_M_K}" - ))); + return Err(ChainError::Nipopow(format!("m and k must be <= {MAX_M_K}"))); } // The reader's h>=2 path delegates to the loader, so it must be wired. @@ -241,9 +238,7 @@ pub fn popow_header_by_id( // here it's a real failure (missing extension, parse error, etc). let reader = ChainPopowReader { chain }; let popow_header = reader.popow_header_by_id(id).ok_or_else(|| { - ChainError::Nipopow(format!( - "failed to construct PoPowHeader for {id:?}" - )) + ChainError::Nipopow(format!("failed to construct PoPowHeader for {id:?}")) })?; let bytes = popow_header @@ -304,9 +299,8 @@ fn verify_inner(bytes: &[u8], check_pow: bool) -> Result HeaderChain { + fn build_chain_with_interlinks_opts( + count: u32, + include_genesis_in_loader: bool, + ) -> HeaderChain { let config = ChainConfig::testnet(); let mut chain = HeaderChain::new(config.clone()); let n_bits = config.initial_n_bits; @@ -423,12 +422,8 @@ mod tests { // Compute expected difficulty based on currently-built chain // for nBits inheritance — but to avoid bringing in chain state // here, we just use the parent's n_bits within the first epoch. - let header = make_synthetic_header( - h, - prev_id, - 1_000_000 + (h as u64 - 1) * 45_000, - n_bits, - ); + let header = + make_synthetic_header(h, prev_id, 1_000_000 + (h as u64 - 1) * 45_000, n_bits); prev_id = header.id; headers.push(header); } @@ -450,8 +445,7 @@ mod tests { } // Pack each into extension bytes keyed by height. - let mut store: std::collections::HashMap> = - std::collections::HashMap::new(); + let mut store: std::collections::HashMap> = std::collections::HashMap::new(); for (idx, h) in headers.iter().enumerate() { if h.height == 1 && !include_genesis_in_loader { continue; @@ -469,9 +463,7 @@ mod tests { // Wire loader. let store_arc = Arc::new(Mutex::new(store)); - chain.set_extension_loader(move |height| { - store_arc.lock().unwrap().get(&height).cloned() - }); + chain.set_extension_loader(move |height| store_arc.lock().unwrap().get(&height).cloned()); chain } @@ -499,12 +491,7 @@ mod tests { let n_bits = chain.config().initial_n_bits; let mut prev = BlockId(Digest32::zero()); for h in 1..=10 { - let hdr = make_synthetic_header( - h, - prev, - 1_000_000 + (h as u64 - 1) * 45_000, - n_bits, - ); + let hdr = make_synthetic_header(h, prev, 1_000_000 + (h as u64 - 1) * 45_000, n_bits); prev = hdr.id; chain.try_append_no_pow(hdr).unwrap(); } @@ -619,12 +606,7 @@ mod tests { // Build a small chain. let mut prev = BlockId(Digest32::zero()); for h in 1..=5u32 { - let hdr = make_synthetic_header( - h, - prev, - 1_000_000 + (h as u64 - 1) * 45_000, - n_bits, - ); + let hdr = make_synthetic_header(h, prev, 1_000_000 + (h as u64 - 1) * 45_000, n_bits); prev = hdr.id; chain.try_append_no_pow(hdr).expect("append"); } @@ -644,13 +626,10 @@ mod tests { let bogus_fields = NipopowAlgos::pack_interlinks(vec![bogus_id]); let bogus_bytes = pack_extension_bytes(&bogus_id, &bogus_fields); - let mut store: std::collections::HashMap> = - std::collections::HashMap::new(); + let mut store: std::collections::HashMap> = std::collections::HashMap::new(); store.insert(3u32, bogus_bytes); let store_arc = Arc::new(Mutex::new(store)); - chain.set_extension_loader(move |height| { - store_arc.lock().unwrap().get(&height).cloned() - }); + chain.set_extension_loader(move |height| store_arc.lock().unwrap().get(&height).cloned()); let reader = ChainPopowReader { chain: &chain }; let result = reader.popow_header_at_height(3); @@ -692,12 +671,8 @@ mod tests { let mut headers: Vec
= Vec::with_capacity(count as usize); let mut prev_id = BlockId(Digest32::zero()); for h in 1..=count { - let header = make_synthetic_header( - h, - prev_id, - 1_000_000 + (h as u64 - 1) * 45_000, - n_bits, - ); + let header = + make_synthetic_header(h, prev_id, 1_000_000 + (h as u64 - 1) * 45_000, n_bits); prev_id = header.id; headers.push(header); } @@ -722,8 +697,7 @@ mod tests { // are well-formed and parse cleanly through `parse_extension_bytes` // — they just don't match the header at the queried height. This // models enr-store returning a stale entry for a missing modifier. - let mut store: std::collections::HashMap> = - std::collections::HashMap::new(); + let mut store: std::collections::HashMap> = std::collections::HashMap::new(); for (idx, h) in headers.iter().enumerate() { let bytes = if h.height >= 16 && h.height <= 48 && idx >= 4 { let src_idx = idx - 4; @@ -741,9 +715,7 @@ mod tests { } let store_arc = Arc::new(Mutex::new(store)); - chain.set_extension_loader(move |height| { - store_arc.lock().unwrap().get(&height).cloned() - }); + chain.set_extension_loader(move |height| store_arc.lock().unwrap().get(&height).cloned()); // Build with both anchor variants: `None` (uses last_headers) and // an explicit deep anchor (uses popow_header_by_id directly). The diff --git a/chain/src/pow.rs b/chain/src/pow.rs index 4700e20..532def8 100644 --- a/chain/src/pow.rs +++ b/chain/src/pow.rs @@ -2,7 +2,7 @@ use ergo_chain_types::autolykos_pow_scheme::{ decode_compact_bits, order_bigint, AutolykosPowScheme, }; use ergo_chain_types::Header; -use num_bigint::ToBigInt; +use num_bigint::{BigInt, ToBigInt}; use crate::error::ChainError; @@ -13,10 +13,36 @@ fn pow_scheme() -> &'static AutolykosPowScheme { POW.get_or_init(AutolykosPowScheme::default) } +/// The Autolykos mining target for a header's compact difficulty encoding. +/// +/// `q / decode_compact_bits(n_bits)`, where `q` is the secp256k1 group order. +/// A `pow_hit` is valid when it falls **strictly below** this value. +/// +/// ⚠ **`decode_compact_bits` returns the difficulty, not the target.** The name +/// invites the opposite reading — it sounds like it yields the number you compare +/// a hash against — and every other consumer in this crate binds it to a variable +/// called `difficulty`, which is correct: chain work accumulates the difficulty, +/// undivided. Only the PoW comparison takes the `q /` division, and only through +/// this function. +/// +/// This exists because the division was missed once. `mining/` re-derived the +/// target from `decode_compact_bits` for the `WorkMessage.b` field it serves to +/// external miners and stopped one operation short, handing them a target tens of +/// orders of magnitude too hard. A GPU miner ran nine minutes at 143 MH/s and +/// submitted zero shares, while this crate's `verify_pow` — computing the target +/// correctly — stood ready to accept a block nobody could find. Serve-side and +/// verify-side now resolve to the same number by construction. **Do not inline +/// `order_bigint() / decode_compact_bits(..)` at a new call site; call this.** +/// +/// See `../facts/chain.md` § "Phase 2: PoW Verification" for the full account. +pub fn pow_target(n_bits: u32) -> BigInt { + order_bigint() / decode_compact_bits(n_bits) +} + /// Verify that a header's proof of work is valid for its claimed difficulty. /// /// Computes `pow_hit(header)` and checks that it is strictly less than -/// `q / decode_compact_bits(header.n_bits)`, where `q` is the secp256k1 group order. +/// [`pow_target`] for the header's `n_bits`. /// /// Returns `Ok(())` if valid, `Err(ChainError::PowInvalid)` if the hit doesn't /// meet the target, or `Err(ChainError::PowCompute)` if the hit can't be computed. @@ -24,15 +50,12 @@ pub fn verify_pow(header: &Header) -> Result<(), ChainError> { let pow = pow_scheme(); let hit = pow.pow_hit(header)?; - let decoded_n_bits = decode_compact_bits(header.n_bits); - let target = order_bigint() / decoded_n_bits; + let target = pow_target(header.n_bits); - let hit_bigint = hit - .to_bigint() - .ok_or(ChainError::PowInvalid { - hit: format!("{hit}"), - target: format!("{target}"), - })?; + let hit_bigint = hit.to_bigint().ok_or(ChainError::PowInvalid { + hit: format!("{hit}"), + target: format!("{target}"), + })?; if hit_bigint < target { Ok(()) diff --git a/chain/src/section.rs b/chain/src/section.rs index 0161342..6e4a9b0 100644 --- a/chain/src/section.rs +++ b/chain/src/section.rs @@ -30,9 +30,22 @@ pub const TRANSACTION_TYPE_ID: u8 = 2; /// [`required_section_ids`] instead. pub fn section_ids(header: &Header) -> [(u8, [u8; 32]); 3] { [ - (BLOCK_TRANSACTIONS_TYPE_ID, prefixed_hash(BLOCK_TRANSACTIONS_TYPE_ID, &header.id.0 .0, &header.transaction_root.0)), - (AD_PROOFS_TYPE_ID, prefixed_hash(AD_PROOFS_TYPE_ID, &header.id.0 .0, &header.ad_proofs_root.0)), - (EXTENSION_TYPE_ID, prefixed_hash(EXTENSION_TYPE_ID, &header.id.0 .0, &header.extension_root.0)), + ( + BLOCK_TRANSACTIONS_TYPE_ID, + prefixed_hash( + BLOCK_TRANSACTIONS_TYPE_ID, + &header.id.0 .0, + &header.transaction_root.0, + ), + ), + ( + AD_PROOFS_TYPE_ID, + prefixed_hash(AD_PROOFS_TYPE_ID, &header.id.0 .0, &header.ad_proofs_root.0), + ), + ( + EXTENSION_TYPE_ID, + prefixed_hash(EXTENSION_TYPE_ID, &header.id.0 .0, &header.extension_root.0), + ), ] } diff --git a/chain/src/sync_info.rs b/chain/src/sync_info.rs index f6aad02..15f64bb 100644 --- a/chain/src/sync_info.rs +++ b/chain/src/sync_info.rs @@ -49,7 +49,8 @@ pub fn build_sync_info(chain: &HeaderChain) -> Vec { } let tip_height = chain.height(); - let base_height = chain.header_at(tip_height - (chain.len() as u32 - 1)) + let base_height = chain + .header_at(tip_height - (chain.len() as u32 - 1)) .map(|h| h.height) .unwrap_or(1); @@ -90,7 +91,9 @@ pub fn parse_sync_info(body: &[u8]) -> Result { } let mut r = Cursor::new(body); - let count = r.get_u16().map_err(|e| ChainError::SyncInfo(e.to_string()))?; + let count = r + .get_u16() + .map_err(|e| ChainError::SyncInfo(e.to_string()))?; if count > 0 { parse_v1(&mut r, count) @@ -118,14 +121,18 @@ fn parse_v1(r: &mut Cursor<&[u8]>, count: u16) -> Result { } fn parse_v2(r: &mut Cursor<&[u8]>) -> Result { - let mode = r.get_u8().map_err(|e| ChainError::SyncInfo(format!("missing V2 mode byte: {e}")))?; + let mode = r + .get_u8() + .map_err(|e| ChainError::SyncInfo(format!("missing V2 mode byte: {e}")))?; if mode != V2_MODE_BYTE { return Err(ChainError::SyncInfo(format!( "expected V2 mode byte 0xFF, got 0x{mode:02X}" ))); } - let count = r.get_u8().map_err(|e| ChainError::SyncInfo(format!("missing V2 count: {e}")))?; + let count = r + .get_u8() + .map_err(|e| ChainError::SyncInfo(format!("missing V2 count: {e}")))?; if count > MAX_V2_HEADERS { return Err(ChainError::SyncInfo(format!( "V2 header count {count} exceeds max {MAX_V2_HEADERS}" @@ -134,9 +141,9 @@ fn parse_v2(r: &mut Cursor<&[u8]>) -> Result { let mut headers = Vec::with_capacity(count as usize); for i in 0..count { - let size = r.get_u16().map_err(|e| { - ChainError::SyncInfo(format!("V2 header {i} size: {e}")) - })?; + let size = r + .get_u16() + .map_err(|e| ChainError::SyncInfo(format!("V2 header {i} size: {e}")))?; if size > MAX_V2_HEADER_SIZE { return Err(ChainError::SyncInfo(format!( "V2 header {i} size {size} exceeds max {MAX_V2_HEADER_SIZE}" diff --git a/chain/src/tests.rs b/chain/src/tests.rs index 0f04778..8d6c50e 100644 --- a/chain/src/tests.rs +++ b/chain/src/tests.rs @@ -50,8 +50,14 @@ mod parse_tests { // fills pow_onetime_pk with the group generator — matching the JVM // (`wForV2 = CryptoConstants.dlogGroup.generator`) — while pow_distance // stays None. - assert_eq!(parsed.autolykos_solution.miner_pk, from_json.autolykos_solution.miner_pk); - assert_eq!(parsed.autolykos_solution.nonce, from_json.autolykos_solution.nonce); + assert_eq!( + parsed.autolykos_solution.miner_pk, + from_json.autolykos_solution.miner_pk + ); + assert_eq!( + parsed.autolykos_solution.nonce, + from_json.autolykos_solution.nonce + ); assert_eq!( parsed.autolykos_solution.pow_onetime_pk, Some(Box::new(ergo_chain_types::ec_point::generator())) @@ -94,7 +100,10 @@ mod parse_tests { assert_eq!(parsed.version, 1); assert_eq!(parsed.height, 3132); assert_eq!(parsed.id, from_json.id); - assert_eq!(parsed.autolykos_solution.pow_distance, from_json.autolykos_solution.pow_distance); + assert_eq!( + parsed.autolykos_solution.pow_distance, + from_json.autolykos_solution.pow_distance + ); assert!(parsed.autolykos_solution.pow_onetime_pk.is_some()); } @@ -206,6 +215,47 @@ mod pow_tests { assert_eq!(parsed.id, from_json.id); assert!(verify_pow(&parsed).is_ok()); } + + /// `pow_target` against values observed on a live Scala node. + /// + /// Provenance — which numbers are evidence and which is arithmetic: + /// - `difficulty` (3912040448) and `target` (the 68-digit literal) were read + /// off a Scala node at testnet height 485,897 and compared digit for digit. + /// These are observations. + /// - `n_bits` (83945773 = 0x0500e92d) is **derived** — computed here by + /// canonical compact-bits encoding of the observed difficulty, not read + /// off the wire. The round-trip assertion below runs first so a bad + /// derivation fails loudly instead of quietly testing a different + /// difficulty than the one the target was observed against. + /// + /// Asserted as literals on purpose. Restating the formula + /// (`pow_target(n) == order_bigint() / decode_compact_bits(n)`) would pass on + /// any self-consistent definition, including the wrong one that shipped in + /// `mining/` and handed miners an impossible target. + #[test] + fn pow_target_matches_scala_node() { + use crate::{decode_compact_bits, pow_target, BigInt}; + use std::str::FromStr; + + const N_BITS: u32 = 83945773; // 0x0500e92d — derived, see above + const DIFFICULTY: u64 = 3912040448; // observed + const TARGET: &str = // observed + "29598898778389163379010897437604384363675568080188445020547283242588"; + + // Derivation check first: does this n_bits actually encode that difficulty? + assert_eq!( + decode_compact_bits(N_BITS), + BigInt::from(DIFFICULTY), + "n_bits derivation is wrong — the target assertion below would be \ + testing a difficulty the Scala node never reported" + ); + + assert_eq!(pow_target(N_BITS), BigInt::from_str(TARGET).unwrap()); + + // The invariant the contract states: the target is never the difficulty. + // At this difficulty they are ~58 decimal orders of magnitude apart. + assert_ne!(pow_target(N_BITS), decode_compact_bits(N_BITS)); + } } #[cfg(test)] @@ -334,12 +384,7 @@ mod chain_tests { use sigma_ser::ScorexSerializable; /// Build a header with a computed ID. For chain tests — no real PoW. - fn make_chain_header( - height: u32, - parent_id: BlockId, - timestamp: u64, - n_bits: u32, - ) -> Header { + fn make_chain_header(height: u32, parent_id: BlockId, timestamp: u64, n_bits: u32) -> Header { let zero32 = Digest32::zero(); let mut header = Header { version: 2, @@ -418,7 +463,12 @@ mod chain_tests { let config = testnet_config(); let mut chain = HeaderChain::new(config.clone()); // Genesis with non-zero parent - let bad = make_chain_header(1, BlockId(Digest32::from([1u8; 32])), 1_000_000, config.initial_n_bits); + let bad = make_chain_header( + 1, + BlockId(Digest32::from([1u8; 32])), + 1_000_000, + config.initial_n_bits, + ); let err = chain.try_append_no_pow(bad).unwrap_err(); assert!(matches!(err, ChainError::InvalidGenesisParent { .. })); @@ -448,9 +498,10 @@ mod chain_tests { fn reject_genesis_wrong_id() { // Build a config with a specific genesis ID requirement let mut config = testnet_config(); - let expected_id: BlockId = "b0244dfc267baca974a4caee06120321562784303a8a688976ae56170e4d175b" - .parse() - .unwrap(); + let expected_id: BlockId = + "b0244dfc267baca974a4caee06120321562784303a8a688976ae56170e4d175b" + .parse() + .unwrap(); config.genesis_id = Some(expected_id); let mut chain = HeaderChain::new(config.clone()); @@ -487,7 +538,12 @@ mod chain_tests { chain.try_append_no_pow(genesis).unwrap(); // Child pointing to a non-existent parent - let bad = make_chain_header(2, BlockId(Digest32::from([0xAB; 32])), 2_000_000, config.initial_n_bits); + let bad = make_chain_header( + 2, + BlockId(Digest32::from([0xAB; 32])), + 2_000_000, + config.initial_n_bits, + ); let err = chain.try_append_no_pow(bad).unwrap_err(); assert!(matches!(err, ChainError::ParentNotFound { .. })); } @@ -546,13 +602,17 @@ mod chain_tests { for h in 2..=10 { let tip = chain.tip(); let expected_n_bits = crate::difficulty::expected_difficulty(&tip, &chain).unwrap(); - let header = make_chain_header(h, tip.id, 1_000_000 + h as u64 * 45_000, expected_n_bits); + let header = + make_chain_header(h, tip.id, 1_000_000 + h as u64 * 45_000, expected_n_bits); chain.try_append_no_pow(header).unwrap(); } let existing = chain.header_at(5).unwrap().clone(); let result = chain.try_append_no_pow(existing).unwrap(); - assert!(matches!(result, crate::AppendResult::Forked { fork_height: 4 })); + assert!(matches!( + result, + crate::AppendResult::Forked { fork_height: 4 } + )); assert_eq!(chain.height(), 10, "chain should be unchanged"); } @@ -568,14 +628,23 @@ mod chain_tests { for h in 2..=10 { let tip = chain.tip(); let expected_n_bits = crate::difficulty::expected_difficulty(&tip, &chain).unwrap(); - let header = make_chain_header(h, tip.id, 1_000_000 + h as u64 * 45_000, expected_n_bits); + let header = + make_chain_header(h, tip.id, 1_000_000 + h as u64 * 45_000, expected_n_bits); chain.try_append_no_pow(header).unwrap(); } let mid_header = chain.header_at(5).unwrap(); - let fork_child = make_chain_header(6, mid_header.id, mid_header.timestamp + 1000, config.initial_n_bits); + let fork_child = make_chain_header( + 6, + mid_header.id, + mid_header.timestamp + 1000, + config.initial_n_bits, + ); let result = chain.try_append_no_pow(fork_child).unwrap(); - assert!(matches!(result, crate::AppendResult::Forked { fork_height: 5 })); + assert!(matches!( + result, + crate::AppendResult::Forked { fork_height: 5 } + )); assert_eq!(chain.height(), 10, "chain should be unchanged"); } @@ -663,15 +732,9 @@ mod chain_tests { // approximately the same (the actual result depends on the linear regression // with only one epoch of data, which returns the same difficulty). let parent = chain.tip(); - let expected_n_bits = - crate::difficulty::expected_difficulty(&parent, &chain).unwrap(); - - let header129 = make_chain_header( - 129, - prev_id, - 1_000_000 + 128 * 45_000, - expected_n_bits, - ); + let expected_n_bits = crate::difficulty::expected_difficulty(&parent, &chain).unwrap(); + + let header129 = make_chain_header(129, prev_id, 1_000_000 + 128 * 45_000, expected_n_bits); assert!(chain.try_append_no_pow(header129).is_ok()); assert_eq!(chain.height(), 129); } @@ -683,13 +746,14 @@ mod reorg_tests { use ergo_chain_types::*; use sigma_ser::ScorexSerializable; - fn make_chain_header( - height: u32, - parent_id: BlockId, - timestamp: u64, - n_bits: u32, - ) -> Header { - make_chain_header_with_nonce(height, parent_id, timestamp, n_bits, height.to_be_bytes().repeat(2)) + fn make_chain_header(height: u32, parent_id: BlockId, timestamp: u64, n_bits: u32) -> Header { + make_chain_header_with_nonce( + height, + parent_id, + timestamp, + n_bits, + height.to_be_bytes().repeat(2), + ) } /// Build a header with a specific nonce — different nonces at the same @@ -733,7 +797,12 @@ mod reorg_tests { } fn make_genesis(config: &ChainConfig) -> Header { - make_chain_header(1, BlockId(Digest32::zero()), 1_000_000, config.initial_n_bits) + make_chain_header( + 1, + BlockId(Digest32::zero()), + 1_000_000, + config.initial_n_bits, + ) } /// Build a chain of `count` headers, returning the chain. @@ -750,7 +819,12 @@ mod reorg_tests { for h in 2..=count { let tip = chain.tip(); let expected_n_bits = crate::difficulty::expected_difficulty(&tip, &chain).unwrap(); - let header = make_chain_header(h, tip.id, 1_000_000 + (h as u64 - 1) * 45_000, expected_n_bits); + let header = make_chain_header( + h, + tip.id, + 1_000_000 + (h as u64 - 1) * 45_000, + expected_n_bits, + ); chain.try_append_no_pow(header).unwrap(); } @@ -770,7 +844,10 @@ mod reorg_tests { // Alternative block at height 3 — same parent as current tip, different nonce let alt_tip = make_chain_header_with_nonce( - 3, parent_id, parent_ts + 50_000, n_bits, + 3, + parent_id, + parent_ts + 50_000, + n_bits, vec![0xFF; 8], // different nonce → different ID ); assert_ne!(alt_tip.id, old_tip_id, "alternative must have different ID"); @@ -778,7 +855,9 @@ mod reorg_tests { // Continuation at height 4 building on the alternative let continuation = make_chain_header(4, alt_tip.id, parent_ts + 100_000, n_bits); - let replaced = chain.try_reorg_no_pow(alt_tip.clone(), continuation.clone()).unwrap(); + let replaced = chain + .try_reorg_no_pow(alt_tip.clone(), continuation.clone()) + .unwrap(); assert_eq!(replaced, old_tip_id); assert_eq!(chain.height(), 4); @@ -798,7 +877,10 @@ mod reorg_tests { // Alternative pointing to some random parent let alt_tip = make_chain_header_with_nonce( - 3, BlockId(Digest32::from([0xAB; 32])), 2_100_000, n_bits, + 3, + BlockId(Digest32::from([0xAB; 32])), + 2_100_000, + n_bits, vec![0xFF; 8], ); let continuation = make_chain_header(4, alt_tip.id, 2_200_000, n_bits); @@ -817,12 +899,15 @@ mod reorg_tests { let parent_ts = parent.timestamp; let n_bits = parent.n_bits; - let alt_tip = make_chain_header_with_nonce( - 3, parent_id, parent_ts + 50_000, n_bits, - vec![0xFF; 8], - ); + let alt_tip = + make_chain_header_with_nonce(3, parent_id, parent_ts + 50_000, n_bits, vec![0xFF; 8]); // Continuation points to wrong parent (not the alternative) - let continuation = make_chain_header(4, BlockId(Digest32::from([0xCD; 32])), parent_ts + 100_000, n_bits); + let continuation = make_chain_header( + 4, + BlockId(Digest32::from([0xCD; 32])), + parent_ts + 100_000, + n_bits, + ); let result = chain.try_reorg_no_pow(alt_tip, continuation); assert!(result.is_err()); @@ -839,10 +924,8 @@ mod reorg_tests { let n_bits = parent.n_bits; // Height 5 instead of 3 - let alt_tip = make_chain_header_with_nonce( - 5, parent_id, parent_ts + 50_000, n_bits, - vec![0xFF; 8], - ); + let alt_tip = + make_chain_header_with_nonce(5, parent_id, parent_ts + 50_000, n_bits, vec![0xFF; 8]); let continuation = make_chain_header(6, alt_tip.id, parent_ts + 100_000, n_bits); let result = chain.try_reorg_no_pow(alt_tip, continuation); @@ -855,7 +938,12 @@ mod reorg_tests { let config = testnet_config(); let mut chain = HeaderChain::new(config.clone()); - let alt = make_chain_header(1, BlockId(Digest32::zero()), 1_000_000, config.initial_n_bits); + let alt = make_chain_header( + 1, + BlockId(Digest32::zero()), + 1_000_000, + config.initial_n_bits, + ); let cont = make_chain_header(2, alt.id, 2_000_000, config.initial_n_bits); let result = chain.try_reorg_no_pow(alt, cont); @@ -869,7 +957,10 @@ mod reorg_tests { let n_bits = chain.tip().n_bits; let alt = make_chain_header_with_nonce( - 1, BlockId(Digest32::zero()), 1_000_000, n_bits, + 1, + BlockId(Digest32::zero()), + 1_000_000, + n_bits, vec![0xFF; 8], ); let cont = make_chain_header(2, alt.id, 2_000_000, n_bits); @@ -885,12 +976,7 @@ mod sync_info_tests { use ergo_chain_types::*; use sigma_ser::ScorexSerializable; - fn make_chain_header( - height: u32, - parent_id: BlockId, - timestamp: u64, - n_bits: u32, - ) -> Header { + fn make_chain_header(height: u32, parent_id: BlockId, timestamp: u64, n_bits: u32) -> Header { let zero32 = Digest32::zero(); let mut header = Header { version: 2, @@ -923,7 +1009,12 @@ mod sync_info_tests { } fn make_genesis(config: &ChainConfig) -> Header { - make_chain_header(1, BlockId(Digest32::zero()), 1_000_000, config.initial_n_bits) + make_chain_header( + 1, + BlockId(Digest32::zero()), + 1_000_000, + config.initial_n_bits, + ) } fn build_test_chain(count: u32) -> HeaderChain { @@ -939,8 +1030,7 @@ mod sync_info_tests { for h in 2..=count { let parent = chain.tip(); - let expected_n_bits = - crate::difficulty::expected_difficulty(&parent, &chain).unwrap(); + let expected_n_bits = crate::difficulty::expected_difficulty(&parent, &chain).unwrap(); let timestamp = 1_000_000 + (h as u64 - 1) * 45_000; let header = make_chain_header(h, prev_id, timestamp, expected_n_bits); prev_id = header.id; @@ -1215,13 +1305,14 @@ mod score_and_deep_reorg_tests { use num_bigint::BigUint; use sigma_ser::ScorexSerializable; - fn make_chain_header( - height: u32, - parent_id: BlockId, - timestamp: u64, - n_bits: u32, - ) -> Header { - make_chain_header_with_nonce(height, parent_id, timestamp, n_bits, height.to_be_bytes().repeat(2)) + fn make_chain_header(height: u32, parent_id: BlockId, timestamp: u64, n_bits: u32) -> Header { + make_chain_header_with_nonce( + height, + parent_id, + timestamp, + n_bits, + height.to_be_bytes().repeat(2), + ) } fn make_chain_header_with_nonce( @@ -1263,7 +1354,12 @@ mod score_and_deep_reorg_tests { } fn make_genesis(config: &ChainConfig) -> Header { - make_chain_header(1, BlockId(Digest32::zero()), 1_000_000, config.initial_n_bits) + make_chain_header( + 1, + BlockId(Digest32::zero()), + 1_000_000, + config.initial_n_bits, + ) } fn build_test_chain(count: u32) -> HeaderChain { @@ -1277,7 +1373,12 @@ mod score_and_deep_reorg_tests { for h in 2..=count { let tip = chain.tip(); let expected_n_bits = crate::difficulty::expected_difficulty(&tip, &chain).unwrap(); - let header = make_chain_header(h, tip.id, 1_000_000 + (h as u64 - 1) * 45_000, expected_n_bits); + let header = make_chain_header( + h, + tip.id, + 1_000_000 + (h as u64 - 1) * 45_000, + expected_n_bits, + ); chain.try_append_no_pow(header).unwrap(); } chain @@ -1295,7 +1396,10 @@ mod score_and_deep_reorg_tests { fn cumulative_score_increases_on_append() { let chain = build_test_chain(5); let score = chain.cumulative_score(); - assert!(score > BigUint::ZERO, "score should be positive after appending headers"); + assert!( + score > BigUint::ZERO, + "score should be positive after appending headers" + ); // Each header contributes decode_compact_bits(n_bits). With constant n_bits, // the score should be n * difficulty. let score_at_1 = chain.score_at(1).unwrap().clone(); @@ -1367,7 +1471,10 @@ mod score_and_deep_reorg_tests { let mut prev_id = genesis_id; for h in 2..=5 { let header = make_chain_header_with_nonce( - h, prev_id, genesis_ts + (h as u64) * 50_000, n_bits, + h, + prev_id, + genesis_ts + (h as u64) * 50_000, + n_bits, vec![0xAA; 8], ); prev_id = header.id; @@ -1396,8 +1503,10 @@ mod score_and_deep_reorg_tests { let genesis_ts = chain.header_at(1).unwrap().timestamp; // First header is valid, second has timestamp <= first (invalid) - let h2 = make_chain_header_with_nonce(2, genesis_id, genesis_ts + 50_000, n_bits, vec![0xBB; 8]); - let h3_bad = make_chain_header_with_nonce(3, h2.id, genesis_ts + 10_000, n_bits, vec![0xBB; 8]); // ts goes backwards + let h2 = + make_chain_header_with_nonce(2, genesis_id, genesis_ts + 50_000, n_bits, vec![0xBB; 8]); + let h3_bad = + make_chain_header_with_nonce(3, h2.id, genesis_ts + 10_000, n_bits, vec![0xBB; 8]); // ts goes backwards let result = chain.try_reorg_deep_no_pow(1, vec![h2, h3_bad]); assert!(result.is_err()); @@ -1438,7 +1547,10 @@ mod score_and_deep_reorg_tests { let mut prev_id = genesis_id; for h in 2..=6 { let header = make_chain_header_with_nonce( - h, prev_id, genesis_ts + (h as u64) * 50_000, n_bits, + h, + prev_id, + genesis_ts + (h as u64) * 50_000, + n_bits, vec![0xCC; 8], ); prev_id = header.id; @@ -1503,21 +1615,13 @@ mod voting_chain_tests { /// Build a testnet chain of `count` headers whose votes come from /// `votes_at(height)`. The seeded tally makes vote PLACEMENT matter: /// only ids voted by the epoch's opening boundary header accumulate. - fn build_chain_with_votes_fn( - count: u32, - votes_at: impl Fn(u32) -> [u8; 3], - ) -> HeaderChain { + fn build_chain_with_votes_fn(count: u32, votes_at: impl Fn(u32) -> [u8; 3]) -> HeaderChain { let config = testnet_config(); let mut chain = HeaderChain::new(config.clone()); let n_bits = config.initial_n_bits; - let genesis = make_header_with_votes( - 1, - BlockId(Digest32::zero()), - 1_000_000, - n_bits, - votes_at(1), - ); + let genesis = + make_header_with_votes(1, BlockId(Digest32::zero()), 1_000_000, n_bits, votes_at(1)); let mut prev_id = genesis.id; chain.try_append_no_pow(genesis).unwrap(); @@ -1573,7 +1677,10 @@ mod voting_chain_tests { let chain = HeaderChain::new(ChainConfig::mainnet()); assert!(chain.is_epoch_boundary(1024)); assert!(!chain.is_epoch_boundary(1023)); - assert!(!chain.is_epoch_boundary(128), "128 is testnet boundary, not mainnet"); + assert!( + !chain.is_epoch_boundary(128), + "128 is testnet boundary, not mainnet" + ); } #[test] @@ -1711,7 +1818,10 @@ mod voting_chain_tests { .compute_expected_parameters_for_candidate(256, &hostile, [0, 0, 0]) .unwrap(); assert_eq!(params, with_empty); - assert_eq!(activated, crate::voting::ValidationSettingsUpdate::default()); + assert_eq!( + activated, + crate::voting::ValidationSettingsUpdate::default() + ); } #[test] @@ -1815,12 +1925,20 @@ mod voting_chain_tests { matches!(err, crate::ChainError::Voting(_)), "rule 214 must reject the contradictory vote field, got: {err}" ); - assert_eq!(chain.height(), 5, "the rejected header must not be appended"); + assert_eq!( + chain.height(), + 5, + "the rejected header must not be appended" + ); // A valid-votes sibling at the same position extends normally. let good = make_header_with_votes(6, tip.id, ts, nb, [1, 2, 0]); chain.try_append_no_pow(good).unwrap(); - assert_eq!(chain.height(), 6, "the valid-votes sibling extends normally"); + assert_eq!( + chain.height(), + 6, + "the valid-votes sibling extends normally" + ); } #[test] @@ -1969,13 +2087,8 @@ mod voting_chain_tests { fn compute_expected_parameters_boundary_fork_vote_starts_round() { // Only the boundary header (256) votes for the fork → a round // starts: id 122 = 256, id 121 = 0. - let chain = build_chain_with_votes_fn(256, |h| { - if h == 256 { - [120, 0, 0] - } else { - [0, 0, 0] - } - }); + let chain = + build_chain_with_votes_fn(256, |h| if h == 256 { [120, 0, 0] } else { [0, 0, 0] }); let expected = chain.compute_expected_parameters(256, &[]).unwrap(); assert_eq!(expected.soft_fork_starting_height(), Some(256)); assert_eq!(expected.soft_fork_votes_collected(), Some(0)); @@ -2024,12 +2137,14 @@ mod voting_chain_tests { } }); let mut params = chain.active_parameters().clone(); - params - .parameters_table - .insert(ergo_lib::chain::parameters::Parameter::SoftForkStartingHeight, 128); - params - .parameters_table - .insert(ergo_lib::chain::parameters::Parameter::SoftForkVotesCollected, 0); + params.parameters_table.insert( + ergo_lib::chain::parameters::Parameter::SoftForkStartingHeight, + 128, + ); + params.parameters_table.insert( + ergo_lib::chain::parameters::Parameter::SoftForkVotesCollected, + 0, + ); let keep = chain.active_proposed_update_bytes().to_vec(); chain.apply_epoch_boundary_parameters(params, keep); @@ -2064,8 +2179,15 @@ mod voting_chain_tests { .unwrap(); assert_eq!(via_candidate, via_header); - assert_eq!(via_candidate.storage_fee_factor(), 1_275_000, "step applied"); - assert_eq!(activated, crate::voting::ValidationSettingsUpdate::default()); + assert_eq!( + via_candidate.storage_fee_factor(), + 1_275_000, + "step applied" + ); + assert_eq!( + activated, + crate::voting::ValidationSettingsUpdate::default() + ); } #[test] @@ -2124,7 +2246,11 @@ mod voting_chain_tests { chain.try_append_no_pow(g).unwrap(); for h in 2..=50 { let header = make_header_with_votes( - h, prev, 1_000_000 + (h as u64 - 1) * 45_000, n_bits, [0, 0, 0], + h, + prev, + 1_000_000 + (h as u64 - 1) * 45_000, + n_bits, + [0, 0, 0], ); prev = header.id; chain.try_append_no_pow(header).unwrap(); @@ -2151,7 +2277,11 @@ mod voting_chain_tests { chain.try_append_no_pow(g).unwrap(); for h in 2..=130 { let header = make_header_with_votes( - h, prev, 1_000_000 + (h as u64 - 1) * 45_000, n_bits, [0, 0, 0], + h, + prev, + 1_000_000 + (h as u64 - 1) * 45_000, + n_bits, + [0, 0, 0], ); prev = header.id; chain.try_append_no_pow(header).unwrap(); @@ -2199,7 +2329,11 @@ mod voting_chain_tests { chain.try_append_no_pow(g).unwrap(); for h in 2..=130 { let header = make_header_with_votes( - h, prev, 1_000_000 + (h as u64 - 1) * 45_000, n_bits, [0, 0, 0], + h, + prev, + 1_000_000 + (h as u64 - 1) * 45_000, + n_bits, + [0, 0, 0], ); prev = header.id; chain.try_append_no_pow(header).unwrap(); @@ -2222,7 +2356,11 @@ mod voting_chain_tests { chain.try_append_no_pow(g).unwrap(); for h in 2..=130 { let header = make_header_with_votes( - h, prev, 1_000_000 + (h as u64 - 1) * 45_000, n_bits, [0, 0, 0], + h, + prev, + 1_000_000 + (h as u64 - 1) * 45_000, + n_bits, + [0, 0, 0], ); prev = header.id; chain.try_append_no_pow(header).unwrap(); @@ -2248,7 +2386,11 @@ mod voting_chain_tests { chain.try_append_no_pow(g).unwrap(); for h in 2..=130 { let header = make_header_with_votes( - h, prev, 1_000_000 + (h as u64 - 1) * 45_000, n_bits, [0, 0, 0], + h, + prev, + 1_000_000 + (h as u64 - 1) * 45_000, + n_bits, + [0, 0, 0], ); prev = header.id; chain.try_append_no_pow(header).unwrap(); @@ -2273,7 +2415,10 @@ mod voting_chain_tests { let r = chain.recompute_active_parameters_from_storage(tip); assert!(r.is_err()); let msg = format!("{}", r.unwrap_err()); - assert!(msg.contains("mismatch"), "expected mismatch error, got: {msg}"); + assert!( + msg.contains("mismatch"), + "expected mismatch error, got: {msg}" + ); } #[test] @@ -2315,7 +2460,9 @@ mod voting_chain_tests { // At each, simulate validator: compute, then apply. let mut current_height = voting_length; while current_height <= voting_end { - let params = chain.compute_expected_parameters(current_height, &[]).unwrap(); + let params = chain + .compute_expected_parameters(current_height, &[]) + .unwrap(); // Save current tip; we need to advance to the boundary height before applying. // (We've already built the chain — now we just simulate apply at each boundary.) // The chain's tip is at voting_end, so we need a per-height apply path. @@ -2352,9 +2499,9 @@ mod voting_chain_tests { let default = crate::voting::default_proposed_update_bytes(crate::Network::Testnet); assert_eq!(chain.active_proposed_update_bytes(), &default[..]); assert!( - chain.active_proposed_update_bytes().starts_with(&[ - 0x02, 0xD7, 0x01, 0x99, 0x03 - ]), + chain + .active_proposed_update_bytes() + .starts_with(&[0x02, 0xD7, 0x01, 0x99, 0x03]), "seed must encode rulesToDisable=[215,409]" ); } @@ -2387,7 +2534,11 @@ mod voting_chain_tests { chain.try_append_no_pow(g).unwrap(); for h in 2..=130 { let header = make_header_with_votes( - h, prev, 1_000_000 + (h as u64 - 1) * 45_000, n_bits, [0, 0, 0], + h, + prev, + 1_000_000 + (h as u64 - 1) * 45_000, + n_bits, + [0, 0, 0], ); prev = header.id; chain.try_append_no_pow(header).unwrap(); @@ -2453,7 +2604,11 @@ mod voting_chain_tests { chain.try_append_no_pow(g).unwrap(); for h in 2..=130 { let header = make_header_with_votes( - h, prev, 1_000_000 + (h as u64 - 1) * 45_000, n_bits, [0, 0, 0], + h, + prev, + 1_000_000 + (h as u64 - 1) * 45_000, + n_bits, + [0, 0, 0], ); prev = header.id; chain.try_append_no_pow(header).unwrap(); @@ -2539,7 +2694,9 @@ mod voting_chain_tests { // Force-remove SubblocksPerBlock from the active parameters to // simulate a chain that hasn't had it auto-inserted yet. let mut stripped = chain.active_parameters().clone(); - stripped.parameters_table.remove(&Parameter::SubblocksPerBlock); + stripped + .parameters_table + .remove(&Parameter::SubblocksPerBlock); assert!( !stripped .parameters_table @@ -2623,9 +2780,8 @@ mod voting_chain_tests { // target = voting_length - 1 (=127). Still no boundary at or before // that height. Defaults stand, loader not called. let mut chain = build_chain_with_votes(260, [0, 0, 0]); - chain.set_extension_loader(|_| { - panic!("loader must not be called below the first boundary") - }); + chain + .set_extension_loader(|_| panic!("loader must not be called below the first boundary")); chain .recompute_active_parameters_from_storage(127) @@ -2767,13 +2923,7 @@ mod voting_chain_tests { let base_height = boundary_height - voting_length; let mut prev_id = BlockId(Digest32::zero()); - let head = make_header_with_votes( - base_height, - prev_id, - 1_000_000, - n_bits, - [0, 0, 0], - ); + let head = make_header_with_votes(base_height, prev_id, 1_000_000, n_bits, [0, 0, 0]); prev_id = head.id; let mut tail: Vec
= Vec::with_capacity((voting_length - 1) as usize); @@ -2801,8 +2951,8 @@ mod voting_chain_tests { /// block 1,628,160 extension (key `[0x00, 0x7C]`). Decodes to /// `rulesToDisable = [215, 409]` + 3 status updates. const MAINNET_V6_PROPOSED_UPDATE: [u8; 18] = [ - 0x02, 0xD7, 0x01, 0x99, 0x03, 0x03, 0x0B, 0x01, 0x03, - 0x10, 0x07, 0x01, 0x03, 0x11, 0x08, 0x01, 0x03, 0x12, + 0x02, 0xD7, 0x01, 0x99, 0x03, 0x03, 0x0B, 0x01, 0x03, 0x10, 0x07, 0x01, 0x03, 0x11, 0x08, + 0x01, 0x03, 0x12, ]; #[test] @@ -2825,8 +2975,7 @@ mod voting_chain_tests { let mut chain = build_mainnet_chain_for_boundary(activation); let mut pre = crate::voting::default_parameters(crate::Network::Mainnet); - pre.parameters_table - .insert(Parameter::BlockVersion, 3); + pre.parameters_table.insert(Parameter::BlockVersion, 3); pre.parameters_table .insert(Parameter::SoftForkStartingHeight, starting_height as i32); pre.parameters_table @@ -2894,8 +3043,7 @@ mod voting_chain_tests { // BlockVersion = 4, soft-fork state still present (it clears at // THIS boundary), NO SubblocksPerBlock yet. let mut pre = crate::voting::default_parameters(crate::Network::Mainnet); - pre.parameters_table - .insert(Parameter::BlockVersion, 4); + pre.parameters_table.insert(Parameter::BlockVersion, 4); pre.parameters_table .insert(Parameter::SoftForkStartingHeight, starting_height as i32); pre.parameters_table @@ -2989,13 +3137,14 @@ mod light_client_install_tests { use ergo_chain_types::*; use sigma_ser::ScorexSerializable; - fn make_chain_header( - height: u32, - parent_id: BlockId, - timestamp: u64, - n_bits: u32, - ) -> Header { - make_chain_header_with_nonce(height, parent_id, timestamp, n_bits, height.to_be_bytes().repeat(2)) + fn make_chain_header(height: u32, parent_id: BlockId, timestamp: u64, n_bits: u32) -> Header { + make_chain_header_with_nonce( + height, + parent_id, + timestamp, + n_bits, + height.to_be_bytes().repeat(2), + ) } fn make_chain_header_with_nonce( @@ -3142,7 +3291,10 @@ mod light_client_install_tests { assert_eq!(installed.len(), 1); assert_eq!(installed[0].height, 500); assert_eq!(installed[0].id, head_id); - assert_eq!(installed[0].score_be, num_bigint::BigUint::ZERO.to_bytes_be()); + assert_eq!( + installed[0].score_be, + num_bigint::BigUint::ZERO.to_bytes_be() + ); } #[test] @@ -3150,7 +3302,12 @@ mod light_client_install_tests { // A chain that already contains headers cannot be re-installed. let config = testnet_config(); let mut chain = HeaderChain::new(config.clone()); - let genesis = make_chain_header(1, BlockId(Digest32::zero()), 1_000_000, config.initial_n_bits); + let genesis = make_chain_header( + 1, + BlockId(Digest32::zero()), + 1_000_000, + config.initial_n_bits, + ); chain.try_append_no_pow(genesis).unwrap(); let parent = BlockId(Digest32::from([0x77; 32])); @@ -3223,7 +3380,10 @@ mod light_client_install_tests { .install_from_nipopow_proof(suffix_head, suffix_tail) .unwrap_err(); assert!( - matches!(err, ChainError::PowInvalid { .. } | ChainError::PowCompute(_)), + matches!( + err, + ChainError::PowInvalid { .. } | ChainError::PowCompute(_) + ), "expected PoW failure, got {err:?}" ); @@ -3277,7 +3437,12 @@ mod light_client_install_tests { // 1) Normal-mode rejection: build a chain at genesis, try a child // with wrong n_bits, expect WrongDifficulty. let mut full_chain = HeaderChain::new(config.clone()); - let genesis = make_chain_header(1, BlockId(Digest32::zero()), 1_000_000, config.initial_n_bits); + let genesis = make_chain_header( + 1, + BlockId(Digest32::zero()), + 1_000_000, + config.initial_n_bits, + ); let genesis_id = genesis.id; full_chain.try_append_no_pow(genesis).unwrap(); let bad_child = make_chain_header(2, genesis_id, 2_000_000, config.initial_n_bits + 1); @@ -3329,7 +3494,12 @@ mod light_client_install_tests { // is the structural expression of "can't reorg past genesis". let config = testnet_config(); let mut chain = HeaderChain::new(config.clone()); - let genesis = make_chain_header(1, BlockId(Digest32::zero()), 1_000_000, config.initial_n_bits); + let genesis = make_chain_header( + 1, + BlockId(Digest32::zero()), + 1_000_000, + config.initial_n_bits, + ); chain.try_append_no_pow(genesis).unwrap(); assert_eq!(chain.reorg_floor(), 1); } @@ -3375,9 +3545,7 @@ mod light_client_install_tests { config.initial_n_bits, vec![0xDD; 8], ); - let err = chain - .try_reorg_deep_no_pow(2499, vec![bogus]) - .unwrap_err(); + let err = chain.try_reorg_deep_no_pow(2499, vec![bogus]).unwrap_err(); match &err { ChainError::Reorg(msg) => { assert!( @@ -3396,6 +3564,7 @@ mod light_client_install_tests { #[cfg(test)] mod lazy_cache_tests { + use std::mem::size_of; use std::num::NonZeroUsize; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -3404,6 +3573,7 @@ mod lazy_cache_tests { use num_bigint::BigUint; use sigma_ser::ScorexSerializable; + use crate::cache::{header_entry_bytes, score_entry_bytes}; use crate::{ChainConfig, HeaderChain}; fn testnet_config() -> ChainConfig { @@ -3417,12 +3587,7 @@ mod lazy_cache_tests { /// Synthetic header builder — no real PoW, id computed via /// serialization roundtrip so each (height, parent_id, timestamp, /// n_bits) tuple yields a distinct, deterministic id. - fn make_header( - height: u32, - parent_id: BlockId, - timestamp: u64, - n_bits: u32, - ) -> Header { + fn make_header(height: u32, parent_id: BlockId, timestamp: u64, n_bits: u32) -> Header { let zero32 = Digest32::zero(); let mut header = Header { version: 2, @@ -3484,7 +3649,10 @@ mod lazy_cache_tests { // Scores are cumulative — we don't compare exact values here; // just assert they exist at every height that was pushed. for h in 1..=5 { - assert!(chain.lazy().peek_score(h).is_some(), "score cache missing height {h}"); + assert!( + chain.lazy().peek_score(h).is_some(), + "score cache missing height {h}" + ); } assert_eq!(chain.lazy().header_cache_len(), 5); assert_eq!(chain.lazy().score_cache_len(), 5); @@ -3539,7 +3707,11 @@ mod lazy_cache_tests { let calls_hot = calls.clone(); chain.set_score_loader(move |h| { calls_hot.fetch_add(1, Ordering::SeqCst); - if h == 10 { Some(BigUint::from(7u32)) } else { None } + if h == 10 { + Some(BigUint::from(7u32)) + } else { + None + } }); assert!(chain.has_score_loader()); @@ -3592,9 +3764,7 @@ mod lazy_cache_tests { fork_parent.timestamp + 1000, fork_parent.n_bits, ); - let err = chain - .try_reorg_deep_no_pow(3, vec![bad_first]) - .unwrap_err(); + let err = chain.try_reorg_deep_no_pow(3, vec![bad_first]).unwrap_err(); // We don't care which error variant — just that it failed and // rolled back. drop(err); @@ -3622,12 +3792,7 @@ mod lazy_cache_tests { fork_parent.timestamp + 9_999, fork_parent.n_bits, ); - let new5 = make_header( - 5, - new4.id, - new4.timestamp + 45_000, - new4.n_bits, - ); + let new5 = make_header(5, new4.id, new4.timestamp + 45_000, new4.n_bits); let new4_id = new4.id; let new5_id = new5.id; @@ -3649,7 +3814,12 @@ mod lazy_cache_tests { let mut chain = HeaderChain::new(testnet_config()); // Install a valid head followed by a bogus tail header whose // parent_id doesn't link to the head → triggers rollback. - let head = make_header(1000, BlockId(Digest32::from([7u8; 32])), 50_000_000, 16842752); + let head = make_header( + 1000, + BlockId(Digest32::from([7u8; 32])), + 50_000_000, + 16842752, + ); let bogus_tail = make_header( 1001, BlockId(Digest32::from([0xff; 32])), // wrong parent @@ -3733,7 +3903,12 @@ mod lazy_cache_tests { #[test] fn successful_install_caches_suffix_head_with_zero_score() { let mut chain = HeaderChain::new(testnet_config()); - let head = make_header(1000, BlockId(Digest32::from([7u8; 32])), 50_000_000, 16842752); + let head = make_header( + 1000, + BlockId(Digest32::from([7u8; 32])), + 50_000_000, + 16842752, + ); let head_id = head.id; chain .install_from_nipopow_proof_no_pow(head, vec![]) @@ -3748,6 +3923,96 @@ mod lazy_cache_tests { "install-boundary score must be zero in cache" ); } + + // ---- memory_estimate ---- + + #[test] + fn memory_estimate_on_empty_chain_is_all_zeros() { + let chain = HeaderChain::new(testnet_config()); + let est = chain.memory_estimate(); + + // A `HashMap::new()` has not allocated, and both LRUs are empty. + assert_eq!(est.index_bytes, 0, "unallocated index holds nothing"); + assert_eq!(est.header_cache_bytes, 0); + assert_eq!(est.score_cache_bytes, 0); + } + + #[test] + fn index_bytes_grow_with_the_chain_and_beat_the_naive_bound() { + let mut chain = HeaderChain::new(testnet_config()); + let built = build_chain(&mut chain, 50); + let at_50 = chain.memory_estimate().index_bytes; + assert!(at_50 > 0, "50 headers must account for something"); + + // Keep appending on the SAME chain — the figure must track the + // index growing, not just be re-derived from a constant. + let mut prev = built.last().unwrap().clone(); + for h in 51..=200u32 { + let hdr = make_header(h, prev.id, 1_000_000 + h as u64 * 45_000, prev.n_bits); + prev = hdr.clone(); + chain.try_append_no_pow(hdr).unwrap(); + } + let at_200 = chain.memory_estimate().index_bytes; + + assert_eq!(chain.len(), 200); + assert!( + at_200 > at_50, + "index_bytes must grow with chain length ({at_50} -> {at_200})" + ); + + // The naive accounting — `entries x (32-byte BlockId + 4-byte + // height)` — ignores hashbrown's per-bucket control byte and the + // 12.5% of slots its load factor keeps empty. `by_id` is the one + // unbounded structure here, so under-reporting it is the failure + // that matters; the estimate must clear this lower bound. + let naive = 200 * size_of::<(BlockId, u32)>() as u64; + assert!( + at_200 > naive, + "index_bytes {at_200} must exceed the naive entries x {} bound of {naive}", + size_of::<(BlockId, u32)>() + ); + } + + #[test] + fn cache_bytes_track_occupancy_not_capacity() { + let capacity = 64usize; + let cap = NonZeroUsize::new(capacity).unwrap(); + + let mut chain = HeaderChain::new(testnet_config()); + chain.set_cache_capacity(cap); + let fresh = chain.memory_estimate(); + assert_eq!(fresh.header_cache_bytes, 0, "fresh cache holds nothing"); + assert_eq!(fresh.score_cache_bytes, 0, "fresh cache holds nothing"); + + // 10 of 64 slots occupied: the figure is occupancy-derived. + build_chain(&mut chain, 10); + let partial = chain.memory_estimate(); + assert_eq!(chain.lazy().header_cache_len(), 10); + assert_eq!(chain.lazy().score_cache_len(), 10); + assert_eq!(partial.header_cache_bytes, 10 * header_entry_bytes()); + assert_eq!(partial.score_cache_bytes, 10 * score_entry_bytes()); + + let header_ceiling = capacity as u64 * header_entry_bytes(); + let score_ceiling = capacity as u64 * score_entry_bytes(); + assert!( + partial.header_cache_bytes < header_ceiling, + "a 10/64-full cache must not report as full" + ); + assert!(partial.score_cache_bytes < score_ceiling); + + // Push well past capacity: the LRU evicts, so occupancy — and + // therefore the estimate — pins at the ceiling and never above. + let mut chain = HeaderChain::new(testnet_config()); + chain.set_cache_capacity(cap); + build_chain(&mut chain, 250); + let full = chain.memory_estimate(); + assert_eq!(full.header_cache_bytes, header_ceiling); + assert_eq!(full.score_cache_bytes, score_ceiling); + + // ...while the index kept every one of the 250 headers. + assert_eq!(chain.len(), 250); + assert!(full.index_bytes > 250 * size_of::<(BlockId, u32)>() as u64); + } } #[cfg(test)] @@ -3814,8 +4079,7 @@ mod restore_tests { #[test] fn restore_multi_entry_contiguous_populates_by_id() { - let entries: Vec<(u32, BlockId)> = - (10u32..=14).map(|h| (h, id_at(h))).collect(); + let entries: Vec<(u32, BlockId)> = (10u32..=14).map(|h| (h, id_at(h))).collect(); let chain = HeaderChain::restore(testnet_config(), entries) .expect("contiguous restore must succeed"); assert_eq!(chain.len(), 5); @@ -3832,10 +4096,7 @@ mod restore_tests { /// Unwrap the error variant — `HeaderChain` does not derive `Debug` /// (loaders are `Arc`), so the standard `unwrap_err` won't /// compile here. - fn restore_err( - config: ChainConfig, - entries: Vec<(u32, BlockId)>, - ) -> RestoreError { + fn restore_err(config: ChainConfig, entries: Vec<(u32, BlockId)>) -> RestoreError { match HeaderChain::restore(config, entries) { Ok(_) => panic!("expected RestoreError, got Ok(HeaderChain)"), Err(e) => e, @@ -3848,7 +4109,10 @@ mod restore_tests { let entries = vec![(1u32, id_at(1)), (2, id_at(2)), (4, id_at(4))]; assert_eq!( restore_err(testnet_config(), entries), - RestoreError::NonContiguousHeights { expected: 3, got: 4 } + RestoreError::NonContiguousHeights { + expected: 3, + got: 4 + } ); } @@ -3864,7 +4128,10 @@ mod restore_tests { let entries = vec![(1u32, id_at(1)), (2, id_at(2)), (2, other_id_at_2)]; assert_eq!( restore_err(testnet_config(), entries), - RestoreError::NonContiguousHeights { expected: 3, got: 2 } + RestoreError::NonContiguousHeights { + expected: 3, + got: 2 + } ); } @@ -3915,12 +4182,7 @@ mod linkage_tests { use ergo_chain_types::*; use sigma_ser::ScorexSerializable; - fn make_chain_header( - height: u32, - parent_id: BlockId, - timestamp: u64, - n_bits: u32, - ) -> Header { + fn make_chain_header(height: u32, parent_id: BlockId, timestamp: u64, n_bits: u32) -> Header { make_chain_header_with_nonce( height, parent_id, @@ -3971,7 +4233,12 @@ mod linkage_tests { } fn make_genesis(config: &ChainConfig) -> Header { - make_chain_header(1, BlockId(Digest32::zero()), 1_000_000, config.initial_n_bits) + make_chain_header( + 1, + BlockId(Digest32::zero()), + 1_000_000, + config.initial_n_bits, + ) } fn build_test_chain(count: u32) -> HeaderChain { @@ -3981,8 +4248,7 @@ mod linkage_tests { chain.try_append_no_pow(genesis).unwrap(); for h in 2..=count { let tip = chain.tip(); - let expected_n_bits = - crate::difficulty::expected_difficulty(&tip, &chain).unwrap(); + let expected_n_bits = crate::difficulty::expected_difficulty(&tip, &chain).unwrap(); let header = make_chain_header( h, tip.id, @@ -4011,8 +4277,7 @@ mod linkage_tests { if h > base { let parent = chain.header_at(h - 1).unwrap(); assert_eq!( - header.parent_id, - parent.id, + header.parent_id, parent.id, "broken parent link at height {h}" ); } @@ -4031,7 +4296,11 @@ mod linkage_tests { // Losing candidate at 6 arrives first — extends the best chain. let loser = make_chain_header_with_nonce( - 6, tip5.id, tip5.timestamp + 45_000, n_bits, vec![0xAA; 8], + 6, + tip5.id, + tip5.timestamp + 45_000, + n_bits, + vec![0xAA; 8], ); assert!(matches!( chain.try_append_no_pow(loser.clone()).unwrap(), @@ -4041,7 +4310,11 @@ mod linkage_tests { // Winner at 6 (same parent, different id) — reported as a fork, // NOT stored in the chain. let winner = make_chain_header_with_nonce( - 6, tip5.id, tip5.timestamp + 50_000, n_bits, vec![0xBB; 8], + 6, + tip5.id, + tip5.timestamp + 50_000, + n_bits, + vec![0xBB; 8], ); assert_ne!(winner.id, loser.id); assert!(matches!( @@ -4089,12 +4362,10 @@ mod linkage_tests { let genesis = make_genesis(&config); let h2 = make_chain_header(2, genesis.id, 1_050_000, config.initial_n_bits); let h3 = make_chain_header(3, h2.id, 1_100_000, config.initial_n_bits); - let loser = make_chain_header_with_nonce( - 4, h3.id, 1_150_000, config.initial_n_bits, vec![0x5f; 8], - ); - let winner = make_chain_header_with_nonce( - 4, h3.id, 1_151_000, config.initial_n_bits, vec![0x89; 8], - ); + let loser = + make_chain_header_with_nonce(4, h3.id, 1_150_000, config.initial_n_bits, vec![0x5f; 8]); + let winner = + make_chain_header_with_nonce(4, h3.id, 1_151_000, config.initial_n_bits, vec![0x89; 8]); let child = make_chain_header(5, winner.id, 1_200_000, config.initial_n_bits); assert_eq!(child.parent_id, winner.id); assert_ne!(winner.id, loser.id); @@ -4112,10 +4383,14 @@ mod linkage_tests { // Loader serving the same corrupted view (the store bridge). // Heights are unique in `view`: 1..=3 common, 4→loser, 5→child. - let view = [genesis.clone(), h2.clone(), h3.clone(), loser.clone(), child.clone()]; - chain.set_header_loader(move |h| { - view.iter().find(|hdr| hdr.height == h).cloned() - }); + let view = [ + genesis.clone(), + h2.clone(), + h3.clone(), + loser.clone(), + child.clone(), + ]; + chain.set_header_loader(move |h| view.iter().find(|hdr| hdr.height == h).cloned()); // The chain serves the broken link — this is the production // observable (`header_at(N).id != header_at(N+1).parent_id`). @@ -4161,9 +4436,8 @@ mod linkage_tests { let h2 = make_chain_header(2, genesis.id, 1_050_000, config.initial_n_bits); let h3 = make_chain_header(3, h2.id, 1_100_000, config.initial_n_bits); // Impostor at height 3: also a child of h2, but not h4's parent. - let impostor = make_chain_header_with_nonce( - 3, h2.id, 1_101_000, config.initial_n_bits, vec![0xEE; 8], - ); + let impostor = + make_chain_header_with_nonce(3, h2.id, 1_101_000, config.initial_n_bits, vec![0xEE; 8]); let h4 = make_chain_header(4, h3.id, 1_150_000, config.initial_n_bits); let h5 = make_chain_header(5, h4.id, 1_200_000, config.initial_n_bits); let h6 = make_chain_header(6, h5.id, 1_250_000, config.initial_n_bits); @@ -4181,13 +4455,15 @@ mod linkage_tests { ]; let mut chain = HeaderChain::restore(config, entries).unwrap(); let view = [genesis, h2, impostor, h4, h5, h6]; - chain.set_header_loader(move |h| { - view.iter().find(|hdr| hdr.height == h).cloned() - }); + chain.set_header_loader(move |h| view.iter().find(|hdr| hdr.height == h).cloned()); // Links checked tip-down: depth 1 → 6-5; depth 2 → +5-4; both clean. - chain.verify_best_chain_linkage(Some(1)).expect("6→5 link is clean"); - chain.verify_best_chain_linkage(Some(2)).expect("5→4 link is clean"); + chain + .verify_best_chain_linkage(Some(1)) + .expect("6→5 link is clean"); + chain + .verify_best_chain_linkage(Some(2)) + .expect("5→4 link is clean"); // Depth 3 reaches the 4→3 link. assert!(matches!( chain.verify_best_chain_linkage(Some(3)), @@ -4215,7 +4491,11 @@ mod linkage_tests { // An impostor at height 3 the chain never accepted. let h2 = &real[1]; let impostor = make_chain_header_with_nonce( - 3, h2.id, h2.timestamp + 46_000, accepted_h3.n_bits, vec![0xEE; 8], + 3, + h2.id, + h2.timestamp + 46_000, + accepted_h3.n_bits, + vec![0xEE; 8], ); assert_ne!(impostor.id, accepted_h3.id); // A store bridge that serves every height, but the wrong header @@ -4255,18 +4535,25 @@ mod linkage_tests { #[test] fn verify_linkage_trivial_and_clean_cases() { let empty = HeaderChain::new(testnet_config()); - empty.verify_best_chain_linkage(None).expect("empty chain is clean"); + empty + .verify_best_chain_linkage(None) + .expect("empty chain is clean"); let single = build_test_chain(1); - single.verify_best_chain_linkage(None).expect("single header is clean"); + single + .verify_best_chain_linkage(None) + .expect("single header is clean"); let chain = build_test_chain(8); - chain.verify_best_chain_linkage(None).expect("full walk clean"); - chain.verify_best_chain_linkage(Some(3)).expect("bounded walk clean"); + chain + .verify_best_chain_linkage(None) + .expect("full walk clean"); + chain + .verify_best_chain_linkage(Some(3)) + .expect("bounded walk clean"); chain .verify_best_chain_linkage(Some(100)) .expect("depth beyond chain length clamps to the chain"); assert_linkage(&chain); } } - diff --git a/chain/src/voting.rs b/chain/src/voting.rs index 4dc2b42..ee395bb 100644 --- a/chain/src/voting.rs +++ b/chain/src/voting.rs @@ -104,8 +104,7 @@ impl VotingConfig { /// widens losslessly. Threshold math stays in u64 — JVM Int-overflow /// on hostile SETTINGS is out of scope (vectors hand sane settings). pub fn soft_fork_approved(&self, votes: i64) -> bool { - let threshold = - (self.voting_length as u64) * (self.soft_fork_epochs as u64) * 9 / 10; + let threshold = (self.voting_length as u64) * (self.soft_fork_epochs as u64) * 9 / 10; votes > threshold as i64 } @@ -151,9 +150,9 @@ pub fn parameter_min(id: i8) -> Option { /// Upper bound for an ordinary parameter (mirrors JVM `maxValues`). pub fn parameter_max(id: i8) -> Option { match id.unsigned_abs() as i8 { - 1 => Some(2_500_000), // StorageFeeFactor - 2 => Some(10_000), // MinValuePerByte - 9 => Some(2_048), // SubblocksPerBlock + 1 => Some(2_500_000), // StorageFeeFactor + 2 => Some(10_000), // MinValuePerByte + 9 => Some(2_048), // SubblocksPerBlock 3..=8 => Some(i32::MAX / 2), _ => None, } @@ -448,9 +447,7 @@ pub fn parse_validation_settings_update( })? .wrapping_add(FIRST_RULE_ID as u16) as i16; let status = RuleStatus::sigma_parse(&mut r).map_err(|e| { - ChainError::ExtensionParse(format!( - "settings update: status update at index {i}: {e}" - )) + ChainError::ExtensionParse(format!("settings update: status update at index {i}: {e}")) })?; statuses.push((rule_id, status)); } @@ -502,14 +499,13 @@ pub fn encode_validation_settings_update( for (i, (rule_id, status)) in update.statuses.iter().enumerate() { // JVM `w.putUShort(ruleId - FirstRuleId)` (Int arithmetic) — the // scorex `putUShort` require rejects a negative offset. - let offset = - u16::try_from(*rule_id as i32 - FIRST_RULE_ID as i32).map_err(|_| { - ChainError::Voting(format!( - "settings update encode: status update rule id {rule_id} at index {i} \ + let offset = u16::try_from(*rule_id as i32 - FIRST_RULE_ID as i32).map_err(|_| { + ChainError::Voting(format!( + "settings update encode: status update rule id {rule_id} at index {i} \ is below FIRST_RULE_ID {FIRST_RULE_ID} (negative wire offset, JVM \ putUShort require)" - )) - })?; + )) + })?; w.put_u16(offset).expect("Vec write"); status.sigma_serialize(&mut w).map_err(|e| { ChainError::Voting(format!( @@ -686,9 +682,8 @@ pub fn pack_extension_bytes( ) -> Vec { use sigma_ser::vlq_encode::WriteSigmaVlqExt; - let mut out = Vec::with_capacity( - 32 + 5 + fields.iter().map(|(_, v)| v.len() + 3).sum::(), - ); + let mut out = + Vec::with_capacity(32 + 5 + fields.iter().map(|(_, v)| v.len() + 3).sum::()); out.extend_from_slice(&header_id.0 .0); // VLQ-encoded field count out.put_u32(fields.len() as u32).expect("Vec write"); @@ -931,8 +926,7 @@ pub fn compute_boundary_parameters( .copied() .ok_or_else(|| { ChainError::Voting( - "BlockVersion missing from parameters table at soft-fork activation" - .into(), + "BlockVersion missing from parameters table at soft-fork activation".into(), ) })?; table.insert(Parameter::BlockVersion, bv.wrapping_add(1)); @@ -948,11 +942,14 @@ pub fn compute_boundary_parameters( // Reads the RUNNING table like JVM (a voting-driven bump at the same // height suppresses the force). if boundary_height == voting.version2_activation_height { - let bv = table.get(&Parameter::BlockVersion).copied().ok_or_else(|| { - ChainError::Voting( - "BlockVersion missing from parameters table at forced-v2 height".into(), - ) - })?; + let bv = table + .get(&Parameter::BlockVersion) + .copied() + .ok_or_else(|| { + ChainError::Voting( + "BlockVersion missing from parameters table at forced-v2 height".into(), + ) + })?; if bv == 1 { table.insert(Parameter::BlockVersion, 2); } @@ -1454,28 +1451,55 @@ mod tests { fn boundary_params_majority_step_applies() { let cfg = VotingConfig::testnet(); let (next, activated) = compute_boundary_parameters( - &cfg, 256, &base_params(), &tally_of(&[(1, 65)]), false, &[], + &cfg, + 256, + &base_params(), + &tally_of(&[(1, 65)]), + false, + &[], ) .unwrap(); - assert_eq!(next.storage_fee_factor(), 1_275_000, "step +25_000 for id 1"); - assert_eq!(activated, empty_update(), "non-activation boundary → 0x0000"); + assert_eq!( + next.storage_fee_factor(), + 1_275_000, + "step +25_000 for id 1" + ); + assert_eq!( + activated, + empty_update(), + "non-activation boundary → 0x0000" + ); } #[test] fn boundary_params_exact_half_no_step() { let cfg = VotingConfig::testnet(); let (next, _) = compute_boundary_parameters( - &cfg, 256, &base_params(), &tally_of(&[(1, 64)]), false, &[], + &cfg, + 256, + &base_params(), + &tally_of(&[(1, 64)]), + false, + &[], ) .unwrap(); - assert_eq!(next.storage_fee_factor(), 1_250_000, "64 of 128 is not > L/2"); + assert_eq!( + next.storage_fee_factor(), + 1_250_000, + "64 of 128 is not > L/2" + ); } #[test] fn boundary_params_negative_vote_decreases() { let cfg = VotingConfig::testnet(); let (next, _) = compute_boundary_parameters( - &cfg, 256, &base_params(), &tally_of(&[(-1, 65)]), false, &[], + &cfg, + 256, + &base_params(), + &tally_of(&[(-1, 65)]), + false, + &[], ) .unwrap(); assert_eq!(next.storage_fee_factor(), 1_225_000); @@ -1488,10 +1512,9 @@ mod tests { params .parameters_table .insert(Parameter::MaxBlockSize, 16 * 1024); // at min - let (next, _) = compute_boundary_parameters( - &cfg, 256, ¶ms, &tally_of(&[(-3, 65)]), false, &[], - ) - .unwrap(); + let (next, _) = + compute_boundary_parameters(&cfg, 256, ¶ms, &tally_of(&[(-3, 65)]), false, &[]) + .unwrap(); assert_eq!( next.max_block_size(), 16 * 1024, @@ -1506,7 +1529,12 @@ mod tests { fn boundary_params_max_block_cost_dynamic_step() { let cfg = VotingConfig::testnet(); let (next, _) = compute_boundary_parameters( - &cfg, 256, &base_params(), &tally_of(&[(4, 65)]), false, &[], + &cfg, + 256, + &base_params(), + &tally_of(&[(4, 65)]), + false, + &[], ) .unwrap(); assert_eq!( @@ -1522,9 +1550,17 @@ mod tests { // approved id — an unknown id throws there (invalid block). Mirror. let cfg = VotingConfig::testnet(); let r = compute_boundary_parameters( - &cfg, 256, &base_params(), &tally_of(&[(99, 65)]), false, &[], + &cfg, + 256, + &base_params(), + &tally_of(&[(99, 65)]), + false, + &[], + ); + assert!( + r.is_err(), + "approved unknown id must error like the JVM throw" ); - assert!(r.is_err(), "approved unknown id must error like the JVM throw"); } #[test] @@ -1533,7 +1569,12 @@ mod tests { // read — unknown ids without a majority are silently ignored. let cfg = VotingConfig::testnet(); let (next, _) = compute_boundary_parameters( - &cfg, 256, &base_params(), &tally_of(&[(99, 10)]), false, &[], + &cfg, + 256, + &base_params(), + &tally_of(&[(99, 10)]), + false, + &[], ) .unwrap(); assert_eq!(next, base_params()); @@ -1549,7 +1590,12 @@ mod tests { // closing epoch's 120 count alone must NOT create counters. let cfg = VotingConfig::testnet(); let (next, activated) = compute_boundary_parameters( - &cfg, 256, &base_params(), &tally_of(&[(SOFT_FORK_VOTE, 128)]), false, &[], + &cfg, + 256, + &base_params(), + &tally_of(&[(SOFT_FORK_VOTE, 128)]), + false, + &[], ) .unwrap(); assert_eq!(next, base_params(), "no 121/122 counters may appear"); @@ -1559,16 +1605,18 @@ mod tests { #[test] fn boundary_params_boundary_fork_vote_starts_round() { let cfg = VotingConfig::testnet(); - let (next, _) = compute_boundary_parameters( - &cfg, 256, &base_params(), &[], true, &[], - ) - .unwrap(); + let (next, _) = + compute_boundary_parameters(&cfg, 256, &base_params(), &[], true, &[]).unwrap(); assert_eq!( next.soft_fork_starting_height(), Some(256), "id 122 = boundary height on round start" ); - assert_eq!(next.soft_fork_votes_collected(), Some(0), "id 121 starts at 0"); + assert_eq!( + next.soft_fork_votes_collected(), + Some(0), + "id 121 starts at 0" + ); } #[test] @@ -1582,7 +1630,12 @@ mod tests { .parameters_table .insert(Parameter::SoftForkVotesCollected, 10); let (next, _) = compute_boundary_parameters( - &cfg, 256, ¶ms, &tally_of(&[(SOFT_FORK_VOTE, 50)]), false, &[], + &cfg, + 256, + ¶ms, + &tally_of(&[(SOFT_FORK_VOTE, 50)]), + false, + &[], ) .unwrap(); assert_eq!( @@ -1606,11 +1659,13 @@ mod tests { .parameters_table .insert(Parameter::SoftForkVotesCollected, 3_700); let proposed = encode_disabled_rules(&[215]); - let (next, activated) = compute_boundary_parameters( - &cfg, 8_320, ¶ms, &[], false, &proposed, - ) - .unwrap(); - assert_eq!(next.block_version(), 2, "voting-driven activation bumps BlockVersion"); + let (next, activated) = + compute_boundary_parameters(&cfg, 8_320, ¶ms, &[], false, &proposed).unwrap(); + assert_eq!( + next.block_version(), + 2, + "voting-driven activation bumps BlockVersion" + ); assert_eq!( activated, rules_update(&[215]), @@ -1630,10 +1685,8 @@ mod tests { params .parameters_table .insert(Parameter::SoftForkVotesCollected, 100); // below 3686 - let (next, activated) = compute_boundary_parameters( - &cfg, 8_320, ¶ms, &[], false, &[], - ) - .unwrap(); + let (next, activated) = + compute_boundary_parameters(&cfg, 8_320, ¶ms, &[], false, &[]).unwrap(); assert_eq!(next.block_version(), 1); assert_eq!(activated, empty_update()); } @@ -1650,10 +1703,7 @@ mod tests { params .parameters_table .insert(Parameter::SoftForkVotesCollected, 100); - let (next, _) = compute_boundary_parameters( - &cfg, 4_352, ¶ms, &[], false, &[], - ) - .unwrap(); + let (next, _) = compute_boundary_parameters(&cfg, 4_352, ¶ms, &[], false, &[]).unwrap(); assert_eq!(next.soft_fork_starting_height(), None); assert_eq!(next.soft_fork_votes_collected(), None); } @@ -1672,17 +1722,13 @@ mod tests { .parameters_table .insert(Parameter::SoftForkVotesCollected, 3_700); // Without forkVote: counters just clear. - let (cleared, _) = compute_boundary_parameters( - &cfg, 8_448, ¶ms, &[], false, &[], - ) - .unwrap(); + let (cleared, _) = + compute_boundary_parameters(&cfg, 8_448, ¶ms, &[], false, &[]).unwrap(); assert_eq!(cleared.soft_fork_starting_height(), None); assert_eq!(cleared.soft_fork_votes_collected(), None); // With forkVote: cleanup then immediate restart. - let (restarted, _) = compute_boundary_parameters( - &cfg, 8_448, ¶ms, &[], true, &[], - ) - .unwrap(); + let (restarted, _) = + compute_boundary_parameters(&cfg, 8_448, ¶ms, &[], true, &[]).unwrap(); assert_eq!(restarted.soft_fork_starting_height(), Some(8_448)); assert_eq!(restarted.soft_fork_votes_collected(), Some(0)); } @@ -1693,12 +1739,14 @@ mod tests { // activated update stays empty — JVM does not wire activatedUpdate // for the forced path. let cfg = VotingConfig::mainnet(); - let (next, activated) = compute_boundary_parameters( - &cfg, 417_792, &base_params(), &[], false, &[], - ) - .unwrap(); + let (next, activated) = + compute_boundary_parameters(&cfg, 417_792, &base_params(), &[], false, &[]).unwrap(); assert_eq!(next.block_version(), 2); - assert_eq!(activated, empty_update(), "forced v2 does not activate the update"); + assert_eq!( + activated, + empty_update(), + "forced v2 does not activate the update" + ); } #[test] @@ -1717,10 +1765,8 @@ mod tests { .insert(Parameter::SoftForkVotesCollected, 3_700); let with_409 = encode_disabled_rules(&[215, 409]); - let (next, activated) = compute_boundary_parameters( - &cfg, 8_320, ¶ms, &[], false, &with_409, - ) - .unwrap(); + let (next, activated) = + compute_boundary_parameters(&cfg, 8_320, ¶ms, &[], false, &with_409).unwrap(); assert_eq!(next.block_version(), 4); assert_eq!(activated, rules_update(&[215, 409])); assert!( @@ -1731,10 +1777,8 @@ mod tests { ); let without_409 = encode_disabled_rules(&[215]); - let (next, _) = compute_boundary_parameters( - &cfg, 8_320, ¶ms, &[], false, &without_409, - ) - .unwrap(); + let (next, _) = + compute_boundary_parameters(&cfg, 8_320, ¶ms, &[], false, &without_409).unwrap(); assert_eq!(next.block_version(), 4); assert_eq!( next.parameters_table @@ -1752,10 +1796,7 @@ mod tests { let cfg = VotingConfig::testnet(); let mut params = base_params(); params.parameters_table.insert(Parameter::BlockVersion, 4); - let (next, _) = compute_boundary_parameters( - &cfg, 256, ¶ms, &[], false, &[], - ) - .unwrap(); + let (next, _) = compute_boundary_parameters(&cfg, 256, ¶ms, &[], false, &[]).unwrap(); assert_eq!( next.parameters_table .get(&Parameter::SubblocksPerBlock) @@ -1780,7 +1821,10 @@ mod tests { let params = params_with_round(128, None); for h in [256u32, 4_352, 8_320, 8_448] { let r = compute_boundary_parameters(&cfg, h, ¶ms, &[], false, &[]); - assert!(r.is_err(), "h={h} must force `votes` and error on absent 121"); + assert!( + r.is_err(), + "h={h} must force `votes` and error on absent 121" + ); } } @@ -1856,7 +1900,12 @@ mod tests { // post-fork SNAPSHOT, so the LAST entry wins deterministically. let cfg = VotingConfig::testnet(); let (plus_then_minus, _) = compute_boundary_parameters( - &cfg, 256, &base_params(), &tally_of(&[(1, 65), (-1, 65)]), false, &[], + &cfg, + 256, + &base_params(), + &tally_of(&[(1, 65), (-1, 65)]), + false, + &[], ) .unwrap(); assert_eq!( @@ -1866,7 +1915,12 @@ mod tests { ); let (minus_then_plus, _) = compute_boundary_parameters( - &cfg, 256, &base_params(), &tally_of(&[(-1, 65), (1, 65)]), false, &[], + &cfg, + 256, + &base_params(), + &tally_of(&[(-1, 65), (1, 65)]), + false, + &[], ) .unwrap(); assert_eq!( @@ -1882,7 +1936,12 @@ mod tests { // snapshot, so the step applies once — not compounded. let cfg = VotingConfig::testnet(); let (next, _) = compute_boundary_parameters( - &cfg, 256, &base_params(), &tally_of(&[(1, 65), (1, 65)]), false, &[], + &cfg, + 256, + &base_params(), + &tally_of(&[(1, 65), (1, 65)]), + false, + &[], ) .unwrap(); assert_eq!(next.storage_fee_factor(), 1_275_000, "one step, not two"); @@ -1940,8 +1999,8 @@ mod tests { /// the sigma `RuleStatusSerializer` port (pin `75be067f`) — the /// payload is VALID, not lenient-tolerated. const MAINNET_V6_PAYLOAD: [u8; 18] = [ - 0x02, 0xD7, 0x01, 0x99, 0x03, 0x03, 0x0B, 0x01, 0x03, - 0x10, 0x07, 0x01, 0x03, 0x11, 0x08, 0x01, 0x03, 0x12, + 0x02, 0xD7, 0x01, 0x99, 0x03, 0x03, 0x0B, 0x01, 0x03, 0x10, 0x07, 0x01, 0x03, 0x11, 0x08, + 0x01, 0x03, 0x12, ]; #[test] @@ -2144,8 +2203,7 @@ mod tests { // wrappers pre-swallow instead; see the chain-level test. let cfg = VotingConfig::testnet(); let hostile = encode_disabled_rules(&[102]); - let r = - compute_boundary_parameters(&cfg, 256, &base_params(), &[], false, &hostile); + let r = compute_boundary_parameters(&cfg, 256, &base_params(), &[], false, &hostile); assert!(r.is_err()); } @@ -2240,8 +2298,14 @@ mod tests { #[test] fn check_header_votes_rule_212_count() { // votesCount = count(_ != 120) <= ParamVotesCount (= 2). - assert!(check_header_votes([1, 2, 120]).is_ok(), "2 ordinary + softfork"); - assert!(check_header_votes([1, 120, 0]).is_ok(), "1 ordinary + softfork"); + assert!( + check_header_votes([1, 2, 120]).is_ok(), + "2 ordinary + softfork" + ); + assert!( + check_header_votes([1, 120, 0]).is_ok(), + "1 ordinary + softfork" + ); // Three ordinary (non-120) votes is the only way to exceed 2. votes_err_names([1, 2, 3], "212"); } @@ -2250,7 +2314,10 @@ mod tests { fn check_header_votes_rule_213_duplicates() { votes_err_names([1, 1, 0], "213"); assert!(check_header_votes([1, 2, 0]).is_ok(), "distinct ids pass"); - assert!(check_header_votes([0, 0, 0]).is_ok(), "all zeros pass (no votes)"); + assert!( + check_header_votes([0, 0, 0]).is_ok(), + "all zeros pass (no votes)" + ); } #[test] @@ -2266,8 +2333,14 @@ mod tests { #[test] fn check_header_votes_real_header_shapes_pass() { // Shapes that appear in real headers — must never break sync. - assert!(check_header_votes([4, 3, 0]).is_ok(), "canonical two-vote header"); - assert!(check_header_votes([0, 0, 0]).is_ok(), "the overwhelmingly common no-vote header"); + assert!( + check_header_votes([4, 3, 0]).is_ok(), + "canonical two-vote header" + ); + assert!( + check_header_votes([0, 0, 0]).is_ok(), + "the overwhelmingly common no-vote header" + ); } // ---- zombie family regression ---- @@ -2294,8 +2367,7 @@ mod tests { // Parameters.scala:127-128). The one legal zombie revival. let cfg = VotingConfig::testnet(); let params = params_with_round(128, Some(100)); // never approved - let (next, _) = - compute_boundary_parameters(&cfg, 8_448, ¶ms, &[], true, &[]).unwrap(); + let (next, _) = compute_boundary_parameters(&cfg, 8_448, ¶ms, &[], true, &[]).unwrap(); assert_eq!( next.soft_fork_starting_height(), Some(8_448), @@ -2309,7 +2381,7 @@ mod tests { let mut input: HashMap = HashMap::new(); input.insert(1, 1_250_000); // StorageFeeFactor input.insert(4, 1_000_000); // MaxBlockCost - input.insert(123, 2); // BlockVersion + input.insert(123, 2); // BlockVersion let kv = pack_parameters_to_kv(&input); // Each entry is 2-byte key + 4-byte value @@ -2392,7 +2464,7 @@ mod tests { let fields = vec![ ([0x00u8, 0x01], 1_250_000i32.to_be_bytes().to_vec()), ([0x00u8, 0x7B], 2i32.to_be_bytes().to_vec()), // 0x7B = 123 = BlockVersion - ([0x01u8, 0x00], vec![0xCD; 33]), // interlink-style field + ([0x01u8, 0x00], vec![0xCD; 33]), // interlink-style field ]; let packed = pack_extension_bytes(&header_id, &fields); @@ -2416,11 +2488,11 @@ mod tests { let mut bytes = Vec::new(); bytes.extend_from_slice(&[0xAAu8; 32]); bytes.push(0x02); // VLQ field_count = 2 - // Field 1 + // Field 1 bytes.extend_from_slice(&[0x00, 0x01]); // key bytes.push(4); // val_len bytes.extend_from_slice(&1_250_000i32.to_be_bytes()); // value - // Field 2 + // Field 2 bytes.extend_from_slice(&[0x01, 0x00]); // key bytes.push(3); // val_len bytes.extend_from_slice(&[0xFF, 0xEE, 0xDD]); // value diff --git a/debian/conffiles b/debian/conffiles index 27dc9c6..126e768 100644 --- a/debian/conffiles +++ b/debian/conffiles @@ -1,3 +1,2 @@ -/etc/ergo-node/ergo.toml /etc/fail2ban/filter.d/ergo-node.conf /etc/fail2ban/jail.d/ergo-node-jail.conf diff --git a/debian/config b/debian/config new file mode 100644 index 0000000..3a64841 --- /dev/null +++ b/debian/config @@ -0,0 +1,194 @@ +#!/bin/sh +# debconf first-install interview. Runs BEFORE unpack, so it may only rely on +# things already on the system — no node binary, no shipped defaults. +# +# Two unconditional screens (network, then a topic checklist), then only the +# questions belonging to checked topics. Everything has a default, so +# DEBIAN_FRONTEND=noninteractive completes without prompting. +# +# This script asks. It does not write config — postinst does, from the answers. +set -e + +. /usr/share/debconf/confmodule + +# ── Helpers ───────────────────────────────────────────────────────────── + +# Is this topic in the selection? +# +# ⚠ debconf returns a multiselect as "A, B, C" — comma AND space. Matching on +# the comma alone silently honours only the FIRST selected topic and skips +# every other question, which looks like the operator simply was not asked. +# Strip spaces from both sides before comparing. +topic_selected() { + _have=$(printf '%s' "$TOPICS" | tr -d ' ') + _want=$(printf '%s' "$1" | tr -d ' ') + case ",$_have," in + *",$_want,"*) return 0 ;; + *) return 1 ;; + esac +} + +# Total RAM in MB, or empty if it cannot be read. Advisory only. +mem_total_mb() { + [ -r /proc/meminfo ] || return 0 + awk '/^MemTotal:/ { print int($2 / 1024); exit }' /proc/meminfo 2>/dev/null || true +} + +# Is a TCP port already listening? Advisory — used to prefill a default, never +# to block. `ss` may be absent, in which case we simply do not probe. +port_in_use() { + command -v ss >/dev/null 2>&1 || return 1 + ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$1\$" +} + +# First free port at or above $1, giving up after 20 tries so a pathological +# machine cannot hang the install. +first_free_port() { + _p="$1" + _n=0 + while [ "$_n" -lt 20 ]; do + port_in_use "$_p" || { echo "$_p"; return; } + _p=$((_p + 1)) + _n=$((_n + 1)) + done + echo "$1" +} + +# ── 1. Network ────────────────────────────────────────────────────────── +# +# First because every other topic's defaults depend on it, and because it is +# the one setting with no safe guess: both are legitimate and picking wrong is +# the bug this interview exists to fix. + +db_input high ergo-node-rust/network || true +db_go || true +db_get ergo-node-rust/network +NETWORK="$RET" + +# ── 2. Topic checklist ────────────────────────────────────────────────── + +db_input high ergo-node-rust/topics || true +db_go || true +db_get ergo-node-rust/topics +TOPICS="$RET" + +# ── Memory notices ────────────────────────────────────────────────────── +# +# Shown before the topic questions so an operator on a small box can pick +# "Node type" in the same pass rather than finding out from a failed start. +# +# ⚠ TWO notices, not one, and the distinction is the point. A single warning +# below 4096 MB told a 3915 MB machine it "has less memory than a full node +# needs" and listed "pick light or digest" as the first remedy — so the +# operator picked light, on a box where a full node runs perfectly well. The +# notice talked someone out of the mode they wanted. +# +# 3072 MB is where the node actually refuses (MEMORY_FLOOR_BYTES); 4096 is only +# a recommendation (MEMORY_RECOMMENDED_BYTES). Below the floor, steer. Between +# the two, inform and explicitly say no action is needed. + +MEM_MB=$(mem_total_mb) +if [ -n "$MEM_MB" ]; then + if [ "$MEM_MB" -lt 3072 ]; then + db_subst ergo-node-rust/mem_refuse detected "$MEM_MB" || true + db_input high ergo-node-rust/mem_refuse || true + db_go || true + elif [ "$MEM_MB" -lt 4096 ]; then + db_subst ergo-node-rust/mem_below_recommended detected "$MEM_MB" || true + db_input high ergo-node-rust/mem_below_recommended || true + db_go || true + fi +fi + +# ── 3. Interfaces ─────────────────────────────────────────────────────── + +if topic_selected "Interfaces"; then + db_get ergo-node-rust/listen_port + # Only probe when the answer is still the shipped default; an operator who + # already chose a port should not have it moved out from under them. + if [ "$RET" = "9030" ]; then + db_set ergo-node-rust/listen_port "$(first_free_port 9030)" || true + fi + db_input high ergo-node-rust/listen_port || true + + db_input high ergo-node-rust/api_address || true + # `high`, not `medium`. debconf's default priority IS high, so a medium + # question is silently skipped — the operator checks the Interfaces topic, + # is never asked, and gets the default anyway. A question inside a topic + # the operator explicitly selected must not be filtered by priority. + db_input high ergo-node-rust/max_inbound || true + db_go || true +fi + +# ── 4. Node type ──────────────────────────────────────────────────────── +# +# Mining requires utxo. Rather than letting the operator pick digest and then +# reporting a conflict, digest is simply not offered when Mining is checked — +# there is no dependency-resolution ceremony for a combination that means +# something more fundamental was misunderstood. + +if topic_selected "Node type"; then + # The description has to move with the choice list. Describing digest on a + # screen that does not offer it reads as a bug in the menu rather than a + # deliberate exclusion — and leaves the operator hunting for an option that + # is not there. + if topic_selected "Mining"; then + db_subst ergo-node-rust/state_type choices "utxo, light" || true + db_subst ergo-node-rust/state_type digest \ + "digest is not offered here because you selected the Mining topic, and only utxo can mine. Deselect Mining to choose it." || true + else + db_subst ergo-node-rust/state_type choices "utxo, light, digest" || true + db_subst ergo-node-rust/state_type digest \ + "digest validates blocks against proofs rather than holding the state, and needs far less memory than utxo. It cannot mine." || true + fi + db_input high ergo-node-rust/state_type || true + db_go || true + + db_get ergo-node-rust/state_type + if [ "$RET" = "utxo" ]; then + db_input high ergo-node-rust/blocks_to_keep || true + db_go || true + fi +fi + +# ── 5. Storage ────────────────────────────────────────────────────────── + +if topic_selected "Storage"; then + db_input high ergo-node-rust/data_dir || true + db_go || true +fi + +# ── 6. Bootstrap ──────────────────────────────────────────────────────── + +if topic_selected "Bootstrap"; then + db_input high ergo-node-rust/fastsync || true + db_go || true +fi + +# ── 7. Mining ─────────────────────────────────────────────────────────── + +if topic_selected "Mining"; then + db_input high ergo-node-rust/miner_pk || true + db_go || true +fi + +# ── 8. Memory ─────────────────────────────────────────────────────────── +# +# One question. There are no memory profiles: the node derives every cache and +# threshold from its ceiling at runtime, against a floor that grows with the +# chain, so a number frozen at install time is correct on the day and wrong +# thereafter. + +if topic_selected "Memory"; then + db_input high ergo-node-rust/memory_budget_mb || true + db_go || true +fi + +# ── 9. Peers ──────────────────────────────────────────────────────────── + +if topic_selected "Peers"; then + db_input high ergo-node-rust/seed_peers_add || true + db_go || true +fi + +exit 0 diff --git a/debian/control b/debian/control index 17e84b4..6078cc4 100644 --- a/debian/control +++ b/debian/control @@ -1,9 +1,9 @@ Package: ergo-node-rust -Version: 0.1.0 +Version: 0.8.0 Section: net Priority: optional Architecture: amd64 -Depends: libc6 +Depends: libc6, debconf (>= 0.5) | debconf-2.0 Suggests: fail2ban, rrdtool Conflicts: ergo-proxy-node Replaces: ergo-proxy-node diff --git a/debian/postinst b/debian/postinst index b828a81..0917d76 100644 --- a/debian/postinst +++ b/debian/postinst @@ -1,8 +1,159 @@ #!/bin/sh set -e +. /usr/share/debconf/confmodule + +CONFD=/etc/ergo-node/conf.d +DEFAULTS=/usr/share/ergo-node-rust/defaults + +# Escape a value for a TOML basic string. Operator input reaches these fields, +# and an unescaped quote would produce a config the node cannot parse — which +# on a fresh install looks like the package being broken. +toml_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +# ⚠ debconf returns a multiselect as "A, B, C" — comma AND space. Matching on +# the comma alone silently honours only the FIRST selected topic and drops +# every other one, with no error and a plausible-looking config file. Strip +# spaces from both sides before comparing. +topic_selected() { + _have=$(printf '%s' "$TOPICS" | tr -d ' ') + _want=$(printf '%s' "$1" | tr -d ' ') + case ",$_have," in + *",$_want,"*) return 0 ;; + *) return 1 ;; + esac +} + +write_generated_config() { + db_get ergo-node-rust/network || true + NETWORK="${RET:-mainnet}" + db_get ergo-node-rust/topics || true + TOPICS="$RET" + + mkdir -p "$CONFD" + + # ── 00-defaults.toml ──────────────────────────────────────────────── + # + # Copied fresh on every configure, deliberately: that is what lets a + # corrected seed-peer list reach existing installs. Not a conffile, so + # dpkg has no opinion and there is no confold/confnew prompt. + if [ -f "$DEFAULTS/$NETWORK.toml" ]; then + cp "$DEFAULTS/$NETWORK.toml" "$CONFD/00-defaults.toml" + else + echo "ergo-node-rust: WARNING — $DEFAULTS/$NETWORK.toml is missing;" >&2 + echo " no package defaults were installed. The node will fall back to" >&2 + echo " its built-in defaults, which may not match your network." >&2 + fi + + # ── 50-debconf.toml ───────────────────────────────────────────────── + # + # ⚠ Only keys with a real answer are written. An unchecked topic must + # leave NOTHING behind, because for most of these settings the node's + # absent-means-derive behaviour is better than any number we could write: + # writing memory_budget_mb "helpfully" would switch derivation off. + { + echo "# Ergo Node Rust — generated from debconf answers." + echo "#" + echo "# ⚠ DO NOT EDIT. Rewritten on every package upgrade and on every" + echo "# 'dpkg-reconfigure ergo-node-rust'. Your edits will be lost." + echo "#" + echo "# To change these, either re-run:" + echo "# sudo dpkg-reconfigure ergo-node-rust" + echo "# or put your value in 99-local.toml, which is yours and which" + echo "# wins over this file (conf.d is merged in lexical order)." + echo "" + echo "[node]" + + # data_dir is always written: the package's location differs from the + # binary's in-pwd default, so leaving it out would put chain data in + # whatever directory systemd happened to start the node from. + db_get ergo-node-rust/data_dir || true + DATA_DIR="${RET:-/var/lib/ergo-node/data}" + echo "data_dir = \"$(toml_escape "$DATA_DIR")\"" + + if topic_selected "Node type"; then + db_get ergo-node-rust/state_type || true + [ -n "$RET" ] && echo "state_type = \"$(toml_escape "$RET")\"" + db_get ergo-node-rust/blocks_to_keep || true + if [ -n "$RET" ] && [ "$RET" -eq "$RET" ] 2>/dev/null; then + echo "blocks_to_keep = $RET" + fi + fi + + if topic_selected "Interfaces"; then + db_get ergo-node-rust/api_address || true + [ -n "$RET" ] && echo "api_address = \"$(toml_escape "$RET")\"" + fi + + if topic_selected "Bootstrap"; then + db_get ergo-node-rust/fastsync || true + [ -n "$RET" ] && echo "fastsync = $RET" + fi + + # Blank is the recommended answer and means "derive at runtime", so a + # blank must write nothing at all rather than a zero or a guess. + if topic_selected "Memory"; then + db_get ergo-node-rust/memory_budget_mb || true + if [ -n "$RET" ] && [ "$RET" -eq "$RET" ] 2>/dev/null; then + echo "memory_budget_mb = $RET" + fi + fi + + if topic_selected "Mining"; then + db_get ergo-node-rust/miner_pk || true + if [ -n "$RET" ]; then + echo "" + echo "[node.mining]" + echo "miner_pk = \"$(toml_escape "$RET")\"" + fi + fi + + if topic_selected "Interfaces"; then + db_get ergo-node-rust/listen_port || true + LISTEN_PORT="$RET" + db_get ergo-node-rust/max_inbound || true + MAX_INBOUND="$RET" + if [ -n "$LISTEN_PORT" ] || [ -n "$MAX_INBOUND" ]; then + echo "" + echo "[listen.ipv6]" + [ -n "$LISTEN_PORT" ] && echo "address = \"[::]:$LISTEN_PORT\"" + if [ -n "$MAX_INBOUND" ] && [ "$MAX_INBOUND" -eq "$MAX_INBOUND" ] 2>/dev/null; then + echo "max_inbound = $MAX_INBOUND" + fi + fi + fi + + # seed_peers_add APPENDS to the shipped list rather than replacing it. + # A bare `seed_peers` here would discard every package default, which + # is not what "additional seed peers" asked for. + if topic_selected "Peers"; then + db_get ergo-node-rust/seed_peers_add || true + if [ -n "$RET" ]; then + echo "" + echo "[outbound]" + printf 'seed_peers_add = [' + # ⚠ printf '%s\n', not '%s'. Without the trailing newline the + # last field has no line terminator, `read` returns non-zero + # on it, and the loop exits BEFORE emitting it — silently + # dropping the final peer from every list. + printf '%s\n' "$RET" | tr ',' '\n' | while read -r peer; do + peer=$(printf '%s' "$peer" | tr -d '[:space:]') + [ -n "$peer" ] && printf '"%s", ' "$(toml_escape "$peer")" + done + echo ']' + fi + fi + } > "$CONFD/50-debconf.toml" + + chmod 0644 "$CONFD/00-defaults.toml" "$CONFD/50-debconf.toml" 2>/dev/null || true +} + case "$1" in configure|reconfigure) + write_generated_config + # Stop and disable old proxy if it was running if systemctl is-active --quiet ergo-proxy-node 2>/dev/null; then systemctl stop ergo-proxy-node 2>/dev/null || true @@ -48,3 +199,7 @@ case "$1" in fi ;; esac + +#DEBHELPER# + +exit 0 diff --git a/debian/postrm b/debian/postrm index 5517065..42cfbd0 100644 --- a/debian/postrm +++ b/debian/postrm @@ -3,10 +3,30 @@ set -e case "$1" in purge) + # /etc/ergo-node/conf.d/{00-defaults,50-debconf}.toml are generated by + # postinst, not shipped as conffiles, so dpkg does not track them and + # will not remove them itself. 99-local.toml is the operator's and goes + # the same way — purge means purge. rm -rf /etc/ergo-node /var/log/ergo-node /var/lib/ergo-node deluser --quiet ergo-node 2>/dev/null || true + + # Forget the interview answers, so a later install asks again instead + # of silently reusing choices made for a node that no longer exists. + # Guarded: on purge, debconf may already be gone, and a postrm that + # fails leaves the package half-removed. + if [ -f /usr/share/debconf/confmodule ]; then + . /usr/share/debconf/confmodule + db_purge || true + fi ;; remove) + # Deliberately keeps /etc/ergo-node. The generated conf.d files are not + # conffiles, but they should behave like them here: a remove-then- + # reinstall must not silently discard the operator's configuration. systemctl daemon-reload ;; esac + +#DEBHELPER# + +exit 0 diff --git a/debian/preinst b/debian/preinst index 67a8a14..c2290d3 100644 --- a/debian/preinst +++ b/debian/preinst @@ -8,3 +8,35 @@ if ! getent passwd ergo-node >/dev/null 2>&1; then fi chown ergo-node:ergo-node /var/lib/ergo-node /var/log/ergo-node + +# ── Migrate the legacy single-file config into the conf.d layout ───────── +# +# /etc/ergo-node/ergo.toml used to be a dpkg conffile and is no longer shipped. +# Move whatever the operator has to conf.d/99-local.toml, which is the layer +# that wins over the package defaults. +# +# ⚠ Without this, 00-defaults.toml layers a network default on top of every +# existing operator's hand-tuned file — flipping working mainnet nodes to +# testnet on upgrade. This is the step that stops an upgrade from silently +# changing which chain a node follows. +# +# ⚠ In preinst, not postinst, deliberately. Dropping a conffile from a package +# makes dpkg decide the file's fate during UNPACK — before postinst runs, and +# for an unmodified file that decision is deletion. Migrating here means we see +# the operator's file in the state they left it, whatever dpkg would have gone +# on to do with it. +# +# The guard on 99-local.toml is what makes this the single exception to "the +# package never writes 99-local.toml": it fires once, when there is nothing +# there to lose, and every later upgrade and dpkg-reconfigure leaves it alone. +LEGACY=/etc/ergo-node/ergo.toml +LOCAL=/etc/ergo-node/conf.d/99-local.toml + +if [ -f "$LEGACY" ] && [ ! -e "$LOCAL" ]; then + mkdir -p /etc/ergo-node/conf.d + mv "$LEGACY" "$LOCAL" + echo "ergo-node-rust: moved $LEGACY to $LOCAL" + echo " Your settings are preserved and still win over the package defaults." + echo " Package defaults now live in /etc/ergo-node/conf.d/00-defaults.toml" + echo " and are refreshed on every upgrade." +fi diff --git a/debian/templates b/debian/templates new file mode 100644 index 0000000..23baa11 --- /dev/null +++ b/debian/templates @@ -0,0 +1,155 @@ +Template: ergo-node-rust/network +Type: select +Choices: mainnet, testnet +Default: mainnet +Description: Which Ergo network should this node join? + mainnet is the live chain. Syncing it from genesis downloads roughly 25 GB + and takes hours to days depending on the machine. + . + testnet is the test chain: much smaller, and the right choice if you are + evaluating the node or developing against it. + . + This cannot be changed later by editing the config alone — a node that has + synced one network cannot be pointed at the other without discarding its + data directory. + +Template: ergo-node-rust/topics +Type: multiselect +Choices: Interfaces, Node type, Storage, Bootstrap, Mining, Memory, Peers +Default: +Description: Which settings would you like to configure? + Everything has a working default, so you can select nothing here and get a + running node. Pick only the topics you want to deviate on. + . + Interfaces: listen address, port, and the REST API bind address. + Node type: full UTXO, light, or digest — and how much block history to keep. + Storage: where the chain data lives. + Bootstrap: fast initial sync options. + Mining: miner public key and block-candidate settings. + Memory: how much RAM this node may use. + Peers: additional seed peers and connection counts. + . + You can return to this screen at any time with: + dpkg-reconfigure ergo-node-rust + +Template: ergo-node-rust/listen_port +Type: string +Default: 9030 +Description: P2P listen port: + The port other Ergo nodes connect to. 9030 is the convention; change it only + if something else on this machine already uses it. + +Template: ergo-node-rust/api_address +Type: string +Description: REST API bind address: + Leave blank to use the default for your network (0.0.0.0:9053 on mainnet, + 0.0.0.0:9052 on testnet). + . + WARNING: 0.0.0.0 exposes the API on every interface. The API has no + authentication unless you set an api_key, and some endpoints are not things + you want reachable from the internet. Bind to 127.0.0.1 unless you have + deliberately firewalled the port. + +Template: ergo-node-rust/max_inbound +Type: string +Default: 20 +Description: Maximum inbound peer connections: + How many other nodes may connect to you. Higher is more useful to the + network and costs more memory and bandwidth. + +Template: ergo-node-rust/state_type +Type: select +Choices: ${choices} +Default: utxo +Description: Node type: + utxo keeps the full UTXO set and can serve any query, mine, and validate + everything itself. It is the only type that can mine. It is also the only + type that holds the AVL prover tree in RAM, which is what makes 4 GB the + practical minimum. + . + light tracks headers only. The right choice on a small machine. + . + ${digest} + +Template: ergo-node-rust/blocks_to_keep +Type: string +Default: -1 +Description: Block history to retain: + -1 keeps everything (archival). 0 keeps only what is needed at the chain + tip. A positive number keeps that many recent blocks. + . + This is the single biggest lever on long-term disk and memory use. Archival + is the right default for a node others rely on, and the wrong one for a + small box. + +Template: ergo-node-rust/data_dir +Type: string +Default: /var/lib/ergo-node/data +Description: Chain data directory: + Where the blockchain, UTXO state and indexes are written. Expect tens of + gigabytes on mainnet, growing over time. Point this at the volume that has + the room. + +Template: ergo-node-rust/fastsync +Type: boolean +Default: true +Description: Use fast initial sync? + Bootstraps from a compressed chain snapshot instead of replaying every block + from genesis. Much faster, and the normal choice. + +Template: ergo-node-rust/miner_pk +Type: string +Description: Miner public key (hex): + The 33-byte compressed public key that block rewards are paid to. Leave + blank if you are not mining. + . + This is a public key, not a seed or a private key. Do not paste a mnemonic + or a wallet secret here — debconf stores answers in world-readable form. + +Template: ergo-node-rust/memory_budget_mb +Type: string +Description: Memory budget in MB: + Leave blank — recommended — and the node sizes itself from the systemd + MemoryMax setting if there is one, otherwise from total system RAM. + . + Setting a number here tells the node it may use that much and it will plan + accordingly. It does not enforce anything; only MemoryMax on the service + unit does that. + +Template: ergo-node-rust/seed_peers_add +Type: string +Description: Additional seed peers: + Comma-separated host:port entries, added to the peers shipped with the + package rather than replacing them. Leave blank unless you have specific + nodes you want to connect to. + +Template: ergo-node-rust/mem_refuse +Type: note +Description: Too little memory for a full node + Detected ${detected} MB of RAM. A full (utxo) node will REFUSE TO START below + 3 GB — it holds the UTXO tree in memory, and initial sync is the demanding + phase. + . + Under the "Node type" topic, choose light or digest. Neither holds that tree, + and both run well here. + . + Otherwise: add memory, or set ignore_memory_floor = true under [node] in + /etc/ergo-node/conf.d/99-local.toml to start anyway and risk an + out-of-memory kill during sync. + . + Installation continues either way — this is a warning, not a failure. + +Template: ergo-node-rust/mem_below_recommended +Type: note +Description: Slightly below the recommended memory for a full node + Detected ${detected} MB of RAM. 4 GB or more is recommended for a full (utxo) + node, so this machine is a little under. + . + A full node will start and run here — no action is needed. Initial sync is + the demanding phase; if it struggles, the levers are pruning + (blocks_to_keep under the "Node type" topic) or more memory. + . + If nothing states a memory budget, the node conservatively assumes it may use + only about half of total RAM. Setting MemoryMax on the systemd unit, or a + value under the "Memory" topic, roughly doubles what it will allow itself on + the same hardware. diff --git a/deploy/defaults/mainnet.toml b/deploy/defaults/mainnet.toml new file mode 100644 index 0000000..9d48f19 --- /dev/null +++ b/deploy/defaults/mainnet.toml @@ -0,0 +1,54 @@ +# Ergo Node Rust — mainnet defaults +# +# PACKAGE-OWNED. Installed to /usr/share/ergo-node-rust/defaults/mainnet.toml +# and copied by postinst to /etc/ergo-node/conf.d/00-defaults.toml. +# +# ⚠ Do not edit either copy. Both are replaced on every package upgrade, which +# is deliberate — it is what lets a corrected seed-peer list actually reach +# existing installs instead of being frozen in a conffile forever. +# +# To change any of this, put your value in /etc/ergo-node/conf.d/99-local.toml. +# Files in conf.d/ are merged in lexical order, so 99- wins over 00-. See +# ergo-node-rust.conf(5) and facts/config.md. +# +# This file holds only what is genuinely network-dependent, plus the peer +# counts the package has an opinion about. It deliberately does NOT set: +# +# api_address — the node already defaults it per network +# (0.0.0.0:9053 mainnet, 0.0.0.0:9052 testnet) +# data_dir — an install choice, written to 50-debconf.toml +# cache_mb and the other memory knobs — derived at runtime from the +# memory budget. Writing a number here would freeze it, and +# the floor it is derived against grows with the chain. + +[proxy] +network = "mainnet" + +[listen.ipv6] +address = "[::]:9030" +mode = "full" +max_inbound = 20 + +[outbound] +min_peers = 3 +max_peers = 10 +# To add your own without losing these, use `seed_peers_add` in +# 99-local.toml — a bare `seed_peers` there replaces the list entirely. +seed_peers = [ + "213.239.193.208:9030", + "159.65.11.55:9030", + "165.227.26.175:9030", + "159.89.116.15:9030", + "136.244.110.145:9030", + "94.130.108.35:9030", + "221.165.214.185:9030", + "217.182.197.196:9030", + "173.212.220.9:9030", + "213.152.106.56:9030", + "[2001:41d0:700:6662::]:29031", +] + +[identity] +agent_name = "ergo-node-rust" +peer_name = "ergo-node-rust" +protocol_version = "6.0.3" diff --git a/deploy/defaults/testnet.toml b/deploy/defaults/testnet.toml new file mode 100644 index 0000000..707cbcf --- /dev/null +++ b/deploy/defaults/testnet.toml @@ -0,0 +1,45 @@ +# Ergo Node Rust — testnet defaults +# +# PACKAGE-OWNED. Installed to /usr/share/ergo-node-rust/defaults/testnet.toml +# and copied by postinst to /etc/ergo-node/conf.d/00-defaults.toml. +# +# ⚠ Do not edit either copy. Both are replaced on every package upgrade, which +# is deliberate — it is what lets a corrected seed-peer list actually reach +# existing installs instead of being frozen in a conffile forever. +# +# To change any of this, put your value in /etc/ergo-node/conf.d/99-local.toml. +# Files in conf.d/ are merged in lexical order, so 99- wins over 00-. See +# ergo-node-rust.conf(5) and facts/config.md. +# +# Keep this file structurally identical to mainnet.toml — same sections, same +# keys, differing only in values. A key present in one and absent from the +# other is the drift this layout exists to prevent. +# +# ⚠ Testnet peers move. The February 2026 reset changed the magic bytes +# ([2,0,2,3] -> [2,3,2,3]) and the default port (9022 -> 9023), and these +# addresses are survivors of it rather than a maintained list. If a fresh +# testnet install finds no peers, suspect this list before suspecting the node. + +[proxy] +network = "testnet" + +[listen.ipv6] +address = "[::]:9030" +mode = "full" +max_inbound = 20 + +[outbound] +min_peers = 3 +max_peers = 10 +# To add your own without losing these, use `seed_peers_add` in +# 99-local.toml — a bare `seed_peers` there replaces the list entirely. +seed_peers = [ + "213.239.193.208:9023", + "128.253.41.110:9020", + "176.9.15.237:9021", +] + +[identity] +agent_name = "ergo-node-rust" +peer_name = "ergo-node-rust" +protocol_version = "6.0.3" diff --git a/deploy/ergo-node-rust.service b/deploy/ergo-node-rust.service index 40b6d28..666f34e 100644 --- a/deploy/ergo-node-rust.service +++ b/deploy/ergo-node-rust.service @@ -7,22 +7,53 @@ Wants=network-online.target Type=simple User=ergo-node Group=ergo-node -ExecStart=/usr/bin/ergo-node-rust /etc/ergo-node/ergo.toml +# No config path argument, deliberately. +# +# Configuration is now layered across /etc/ergo-node/conf.d/ — package +# defaults, debconf answers, and the operator's own file — with no single file +# holding the result. Naming a path here would pin the node to one layer. +# +# ⚠ It specifically must not name /etc/ergo-node/ergo.toml any more. preinst +# MOVES that file to conf.d/99-local.toml on upgrade, so an explicit argument +# pointing at it would name a file that no longer exists and the service would +# fail to start on the first upgrade after this change. +# +# With no argument the node searches ./ergo.toml, ~/.config/ergo-node/, then +# /etc/ergo-node/ — and layers a sibling conf.d/ onto whichever it finds, or +# uses conf.d alone. WorkingDirectory is pinned below so "./" is a known +# location rather than whatever systemd happened to pick. +WorkingDirectory=/var/lib/ergo-node +ExecStart=/usr/bin/ergo-node-rust Restart=on-failure RestartSec=10 # stdout/stderr default to journald — the shipped fail2ban jail uses # backend=systemd and journalmatch=_SYSTEMD_UNIT=ergo-node-rust.service, # so PENALTY lines must reach the journal to be acted on. Environment=RUST_LOG=info -# Opt jemalloc out of Transparent Huge Pages. -# With kernel THP=always, jemalloc cannot effectively return memory to -# the OS via madvise(MADV_DONTNEED) — partial THP regions can't be -# reclaimed without splitting, so RSS grows over time even when -# allocated stays flat. Setting thp:never tells jemalloc to -# madvise(MADV_NOHUGEPAGE) its arenas, restoring granular reclaim. -# The _RJEM_ prefix is required by tikv-jemallocator (jemalloc symbols -# are mangled — standard MALLOC_CONF is silently ignored). -Environment=_RJEM_MALLOC_CONF=thp:never +# jemalloc tuning. The _RJEM_ prefix is required by tikv-jemallocator +# (jemalloc symbols are mangled — a plain MALLOC_CONF is silently ignored). +# +# thp:never — with kernel THP=always, jemalloc cannot effectively return +# memory to the OS via madvise(MADV_DONTNEED); partial THP regions can't be +# reclaimed without splitting, so RSS grows over time even when allocated +# stays flat. This makes jemalloc madvise(MADV_NOHUGEPAGE) its arenas, +# restoring granular reclaim. +# +# background_thread:true — jemalloc defaults this to FALSE on Linux. Without +# it, an arena's dirty pages decay only when a thread next touches that +# arena, and jemalloc runs 4 x ncpu arenas (128 on a 32-core host), so quiet +# arenas hold their pages indefinitely. Measured during a genesis sync +# 2026-08-11: 1021 MB of resident-minus-active, a third of the process. +# +# dirty_decay_ms / muzzy_decay_ms — halved from jemalloc's 10s default. +# +# narenas is deliberately NOT set. Reducing it would also cut per-arena +# retention, and changing both at once makes any result unattributable. +# +# NOTE: a systemd drop-in that sets this variable REPLACES this value +# entirely — systemd does not merge repeated assignments of the same +# variable. Copy the whole string when overriding. +Environment=_RJEM_MALLOC_CONF=thp:never,background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 NoNewPrivileges=yes ProtectSystem=strict diff --git a/deploy/ergo.toml b/deploy/ergo.toml deleted file mode 100644 index 4b899f8..0000000 --- a/deploy/ergo.toml +++ /dev/null @@ -1,38 +0,0 @@ -# Ergo Node Rust — system install configuration -# -# Installed by the .deb package to /etc/ergo-node/ergo.toml. Overrides -# only the fields the system install needs to differ from the binary's -# built-in defaults — chiefly `data_dir`, which points at the package's -# system state directory rather than the in-pwd default used by tarball -# installs. -# -# For the full documented option set, see ergo.toml.example shipped at -# /usr/share/doc/ergo-node-rust/examples/ergo.toml.example. -# -# This file is registered as a dpkg conffile — your edits are preserved -# across package upgrades. - -[proxy] -network = "testnet" - -[node] -data_dir = "/var/lib/ergo-node/data" - -[listen.ipv6] -address = "[::]:9030" -mode = "full" -max_inbound = 20 - -[outbound] -min_peers = 3 -max_peers = 10 -seed_peers = [ - "213.239.193.208:9023", - "128.253.41.110:9020", - "176.9.15.237:9021", -] - -[identity] -agent_name = "ergo-node-rust" -peer_name = "ergo-node-rust" -protocol_version = "6.0.3" diff --git a/docs/operations-manual.md b/docs/operations-manual.md index 7e8d81c..9aca2a2 100644 --- a/docs/operations-manual.md +++ b/docs/operations-manual.md @@ -130,18 +130,67 @@ inherit the cold-sync parent when unset. ```toml [node] # Cold sync -cache_mb = 1024 # redb cache during sync +cache_mb = 1024 # TOTAL redb cache across both databases +cache_store_pct = 50 # share to modifiers.redb; rest to state.redb flush_heap_threshold_mb = 2048 # live-heap trigger for redb flush flush_max_blocks = 100 # upper bound between flushes flush_min_blocks = 5 # lower bound, prevents storm # At tip (optional; if unset, mirrors inherit the cold-sync value) -synced_cache_mb = 256 +synced_cache_mb = 256 # also a TOTAL; cache_store_pct applies synced_flush_heap_threshold_mb = 512 synced_flush_max_blocks = 10 synced_flush_min_blocks = 5 ``` +### `cache_mb` is a total, and it changed meaning + +`cache_mb` is the **combined** page cache for `modifiers.redb` and +`state.redb`, divided by `cache_store_pct`. Before this change it sized +`state.redb` alone while `modifiers.redb` silently took redb's built-in 1 GiB +default — so a config reading `cache_mb = 1024` actually consumed 2048 MB. If +you carried a config across that change, the node now uses **less** memory than +it did; raise `cache_mb` if sync throughput regresses. + +`cache_store_pct` must be 1–99. A zero share means a database with no page +cache at all, which the node refuses to start with rather than running badly. + +**Judging whether a cache is big enough:** read `storeCacheEvictions` from +`/debug/memory`, not `storeCacheBytes`. Occupancy reports the live working set, +not the configured ceiling — it reads the same at 8 MiB as at 1 GiB under +identical load. A rising eviction count is the signal that the cache is +undersized. + +**An in-place at-tip resize only moves ~90% of the budget.** redb splits a +cache size 90% read / 10% write, and the runtime resize path reaches only the +read half; the write buffer is fixed when the database is opened. So +`synced_cache_mb = 128` gives roughly 115 MB of read cache plus a write buffer +sized from your cold-sync total. A full restart applies both halves. + +### Tuning the allocator + +Allocator settings live in the systemd unit, not `ergo.toml`, because +`MALLOC_CONF` is read before the process starts and cannot come from a config +file the node parses afterwards. The shipped defaults return freed memory to +the OS promptly; you should not normally need to change them. + +The unit is package-owned and replaced on upgrade, so override with a drop-in +rather than editing it: + +```bash +sudo systemctl edit ergo-node-rust +``` + +```ini +[Service] +Environment=_RJEM_MALLOC_CONF=thp:never,background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000,narenas:16 +``` + +**Copy the whole string and modify it.** systemd does not merge repeated +assignments of the same variable — whatever you set here *replaces* the +packaged value entirely, so omitting `thp:never` or `background_thread:true` +silently disables them and RSS will grow. + ### `flush_heap_threshold_mb` semantics Set to `0` to disable the memory trigger entirely (flushes are then diff --git a/ergo.toml.example b/ergo.toml.example index 21c3cff..0357729 100644 --- a/ergo.toml.example +++ b/ergo.toml.example @@ -144,10 +144,72 @@ protocol_version = "6.0.3" # whether to fastsync. # fastsync_peer_wait_timeout_sec = 30 -# ── Cold-sync memory (in use until chain reaches tip) ─────────────────── +# ── Memory ────────────────────────────────────────────────────────────── +# +# EVERY key in this section is optional and DERIVED when absent. The node +# reads its own ceiling — the cgroup limit if it has one (systemd MemoryMax, +# container --memory), otherwise a conservative share of total RAM — and sizes +# its caches and flush thresholds from that, minus the parts no knob governs +# (the header index, which grows with the chain). +# +# Setting a key disables derivation FOR THAT KEY ONLY and is then obeyed +# exactly. Partial configuration is normal. +# +# The derivation is logged at startup as "memory budget derived", with every +# input and output, so you can see why a cache is the size it is. If that line +# is absent, you configured everything and nothing was derived. +# +# Prefer setting MemoryMax on the systemd unit over memory_budget_mb here: +# the cgroup limit bounds the node for real, whereas this only tells it what +# to aim for. -# redb cache size in megabytes. -# cache_mb = 256 +# Total memory the node may use, MB. Absent = read the cgroup limit, else a +# conservative share of MemTotal. +# memory_budget_mb = 2048 + +# ── Startup memory floor ─────────────────────────────────────────────── +# +# A UTXO node REFUSES TO START when its resolved ceiling is under 3 GiB, and +# warns under 4 GiB. 4 GB is the practical minimum and more is better: cold +# sync is the tightest phase, and it peaked at 3.02 GB on a well-provisioned +# box. `digest` and `light` do not hold the AVL prover tree and are never +# refused — on a small box, those are the modes to run. +# +# The check is on the CEILING, not on free memory at the moment you start, so +# it is the same answer every time rather than one that depends on what the +# page cache happens to be holding. +# +# ⚠ The source of the ceiling changes what it buys. Nothing stating a budget +# means MemTotal, and the node takes a conservative 50% of it — so 4 GB of RAM +# becomes a 2 GB budget, under the cold-sync peak. A MemoryMax on the unit, or +# memory_budget_mb above, raises that to 90% or 100%. Declaring the same RAM +# honestly is worth roughly 40% more budget, and the node says so at startup +# when it notices. +# +# Downgrade the refusal to a warning. For when you have a real reason to cross +# the floor — a heavily pruned node, or a box we are simply wrong about. It is +# a config key rather than a flag so it survives a restart and is greppable, +# and startup says when it is set. +# ignore_memory_floor = false + +# ── Cold-sync values (in use until the chain reaches tip) ─────────────── + +# TOTAL redb page cache across BOTH databases, in megabytes. +# +# BREAKING CHANGE: this previously sized state.redb's cache alone, while +# modifiers.redb silently took redb's built-in 1 GiB default. A config saying +# 1024 actually used 2048 MB. It now means what it says. +# cache_mb = 512 + +# Percentage of `cache_mb` given to modifiers.redb; the remainder goes to +# state.redb. Valid range 1-99 — a zero share means a database with no page +# cache at all, which is a configuration mistake rather than a tuning choice, +# so the node refuses to start rather than running badly. +# +# 50 is provisional. The two databases have very different access patterns +# (modifiers is write-heavy sequential during sync; state carries the AVL +# tree) and the right split is a measurement, not a guess. +# cache_store_pct = 50 # Live-heap threshold (MB) above which the validation sweep commits the # redb write transaction mid-sweep. 0 disables the memory trigger and diff --git a/examples/state_inspect.rs b/examples/state_inspect.rs index cd805ee..8ec098a 100644 --- a/examples/state_inspect.rs +++ b/examples/state_inspect.rs @@ -43,12 +43,7 @@ fn main() { match meta.get(*key).unwrap() { Some(v) => { let bytes = v.value(); - println!( - " {} = {} ({} bytes)", - key, - hex(bytes), - bytes.len() - ); + println!(" {} = {} ({} bytes)", key, hex(bytes), bytes.len()); } None => println!(" {} = ", key), } @@ -79,11 +74,7 @@ fn main() { break; } if let Ok((k, v)) = item { - println!( - " lsn={}, undo_bytes={}", - k.value(), - v.value().len() - ); + println!(" lsn={}, undo_bytes={}", k.value(), v.value().len()); } } } @@ -99,34 +90,30 @@ fn main() { key_length: 32, value_length: None, }; - let storage = RedbAVLStorage::open( - &path, - params, - 200, - CacheSize::Bytes(256 * 1024 * 1024), - ) - .expect("open storage"); + let storage = RedbAVLStorage::open(&path, params, 200, CacheSize::Bytes(256 * 1024 * 1024)) + .expect("open storage"); match storage.version() { - Some(v) => println!( - " storage.version() = {} ({} bytes)", - hex(&v), - v.len() - ), + Some(v) => println!(" storage.version() = {} ({} bytes)", hex(&v), v.len()), None => println!(" storage.version() = None"), } match storage.root_state() { Some((hash, height)) => { - println!(" storage.root_state() = (hash={}, height={})", hex(&hash), height) + println!( + " storage.root_state() = (hash={}, height={})", + hex(&hash), + height + ) } None => println!(" storage.root_state() = None"), } - let rollback_versions: Vec<_> = storage - .rollback_versions() - .collect(); - println!(" rollback_versions (chain w/o current): {}", rollback_versions.len()); + let rollback_versions: Vec<_> = storage.rollback_versions().collect(); + println!( + " rollback_versions (chain w/o current): {}", + rollback_versions.len() + ); for (i, d) in rollback_versions.iter().take(5).enumerate() { println!(" [{}] {}", i, hex(d)); } @@ -139,13 +126,23 @@ fn main() { .expect("construct persistent prover"); let d = persistent_prover.digest(); - println!(" prover.digest() = {} ({} bytes)", hex(d.as_ref()), d.len()); + println!( + " prover.digest() = {} ({} bytes)", + hex(d.as_ref()), + d.len() + ); let prover_digest = d.to_vec(); if args.len() >= 3 { let modifiers_path = PathBuf::from(&args[2]); - println!("\n=== cross-check vs modifiers.redb ({}) ===", modifiers_path.display()); - let store = RedbModifierStore::new(&modifiers_path).expect("open modifiers"); + println!( + "\n=== cross-check vs modifiers.redb ({}) ===", + modifiers_path.display() + ); + // Read-only inspection of a handful of keys; 16 MiB is ample and + // avoids handing an offline tool the node's full cache budget. + let store = + RedbModifierStore::new(&modifiers_path, 16 * 1024 * 1024).expect("open modifiers"); match store.best_header_tip() { Ok(Some((tip_h, _))) => println!(" best_header_tip height = {}", tip_h), @@ -185,7 +182,10 @@ fn main() { // any header whose state_root matches prover.digest(). let scan_top = target_height.saturating_add(50); let scan_bottom = target_height.saturating_sub(100); - println!("\n Scanning headers [{}..{}] for state_root matching prover.digest():", scan_bottom, scan_top); + println!( + "\n Scanning headers [{}..{}] for state_root matching prover.digest():", + scan_bottom, scan_top + ); let mut found_any = false; for h in (scan_bottom..=scan_top).rev() { if let Ok(Some(id)) = store.best_header_at(h) { @@ -193,7 +193,11 @@ fn main() { if let Ok(header) = parse_header(&bytes) { let root_bytes: [u8; 33] = header.state_root.into(); if root_bytes.as_slice() == prover_digest.as_slice() { - println!(" MATCH at height {}: state_root = {}", h, hex(&root_bytes)); + println!( + " MATCH at height {}: state_root = {}", + h, + hex(&root_bytes) + ); found_any = true; } } diff --git a/facts/api.md b/facts/api.md index ab8f3fa..774d37e 100644 --- a/facts/api.md +++ b/facts/api.md @@ -86,6 +86,101 @@ lives at `api/src/lib.rs::ApiState`. The roles, summarized: - Optional P2P-capture handle (`None` when `[debug.p2p_capture]` is off) - Mining state and block submitter (`None` outside UTXO mode with a miner PK) +### Component memory attribution (added 2026-08-11) + +`ChainAccess` carries a `memory_estimate()` returning an api-local +`ChainMemory { index_bytes, header_cache_bytes, score_cache_bytes }`, surfaced +by `GET /debug/memory` as `chainIndexBytes` / `chainHeaderCacheBytes` / +`chainScoreCacheBytes`. + +**The API layer must not compute these numbers.** It reports what the owning +crate tells it. The removed `chainHeaderEstimateBytes` was computed here from +a local `AVG_HEADER_BYTES = 800` describing a `Vec
` inside `chain/`; +when `chain/` retired that Vec in Phase 3, this constant kept multiplying it +by the header count and reported ~1.48 GB for a structure that did not exist — +a figure exceeding total process RSS, which went unnoticed because nothing +cross-checked the two crates. + +This crate deliberately does not depend on `enr-chain`; it reaches the chain +only through `ChainAccess`. `ChainMemory` is therefore an api-local type, and +the integrator's adapter in the main crate maps `chain/`'s +`ChainMemoryEstimate` onto it. That mapping is the adapter's job as the +integration seam — it is not licence to reintroduce sizing arithmetic here. + +Any future per-component memory field follows the same rule: the crate that +owns the structure computes the figure; this crate transports it. + +**Extended 2026-08-12** with `storeCacheBytes`, `stateCacheBytes` and +`storeCacheEvictions`, sourced from `redb::Database::cache_stats()` in `store/` +and `state/` respectively, reached through `StoreAccess::cache_bytes_used`, +`StoreAccess::cache_evictions` and `UtxoAccess::cache_bytes_used`. + +redb was the single largest consumer during a genesis sync and was entirely +invisible to this endpoint — 2701 MB of a 2859 MB process was unattributed, +which is what made the 2026-08-11 investigation take a day. + +All three accessors return `Option` and the fields are omitted when the +value is `None`. Reporting `0` would assert an empty cache, the same class of +falsehood as the removed `chainHeaderEstimateBytes`. The traits declare these +methods **without default bodies**: a default would let an implementor silently +return a wrong value, which is structurally how `AVG_HEADER_BYTES` survived +four months. + +**Extended 2026-08-14** with `proverModifiedNodesBytes`, `proverResidentNodesBytes` +and `syncWindowBytes`. + +The 2026-08-12 extension attributed redb and left the rest. Measured on two +v0.8.0 nodes at the same tip on the same machine: a node that had applied ~27k +blocks to catch up held **2175 MB rssAnon with 1356 MB (87% of live heap) +unattributed**, while a node already at tip held 430 MB with 214 MB +unattributed. Same version, same height, and the catch-up node had the *shorter* +uptime — so the growth follows block application and is not released when sync +ends. The at-tip cache resize fires correctly and does not help, because the +bulk was never cache: that node's caches were down to 34 MB state / 18 MB store. + +The suspects are the AVL prover's working set and `sync/`'s in-flight window, +neither of which any crate reports today. + +⚠ **These three cannot be read synchronously, unlike every field above.** The +prover lives inside the UTXO validator, which `sync/` owns and does not share +(`facts/validation.md` — the validator is moved into `HeaderSync`, not wrapped +in an `Arc`). The API therefore cannot call an accessor on it. They are +**published** instead: the owning crate computes its estimate, the main crate +stores it in a `PublishedGauge` — the mechanism already used for +`shared_height` — and this crate reads that. The `Option` discipline is +unchanged: a gauge that has never been written reports absent, not zero, so a +node that has applied no blocks does not claim an empty prover. + +⚠ **`Arc` is the interchange type; `PublishedGauge` is a view over +one.** `api/` and `sync/` cannot name each other's types — neither depends on +the other, and their only shared crates are `enr-p2p`, `ergo-chain-types` and +`ergo-validation`, none of which is a sensible home for an observability +primitive. So the *storage* is a plain `Arc` that the main crate +allocates once and hands to both ends: the producer publishes into it, and +`PublishedGauge` adopts the same `Arc` to read it. Two views, one allocation. + +`PublishedGauge` must therefore be constructible from an existing +`Arc` and able to hand its own back. A gauge that owns its +`AtomicU64` outright cannot be shared with a producer in another crate, which +is the whole requirement. + +Both ends already agree on `u64::MAX` as the never-published sentinel and on +`Relaxed` ordering. Relaxed is right: this is a monotonic diagnostic gauge with +a single writer, never a synchronisation edge, and nothing downstream orders +other reads against it. + +⚠ **The two cadences differ, deliberately.** `syncWindowBytes` is cheap and +publishes after each applied block. The prover figures walk the resident tree — +O(resident nodes), the same order as applying a block at mainnet scale — so they +publish at **flush cadence**. Per-block publication there is a measurable sync +regression for no benefit: the structure grows monotonically, so a slow gauge +loses nothing. Do not "make it consistent" by moving the prover onto the +per-block path. + +Do not "simplify" this into a direct accessor. A synchronous read would need +either a lock on the validator on an HTTP path or an `Arc` around it, +and the second was rejected on its own merits. + ### State Context Lifecycle `state_context` is rebuilt whenever the validated tip advances: @@ -257,6 +352,28 @@ hit the cap must check the returned length against the requested limit. exist in the store. Rust-specific addition used by fastsync; after a state wipe `fullHeight` resets to 0 while `downloadedHeight` reflects what is still on disk. +- `maxPeerHeight` — highest header height announced by **any** peer in a + `SyncInfo` message since process start. Present for JVM parity; consumers + compute sync progress as `headersHeight / maxPeerHeight`. + +`maxPeerHeight` has three properties a consumer must account for: + +1. **Monotonic within a process.** It never decreases, including when the + announcing peer disconnects or the chain reorgs below it. It is a + high-water mark, not a live poll of current peers. +2. **Absent until the first `SyncInfo` is parsed**, rather than reported as + `0`. A zero would assert "the network is at height 0", which is false and + worse than saying nothing. Consumers must treat absence as "unknown" — + the JVM types this field optional and so do we. +3. **Peer-supplied and unverified.** A peer announcing a bogus height inflates + it for the lifetime of the process. It is advisory display data and must + never feed a consensus, validation, or storage decision. The value that + *does* drive behaviour (the fastsync gap) reads the same underlying atomic + but is bounded by a threshold check. + +`launchTime` — Unix epoch milliseconds at which this process began serving. +Constant for the process lifetime. Present for JVM parity; consumers derive +uptime as `currentTime - launchTime`. `/info/wait?after=H` long-polls until `fullHeight > H` (30s deadline, then 204). diff --git a/facts/chain.md b/facts/chain.md index f902e9a..a09bc7d 100644 --- a/facts/chain.md +++ b/facts/chain.md @@ -31,10 +31,38 @@ Stateless observer. Tracks headers seen on the network without validating chain ## Phase 2: PoW Verification +### `pow_target(n_bits: u32) -> BigInt` + +The Autolykos mining target: `order_bigint() / decode_compact_bits(n_bits)`, +where `order_bigint()` is the secp256k1 group order `q`. + +- **Postcondition**: Returns the value a `pow_hit` must fall strictly below. +- **Invariant**: Never equal to `decode_compact_bits(n_bits)`. That function + returns the **difficulty**, and the target is `q` divided by it. Since + `q ≈ 1.16 × 10^77`, the two differ by roughly `77 − 2·log₁₀(difficulty)` + decimal orders of magnitude — tens of orders at any real difficulty, so + they are never plausibly mistaken for each other in a log line. + +⚠ **This is the only sanctioned way to obtain a target, and it exists because +the formula was independently re-derived once and got it wrong.** `mining/` +sent `decode_compact_bits(n_bits)` directly as the `WorkMessage.b` field, so +external miners were handed a target ~10^58 times harder than the real one and +submitted zero shares while the node's own `check_pow` — which computes the +target correctly — stood ready to accept blocks nobody could find. Serve-side +and verify-side must resolve to the same number by construction, not by two +call sites agreeing to spell out the same division. **Do not inline +`order_bigint() / decode_compact_bits(..)` at a new call site; call this.** + +The naming is the trap: `decode_compact_bits` sounds like it yields the thing +you compare a hash against, and every other consumer in this repo binds it to a +variable named `difficulty` — which is correct, and is what made the one site +that named it `target` invisible in review. + ### `verify_pow(header: &Header) -> Result<()>` - **Precondition**: Header is parsed (Phase 1). -- **Postcondition**: Ok if `pow_hit(header) <= target(header.n_bits)`. Err otherwise. -- **Uses**: `ergo-chain-types::AutolykosPowScheme::pow_hit()`. +- **Postcondition**: Ok if `pow_hit(header) < pow_target(header.n_bits)`, + strictly. Err otherwise. +- **Uses**: `ergo-chain-types::AutolykosPowScheme::pow_hit()`, `pow_target`. - **Cost**: One hash computation. Cheap enough to call on every header before forwarding. - **Invariant**: A header that fails PoW is never valid regardless of chain context. @@ -1286,6 +1314,50 @@ peer on demand. Tracked as a follow-up. - `reorg_floor()` is consulted before any reorg execution. Reorgs whose fork point falls below the floor are rejected. +## Memory attribution (added 2026-08-11) + +### `HeaderChain::memory_estimate() -> ChainMemoryEstimate` + +```rust +pub struct ChainMemoryEstimate { + /// `by_id` (BlockId → height). Unbounded — grows with chain length. + pub index_bytes: u64, + /// `LazyHeaderStore` header LRU, current occupancy. Bounded by capacity. + pub header_cache_bytes: u64, + /// `LazyHeaderStore` score LRU, current occupancy. Bounded by capacity. + pub score_cache_bytes: u64, +} +``` + +Reported by `GET /debug/memory` via `ChainAccess::memory_estimate`. + +**This function must live in this crate, beside the fields it models.** It +replaces a constant (`AVG_HEADER_BYTES = 800`) that sat in `api/` and sized +the `Vec
` retired in Phase 3. Nothing connected the two, so after the +retirement the endpoint reported ~1.48 GB for a structure that no longer +existed — larger than the entire process RSS — and that figure was quoted as a +leading suspect during a real memory investigation. + +The invariant is therefore not "use the right constant" but **the formula +lives with the data**. A future refactor that retires or reshapes a field will +be editing this file, with the estimate visible in it. + +Requirements: + +- Every returned value is a **formula over live counts and capacities**, never + a measurement. Callers must treat it as attribution guidance, not truth; a + heap profile is the ground truth. +- Cache figures are **current occupancy**, not capacity ceilings — an LRU that + is half full must not report as if it were full. +- `index_bytes` must account for real map overhead (control bytes and load + factor), not just `entries × (key + value)`. Under-reporting the one + unbounded structure is the failure mode that matters here. +- Constant-time. `/debug/memory` is an operator diagnostic and must not walk + the chain, the caches, or storage to answer. +- Headers themselves are **not resident** and must not appear in the result. + If a future change makes them resident again, that is a new field with a new + name, decided deliberately. + ## Does NOT own - Block bodies, transactions, AD proofs — that's `ergo-validation`. diff --git a/facts/config.md b/facts/config.md new file mode 100644 index 0000000..69f4e8f --- /dev/null +++ b/facts/config.md @@ -0,0 +1,191 @@ +# Configuration Loading — Interface Contract + +Owner: main session (root `src/`). Consumers: the `.deb` maintainer scripts, +`install.sh`, and `enr-p2p`'s `Config`. + +Design of record: `docs/superpowers/specs/2026-08-11-debconf-first-install-design.md`. +That document explains *why*; this one is what the code must do. + +## Problem + +"What a first config looks like" was written down in four places and they had +already drifted: + +| Source | State | +|---|---| +| `src/main.rs` | Correct. mainnet→9053, testnet→9052. | +| `ergo.toml.example` | Correct. | +| `install.sh` | **Inverted.** mainnet→9052, testnet→9053 — the pre-v0.6.10 values. | +| `deploy/ergo.toml` | **testnet**, testnet seeds, `max_peers = 10`. | + +So a `.deb` install lands on a testnet node with no indication anything is +wrong, and anyone who ran `install.sh` and accepted the default got a config +pointing at the wrong API port for their network. + +A debconf interview that hard-codes ports and seeds becomes the **fifth** copy +and drifts identically. Layering is what prevents that: the network-dependent +values get exactly one home, and the interview only has to name which network. + +## Layout + +``` +/usr/share/ergo-node-rust/defaults/ + mainnet.toml package-owned, never edited, replaced on upgrade + testnet.toml + +/etc/ergo-node/conf.d/ + 00-defaults.toml copied from the above by postinst + 50-debconf.toml generated from debconf answers + 99-local.toml the operator's. the package never writes it. +``` + +**Nothing under `conf.d/` is a dpkg conffile.** `00-` and `50-` are +postinst-owned and rewritten freely on `dpkg-reconfigure`; `99-` is the +operator's and dpkg has no opinion about it. This *removes* the +confold/confnew problem rather than managing it — no `ucf`, no three-way +merge, no prompt on upgrade. + +Refreshing `00-defaults.toml` from `/usr/share` on every upgrade is what lets +seed-peer changes reach existing operators. Today's list is frozen in a +conffile forever. + +## Search path + +Unchanged: `./ergo.toml`, `~/.config/ergo-node/ergo.toml`, +`/etc/ergo-node/ergo.toml`, first found wins. A sibling `conf.d/` **next to the +winning file** is then layered on top of it. + +⚠ **The loader MUST accept either half alone.** A base file with no `conf.d` +(tarball installs) and a `conf.d` with no base file (the `.deb` end state after +migration) are both valid. Neither is an error, and a `conf.d`-only install must +not fall through to "no config found". + +## Merge semantics + +Files in `conf.d/` are read in **lexical filename order**, each layered onto the +accumulated result. + +| Case | Rule | +|---|---| +| Scalar key | Later file wins, **per key, not per table**. Setting `max_peers` must not drop `seed_peers` from the same table. | +| Array key | Later file **replaces** the array, discarding any `_add` contributions accumulated so far. | +| `_add` array | **Appended** to the accumulated ``, in file order. | + +The replace rule is deliberately destructive of prior `_add` entries. Given +`00` setting `seed_peers = [A]`, `50` setting `seed_peers_add = [B]`, and `99` +setting `seed_peers = [C]`, the result is `[C]`. An operator writing a bare +array is stating the complete list; silently retaining an earlier `_add` would +make that statement untrue. + +Per-table-key merging is the rule most easily got wrong, because the naive +implementation — insert the later table over the earlier one — passes any test +where the later file sets every key of that table. It fails the moment someone +sets one key. Test the single-key case explicitly. + +`_add` applies to **exactly three fields**: `seed_peers`, and `include_ips` / +`exclude_ips` under `[debug.p2p_capture]`. It is a convention for those three, +not a general mechanism applied to every array. `_add` names the operation +rather than the provenance — provenance is already implied by which file the +line is in — and leaves room for a future `_remove`. + +## One document, parsed twice + +The merged result is a **document**, not a file. It is parsed twice: into +`RootConfig`, and into `enr_p2p::config::Config` for `[proxy]`, `[listen.*]`, +`[outbound]` and `[identity]`. + +⚠ **Both parses MUST see the merged document.** `enr_p2p::config::Config::load` +took a path and read the file itself, which would have made the p2p half ignore +every `conf.d` layer while the node half honoured them — a node configured two +different ways in one process. `from_toml_str` exists for this; see +`facts/p2p-node.md` § "Module: `config::Config`". + +Do **not** serialise the merged document to a temporary file to satisfy a +path-shaped API. That is the adapter the Interface Integrity rule forbids. + +⚠ **`RootConfig` must keep tolerating unknown keys.** It has no +`deny_unknown_fields` deliberately, because this same document carries +`[proxy]`, `[listen.*]`, `[outbound]` and `[identity]` — sections `RootConfig` +does not declare. Denying unknown fields there would reject every real config. +The consequence is that a typo'd key is silently ignored at this level, which is +a known cost of the double-parse, not laxity. Do not "fix" it by adding the +attribute. + +## The interview, and what it may write + +`50-debconf.toml` is generated from debconf answers. Two screens are +unconditional — **Network** (`select`, default `mainnet`) and **Topics** +(`multiselect`, default nothing selected) — and each selected topic adds its +own questions. An unchecked topic takes defaults silently, so a mainnet +archival node is two screens. + +Topics: Interfaces · Node type · Storage · Bootstrap · Mining · Memory · Peers. + +Network comes first because it determines the defaults every other topic +computes from. Every question has a default, so `DEBIAN_FRONTEND=noninteractive` +completes without prompting and unattended installs get mainnet. + +⚠ **Mining requires `state_type = "utxo"`, and the conflict is removed rather +than reported: if Mining is checked, `digest` is not offered in the Node type +select.** No error path for a combination that means the operator has +misunderstood something more fundamental. + +### The Memory topic is one question + +⚠ **There are no memory profiles.** An earlier design had +`memory_profile = small | standard | large | custom` plus nine individual knobs. +v0.8.0 derives all nine from the ceiling (see `facts/memory.md`), so a profile +would be a second, worse answer to a question already answered — and a fixed one, +against a floor that **grows with the chain**: `chainIndexBytes` went 9.7 MB at +201k headers to 148 MB at 1.85M. Any profile shipping a number is correct on the +day it is measured and drifts from then on. Do not reintroduce them. + +What remains is the one input the system genuinely cannot supply: **how much of +this machine may the node have?** Blank — the default — means derive. A value +writes `memory_budget_mb`, which derivation treats as an explicit statement and +spends at 100%. + +The interview should state the floor at this point rather than let the node +refuse later: below 4 GiB warns, below 3 GiB will refuse to start in UTXO mode. +Install time is where an operator can still act on that. + +## Migration + +`postinst` moves an existing `/etc/ergo-node/ergo.toml` to `conf.d/99-local.toml` +on first upgrade, then the file is dropped from `debian/conffiles`. + +This is the **single exception** to "the package never writes `99-local.toml`": +a one-time move, guarded on that file not already existing. Every subsequent +upgrade and every `dpkg-reconfigure` leaves it alone. + +⚠ **Without the migration, shipping `00-defaults.toml` layers a network default +on top of every existing operator's hand-tuned file — flipping working mainnet +nodes to testnet on upgrade.** The migration is not tidiness; it is the thing +that stops an upgrade from silently changing which chain a node follows. + +Old and new installs converge on the same shape, so the loader carries no +permanent legacy branch. + +## Testing + +1. **Scalar override across files**, and **per-key merge not clobbering + siblings** in the same table. +2. **Array replace**, and **`_add` append order**, including the + replace-discards-prior-`_add` case above. +3. **Base file only**; **`conf.d` only**; **neither present**. +4. **Migration** — run postinst against a copy of a real hand-tuned + `/etc/ergo-node/ergo.toml`; assert it lands at `99-local.toml` + byte-identical and the merged result is unchanged from pre-upgrade. +5. **Preseed** — `debconf-set-selections` a full answer set, install + noninteractive, assert the generated config matches. +6. **Noninteractive, no preseed** — assert mainnet, assert the node starts. +7. **`dpkg-reconfigure`** — assert `50-debconf.toml` is rewritten and + `99-local.toml` is untouched. + +## Non-goals + +- **Not a general `_add`/`_remove` mechanism.** Three fields, by name. +- **Not an ordering the operator can override.** Lexical filename order is the + whole mechanism; a file that wants to win names itself later. +- **Not a validation layer.** Merging does not check semantic validity; the + existing per-field validation runs on the merged result as it does today. diff --git a/facts/fastsync.md b/facts/fastsync.md new file mode 100644 index 0000000..9a80b12 --- /dev/null +++ b/facts/fastsync.md @@ -0,0 +1,144 @@ +# Contract: `ergo-fastsync` (addons/fastsync) + +Version 1.0.0 — 2026-08-11 + +HTTP-based cold-sync accelerator. Downloads headers and block sections from +JVM peers' REST APIs and pushes them into the local node, typically 5-10x +faster than P2P for a cold start. + +**Ownership**: main session, source included. `addons/*` has no per-crate +session or scaffolding — see `CLAUDE.md` § Per-Crate Dispatch. + +## Position + +``` +JVM peers --GET /blocks/chainSlice--> fastsync --POST /ingest/modifiers--> local node + --POST /blocks/headerIds--> (localhost only) +``` + +fastsync is a separate binary, `exclude`d from the workspace with its own +lockfile, versioned independently (currently 0.1.7). The node spawns it when +`fastsync = true` and the gap exceeds `fastsync_threshold_blocks`. + +## Lifecycle + +Spawned by the node at startup when +`peer_tip - downloaded_height > fastsync_threshold_blocks` (default 25,000). + +| Phase | Source | Target | +|---|---|---| +| 1 — headers | `GET /blocks/chainSlice` | `peer_tip - 25000` (handoff margin) | +| 2 — blocks | `POST /blocks/headerIds` | same | + +It exits after phase 2; the node's P2P sync covers the remaining 25,000-block +handoff margin and everything after. **It is NOT short-lived**: phase 2 covers +~98% of the chain, so on a cold start fastsync is resident for the entire sync +— the whole window in which memory is scarce. + +## Interface: fastsync → node + +### `POST /ingest/modifiers` + +Binary body, repeated records, no framing header and no version byte: + +``` +(type_id: u8, modifier_id: [u8; 32], data_len: u32 BE, data: [u8; data_len])* +``` + +Producer: `wire.rs::encode_ingest_body`. +Consumer: `api/src/handlers.rs::post_ingest_modifiers`. + +Invariants: + +- **Localhost only.** The node rejects non-loopback peers with 403. This is + the only access control; there is no authentication. +- **The node buffers the entire body** (`axum::body::Bytes`) before parsing. + Batch size therefore has a hard ceiling. +- Response is `{"accepted": u32}`; fastsync retries once after 1 s on failure, + then aborts the run. +- The format carries **no version or magic bytes**. Both sides must change + together; there is no negotiation and no way to detect a mismatch other + than a parse failure — and a changed `type_id` would not even produce one. + +### ⚠ Unresolved: body limit is undocumented and uncoordinated + +`facts/api.md` documents `max_body_bytes = 2_097_152` as a config option and +asserts "request body size is bounded by `max_body_bytes`" as an invariant. +**Neither the config field nor any explicit limit exists in the code** — no +`max_body_bytes`, no `DefaultBodyLimit`, no `RequestBodyLimitLayer`. The +effective ceiling is axum's built-in default (2 MB in current versions), which +no operator can configure. + +Meanwhile fastsync pushes batches of `BLOCK_BATCH_SIZE = 32` blocks' worth of +sections per request, with no awareness of any ceiling. Nothing coordinates +the two numbers. It works today, so real batches evidently fit; the dense end +of the chain is where they would stop. + +Resolving this means picking one: implement the documented knob, or document +the real limit and bound `BLOCK_BATCH_SIZE` against it. + +## Interface: peers → fastsync + +### `GET /blocks/chainSlice?fromHeight=A&toHeight=B` + +Returns header JSON. **The lower bound is EXCLUSIVE**: `(A, B]`. Verified +against a live endpoint — `fromHeight=0 toHeight=3` yields heights 1,2,3. + +The name says otherwise, and treating it as inclusive drops one header per +chunk. On a cold start that header is genesis, after which nothing chains and +every subsequent header sits in the orphan buffer forever. This was live for +months and only surfaced when a wiped `modifiers.redb` finally exercised the +cold path — every prior run logged "headers already synced — skipping phase 1". + +### `POST /blocks/headerIds` + +Batches of `BLOCK_BATCH_SIZE = 32` IDs. Returns full blocks. + +### `GET /info` + +Peer chain state, used for tip discovery and health. + +### `GET /peers/api-urls` (local node) + +Mid-fetch peer discovery, rate-limited to once per `REFRESH_INTERVAL` (30 s). + +## Memory characteristics + +Measured 2026-08-11 during a genesis resync: **+416 MB/hr, ~40% of the +combined node+fastsync footprint, no plateau observed over 54 minutes.** + +fastsync has **no memory observability** — no `/debug/memory` equivalent, no +jemalloc profiling feature. The figures below are code-read estimates, not +profiles. + +| Structure | Estimate at 1.82M headers | Lifetime | +|---|---|---| +| `header_ids: Vec<(u32, String)>` (`pool.rs:281`) | ~167 MiB | entire run | +| `queue` clone (`pool.rs:546`) | ~153 MiB | all of phase 2 | +| out-of-order `buffer` (`pool.rs:278`) | ~41 MB at 33 peers | transient | + +The first two are **simultaneously live**: `fetch_blocks` takes +`header_ids: &[(u32, String)]` by reference and clones every string into +`queue` up front, so both copies exist for the whole of phase 2. + +IDs are stored as 64-char hex `String`s — 88 bytes of allocation to carry 32 +bytes of data. + +## Invariants + +- fastsync never writes to the node's databases directly. All state changes go + through `POST /ingest/modifiers` and are subject to the node's normal + validation pipeline. +- fastsync is advisory and always optional. A node with `fastsync = false`, or + with the binary absent from `PATH`, syncs correctly over P2P — only slower. +- Phase 1 must complete before phase 2 begins; block fetching is keyed on the + header IDs phase 1 collected. +- Failure is non-fatal to the node: fastsync exiting non-zero leaves the node + to continue over P2P. + +## Does NOT own + +- Validation of anything it fetches — the node validates every modifier it + ingests, exactly as if it had arrived over P2P. +- Chain state, storage, or reorg handling. +- The 25,000-block handoff margin, which is P2P sync's job. diff --git a/facts/journal-events.md b/facts/journal-events.md index 6d88a76..7e9bfec 100644 --- a/facts/journal-events.md +++ b/facts/journal-events.md @@ -1,6 +1,6 @@ # Journal Events Contract -Version: 1.3.0 +Version: 2.1.0 Stable contract for parseable events in the node's structured log output. Consumers (e.g. the Ergo Node Doctor adapter) write parsers @@ -20,12 +20,43 @@ are promised stable. The node advertises this contract's version in `/info`: ```json -{ "journalEventsVersion": "1.0", ... } +{ "journalEventsVersion": "2.1", ... } ``` Consumers refuse to parse on unrecognized major. Additive changes (new events, new optional fields) are minor bumps. +### 2.1.0 — memory budget derivation (v0.8.0) + +Additive: one new event, `memory_budget_derived`. Minor bump; consumers pinned +to major 2 are unaffected. + +### 2.0.0 — deferred script evaluation removed (v0.8.0) + +A major bump, because three **stable** events were removed outright and two +field domains narrowed. Per the stability rules below, that is exactly what a +major is for. + +| Change | Was | +|---|---| +| `deferred_eval_backlog` removed | 1.5, replaced by `catchup_progress` | +| `deferred_eval_gate_engaged` removed | 1.6 | +| `eval_frontier_hole` removed | 1.6 | +| `validation_rollback_failed.path` loses `eval_failure` | now only `reorg` | +| `validation_stuck.error_kind` loses `script_eval` | replaced by `transaction_invalid` | + +The first four describe deferred script evaluation, which no longer exists: +`apply_state` evaluates before persisting, so there is no queue to report on, +no dispatch gate to engage, and no verification frontier to fall behind. + +⚠ **The stability rules below also require a deprecation release, and this did +not get one.** The events went in the same release that removed the machinery +behind them, because emitting a deprecated `eval_frontier_hole` from a node +with no frontier would mean fabricating the fields. Consumers pinned to major 1 +— the Doctor adapter among them — will refuse to parse a v0.8.0 node until they +are updated. That is a real cost and it was accepted knowingly rather than +overlooked. + ## Format conventions Each contract event is a single line emitted via `tracing::info!()`, @@ -37,12 +68,43 @@ human-readable subscriber. The line carries: - Zero or more **fields**: `key=value` pairs appended via tracing's named-field syntax. Keys are snake_case ASCII. String-recorded values are surrounded by double quotes in the default formatter (e.g. - `error_kind="script_eval"`); numeric and `Display`-formatted values + `error_kind="missing_key"`); numeric and `Display`-formatted values are not. Parsers MUST tolerate optional surrounding quotes on any value. - Optional **free-text suffix** after the marker, for human readability. Parsers MUST tolerate arbitrary suffix content. +Two rules follow from parsers matching on the *prefix*, both learned by +breaking them on 2026-08-12: + +- **No marker may be a prefix of another marker.** A parser keyed on the + shorter one matches both events and then fails looking for fields that only + the shorter one carries. `"deferred eval backlog at bound …"` began with the + whole of `"deferred eval backlog"` and did exactly this; it became + `"eval dispatch gate engaged"`. When adding an event, check its marker + against every existing one in both directions. +- **Markers are ASCII.** The free-text suffix may contain anything — an em dash + there is fine and several markers have one — but the matched portion must + survive being retyped from a report, a terminal, or a grep. The same event + shipped with a U+2014 inside its marker while this document specified a + hyphen, so the documented literal matched nothing. + +One field-level convention, which applies to **every** event that carries it: + +- **`state_applied_height` is always `validator.validated_height()`, never + `HeaderSync`'s struct field of the same name.** That field is a cache + reconciled at sweep *end*; any event emitted mid-sweep reads it frozen at the + pre-sweep tip. This is stated once here rather than per entry because it has + caught two events before either was retired in 2.0 — `deferred_eval_backlog`, + where the stale read would peg `eval_lag` at 0 by saturation and report a + healthy node while the queue grew, and `eval_frontier_hole`, reached from the + dispatch gate's mid-sweep drain. Both are gone; the trap is not. `catchup_progress` + carries the field today and reads it from the validator for exactly this + reason. Assume the next event that carries it has the same trap. + +A parser consuming these cannot tell a stale value from a fresh one, which is +why the rule lives in the contract rather than in a comment at each emit site. + Example emit: ```rust @@ -240,12 +302,53 @@ phase's `_started`. - **Emitted:** when the validated frontier (`validated_height`) fails to advance past the same height for `attempts >= 5` consecutive sweeps — the next block is failing deterministically. Covers both - failure modes: an `apply_state` error (state-DB inconsistency such - as `missing_key`) and a deferred script-eval rejection (the - evaluator refusing a transaction). `error_kind` names the mode — an - `apply_state` error kind, or `script_eval` for an eval-failure - stall; `missing_key` is present only for the `apply_state` - `missing_key` case. Surfaces the silent retry loop that previously + failure modes, though since 2.0 they arrive by one route: an + `apply_state` error (state-DB inconsistency such as `missing_key`) + and a script rejection, which `apply_state` now returns directly + because evaluation happens inside it. `error_kind` names the mode + via `classify_apply_state_error`; `missing_key` is present only for + the `apply_state` `missing_key` case. + + **`error_kind` domain as of 2.0:** `missing_key` (the AVL tree lacks a key + the block spends), `transaction_invalid` (the block was rejected because a + transaction in it did not validate — a consensus problem), `other` + (everything else). `missing_key` is accompanied by the `missing_key` field; + the others are not. + + ⚠ **`missing_key` arrives through two variants, not one, and means + different things in each.** The prose is raised once in + `ergo_avltree_rust`'s shared `operation.rs`, which both the prover and the + verifier use — so it surfaces as `StateOperationFailed` in UTXO mode and as + `ProofVerificationFailed` in digest mode. The classifier matches both. + In UTXO mode it means the node's own state store lacks the key, which is a + local storage problem. In digest mode the node holds no UTXO set at all, so + it means the *proof* did not contain the key it needed — closer to a bad + block or a bad peer. An adapter that reads `missing_key` as "this node's + database is damaged" is right only for a UTXO-mode node, and should consult + `stateType` from `/info` before recommending a resync. + + *An earlier draft of this section described a single `String`-carrying + variant and called the kind a state-DB problem outright. Both were wrong, + and matching only the UTXO arm would have dropped `missing_key` on every + digest-mode node — silently, which is the exact failure this rewrite exists + to prevent.* + + *`script_eval` is gone and `transaction_invalid` is not a rename of it.* + The old value named the deferred eval-failure **path**, which no longer + exists. The new one names the **fact** — `ValidationError::TransactionInvalid` + — which covers a failed script and equally a failed ERG or token + preservation check. That is the distinction the Doctor actually needs: is + this node stuck because its state store is damaged, or because it keeps + refusing a block the network accepted? + + ⚠ **Classification is on the error variant, not on its Display string.** + `classify_apply_state_error` took a `&str` and grepped it for + `"does not exist"`, while its caller held the typed `ValidationError` and + stringified it one line earlier. That is why the distinction disappeared + silently when deferred evaluation was removed — nothing referenced the + variant, so nothing broke. It now takes `&ValidationError`. The one place + a string is still parsed is inside `missing_key`, where the key really + does arrive as prose from the storage layer. Surfaces the silent retry loop that previously buried a stuck frontier in INFO-level logs. The Doctor adapter treats this as a primary "node stuck" signal. Emitted at most once per height; re-emits when the height changes or after real progress @@ -253,16 +356,65 @@ phase's `_started`. `apply_state` path — a deferred eval-failure stall (the loop that hammered on a wrongly-rejected script) did not emit it. +#### `memory_budget_derived` +- **Level:** INFO +- **Marker:** `"memory budget derived"` +- **Fields:** `source` (string: `config`|`cgroup`|`meminfo` — where the ceiling + came from), `ceiling_mb` (u64), `usable_mb` (u64: ceiling × the fraction that + source justifies), `baseline_mb` (u64), `chain_index_mb` (u64), + `available_mb` (u64: usable − floor), `cache_mb` (u64), + `cache_store_pct` (u64), `store_cache_mb` (u64), `state_cache_mb` (u64), + `flush_heap_threshold_mb` (u64), `synced_cache_mb` (u64) +- **Since:** 2.1 +- **Stability:** stable +- **Emitted:** once at startup, after the header chain is restored, **only when + at least one memory setting was derived**. A node with every memory key set + explicitly derives nothing and emits nothing — absence of this event means + the operator configured everything, not that derivation failed. +- **Why it exists:** the node sizes its own caches from its cgroup limit rather + than from config. Auto-sizing's failure mode is not being wrong, it is being + wrong **invisibly** — without this line an operator asking "why is my cache + this size" has nothing to point at. Every input and every output is present + so the arithmetic can be checked from the log alone. + + A second INFO line follows when `source` is `meminfo`, noting that setting + `MemoryMax` on the unit or `memory_budget_mb` in `ergo.toml` would let the + node use more. That path takes a deliberately conservative share, because + nothing said the node owns the machine. + +#### `catchup_progress` +- **Level:** INFO +- **Marker:** `"catch-up progress"` +- **Fields:** `state_applied_height` (u64: the **applied tip**, read from the + validator rather than from sync's struct field), `jemalloc_allocated` + (u64, **omitted entirely** when no heap probe is wired — an absent field + rather than a rendered `None`, same convention as `validation_stuck`'s + optional `missing_key`) +- **Since:** 2.0 (replaces `deferred_eval_backlog`, 1.5–1.6) +- **Stability:** stable +- **Emitted:** at most once per 5 s during catch-up, from the sweep loop. Not + emitted at tip. +- **Why it exists:** catch-up is otherwise silent between flushes, and an + operator watching a long sync needs to know it is moving. The applied tip is + read from the validator because sync's own field lags it within a sweep. + +*Replaced `deferred_eval_backlog` in 2.0. That event carried four more fields +— `evals_in_flight`, `script_verified_height`, `eval_lag`, and +`eval_bytes_in_flight` — all describing the deferred-eval queue, which no +longer exists. Anything quoting `eval_lag` from a v0.7.x journal is quoting a +number that was frozen by the reorder-buffer bug until 2026-08-12 anyway; see +facts/sync.md § "Block Assembly".* + #### `validation_rollback_failed` - **Level:** ERROR - **Marker:** `"validation rollback failed"` - **Fields:** `height` (u64: the rollback TARGET height), `path` - (string: `eval_failure`|`reorg`), `error` (string: the underlying + (string: `reorg` — the only value since 2.0; see below), `error` (string: the underlying storage/rollback error, Display-formatted) - **Since:** 1.4 (added 2026-06-12 with `reset_to → Result`) - **Stability:** stable - **Emitted:** when `BlockValidator::reset_to` returns Err on the - deferred-eval-failure path or the reorg-control path — the underlying + reorg-control path — the underlying state rollback failed and the validator did NOT move (its height and digest are unchanged; facts/validation.md `reset_to` Err postcondition). Sync holds every watermark in place rather than diff --git a/facts/memory.md b/facts/memory.md new file mode 100644 index 0000000..5c2163e --- /dev/null +++ b/facts/memory.md @@ -0,0 +1,224 @@ +# Memory Budget — Interface Contract + +Owner: **main crate** (`src/main.rs`). The crates that consume memory — +`enr-state`, `enr-store`, `ergo-sync` — already receive byte counts as +constructor parameters and need no API change for this. Derivation is +orchestration, which is main's job. + +## Problem + +The node sizes its caches from `cache_mb` in `ergo.toml` and is otherwise blind +to how much memory it may actually use. On 2026-08-13 a test node was run under +`systemd-run -p MemoryMax=1500M` and had **no idea** — it sized itself from a +TOML file, and had that number been wrong for the ceiling, the first indication +would have been the OOM killer. The kernel knew the answer exactly the whole +time. + +Every value the operator is asked for is one the system can supply: + +- the ceiling is in the cgroup, or in `MemTotal` +- the untunable floor is measurable at runtime and **grows with the chain** — + `chainIndexBytes` was 9.7 MB at 201k headers and 148 MB at 1.85M, so any + number written into a config at install time is correct on the day and drifts + thereafter +- absolute defaults are wrong at the edges: `flush_heap_threshold_mb = 4096` + sits above total RAM on any box small enough to care, so the trigger it + governs is inert exactly where it is needed + +## Rule: autosize unless told otherwise + +**An option absent from the config is derived. An option present in the config +is obeyed, exactly, with no adjustment.** + +⚠ **This requires the config fields to be `Option`.** Today they are +`#[serde(default = "default_cache_mb")]`, so by the time `main` reads +`cache_mb = 1024` it cannot distinguish "the operator wrote 1024" from "serde +filled it in". Both must be representable, because the failure mode otherwise is +the worst kind: an operator sets a value, derivation silently overrides it, and +nothing says so. + +The `default_cache_mb` and `default_flush_heap_threshold_mb` functions are +**deleted**, not repurposed as fallbacks: derivation cannot fail. If every +source is unreadable it assumes a small box (1 GB) rather than a large one, so +there is always a number and a second default would only be a second answer to +the same question. + +Fields under this rule: `cache_mb`, `cache_store_pct`, +`flush_heap_threshold_mb`, `synced_cache_mb`, +`synced_flush_heap_threshold_mb`. + +⚠ **`flush_max_blocks` and `flush_min_blocks` are NOT derived**, and keep their +existing defaults (100 / 5). They bound crash-recovery work in *blocks*, and +nothing measured relates a block count to a memory budget. They do affect +memory — fewer blocks between flushes means fewer accumulated dirty pages — so +a constrained profile may well want them lower, but picking that number without +a measurement would be exactly the invention this contract exists to avoid. + +Setting **any** of them disables derivation for that field only; the rest are +still derived. Partial configuration is the normal case, not an error. + +## The effective limit, in priority order + +1. **`memory_budget_mb`** in `[node]`, if set — an explicit statement, used as + given. +2. **The cgroup limit.** cgroup v2 `memory.max` (the literal string `max` means + no limit), else cgroup v1 `memory.limit_in_bytes` (a huge sentinel means no + limit). Read the node's own cgroup via `/proc/self/cgroup`. +3. **`MemTotal`** from `/proc/meminfo`. + +Take `min()` of any that apply — a cgroup limit above physical RAM is not a +licence to use more than exists. + +**The fraction taken differs by source, and deliberately:** + +| Source | Fraction | Why | +|---|---|---| +| `memory_budget_mb` | 100% | The operator stated a budget. Obey it. | +| cgroup limit | high (~90%) | A cgroup limit is an explicit statement of intent — something already decided this node gets this much. | +| `MemTotal` | conservative (~50%) | Nothing said the node owns the box. Log that setting `MemoryMax` or `memory_budget_mb` would let it use more. | + +## Budget against anon, not against the limit + +⚠ **The cgroup counts page cache toward `memory.max`, but page cache is +reclaimable and anonymous memory is not.** On 2026-08-13 the test node showed +`MemoryPeak` at 1.36 GB against a 1.5 GB ceiling — 86%, alarming — while +`memory.stat` reported `anon` 578 MB and `file` 292 MB, and `memory.events` said +`max 0`: the limit had never once been touched. Nearly 300 MB of that "peak" was +cache the kernel drops on demand. + +So the budget is spent against **anon**, and page cache is left to the kernel. +Sizing against `MemoryCurrent`/`MemoryPeak` over-provisions by roughly the cache +and is the single easiest mistake to make here — it was made during the +measurement that produced this contract. + +## Derivation + +``` +limit = effective limit × source fraction (above) +floor = chain_index_bytes (measured, grows with the chain) + + baseline_anon (calibration, below) +available = max(0, limit − floor) + +cache_total = available × CACHE_SHARE +flush_heap_threshold = available × FLUSH_SHARE +synced_cache_total = cache_total × SYNCED_RATIO +``` + +`cache_total` is then split by `cache_store_pct` exactly as today. + +**Calibration constants, with provenance.** These come from mainnet at 1.85M +blocks on a 32-core box, `cache_mb = 1024` / `synced_cache_mb = 128`, +inline-only evaluation: + +- `baseline_anon` ≈ **300 MB** — allocated-but-unattributed heap at tip + (jemalloc `allocated` 703 MB against 402 MB of tracked components). Reproduces + the "~200 MB of at-tip growth that cache does not account for" reported + independently from the field. Excludes jemalloc's own overhead, a further + ~260 MB between `allocated` and `resident`. +- Cold-sync peak reached **3.02 GB RSS** with a 1 GB cache budget, against + 950 MB at tip — so the cold-sync phase, not the at-tip phase, sets the + ceiling. + +⚠ **`CACHE_SHARE` and `FLUSH_SHARE` are not yet calibrated for a constrained +box.** The run that settles them is in progress; until it lands, these are the +one part of this contract resting on a fast machine's numbers. Do not present +derived values for a small box as measured. + +## When it runs + +⚠ **Derivation happens in two phases, because of an ordering constraint that is +not negotiable.** `modifiers.redb` must be opened before the header chain can be +restored — the chain is restored *from* it — and redb fixes its cache size at +open with no resize path. So `chain_index_bytes`, part of the floor, is not +knowable when the first cache must be sized. + +- **Phase 1, before the store opens.** Floor is `baseline_anon` alone; the index + is not yet knowable. Derives the **store cache** only. The budget is + over-estimated by whatever the index turns out to be — bounded by ~150 MB at + today's chain length, so single-digit percent of any sane budget. +- **Phase 2, after the header chain is restored.** `chain_index_bytes` is real. + Derives the **state cache and the flush thresholds**, and logs the phase-1 + estimate against the measured floor so the error is visible rather than + assumed small. +- **Once at the at-tip transition**, reusing the existing `synced_*` swap. The + machinery already exists; derivation supplies its numbers. + +Do not "fix" phase 1 by opening the store with a small bootstrap cache and +reopening later: a small cache makes the startup chain walk roughly 70× slower, +which is a known and measured cost. + +**It does NOT re-derive periodically.** The floor moves over months, not +minutes, and a restart picks up the drift. A periodic re-derive trades a real +churn risk for an imperceptible gain. + +## Startup floor + +A ceiling below which a UTXO node will not run at all, checked once at startup +after the budget is resolved and before anything is opened. + +``` +ceiling < 3 GiB and state_type == "utxo" → refuse to start +ceiling < 4 GiB → warn: below the recommended minimum +usable < 3.02 GB → warn: derived budget is under the measured cold-sync peak +``` + +**Refusal keys on `ceiling_bytes`, not `MemAvailable`.** `MemAvailable` +fluctuates with page cache, so keying on it makes startup nondeterministic — a +node that came up at boot would refuse after a busy hour, for a reason the +operator cannot see and did not cause. The ceiling is what the node is *allowed*, +is already computed, and is the same number derivation spends. + +⚠ **The refusal is UTXO-mode only.** 4 GiB is a UTXO-mode figure — that is where +the AVL prover tree lives. `digest` uses the verifier rather than the persistent +prover and `light` holds no tree at all, so refusing them on a small box would +block the one mode that box should be running. They warn and continue. + +⚠ **Ceiling and usable diverge by source, and the gap is the point of the second +warning.** A 4 GiB box with no cgroup resolves to `MemTotal` → 50% → **2 GiB +usable**, which is below the 3.02 GB cold-sync peak. It passes the ceiling check +and is still the configuration most likely to struggle. The warning must say the +actionable thing: setting `MemoryMax` or `memory_budget_mb` moves the source to +cgroup or explicit and the fraction from 50% to 90% or 100% — the *same RAM*, +honestly declared, buys ~40% more budget. + +The evidence for 3 GiB being survivable at all is a cgroup one: +`cg_anon` peaked at **2.47 GB against a 3 GiB ceiling** across **831k blocks +with zero OOM kills**. That is 90% of 3 GiB — 2.7 GB usable. Bare `MemTotal` of +3 GiB gives 1.5 GB and is not the same machine. **Do not cite the 831k-block run +as evidence that 3 GB of RAM is enough; it is evidence that a 3 GiB _stated +budget_ is enough.** + +**Escape hatch: `[node] ignore_memory_floor = true`.** Downgrades the refusal to +a warning. A rule-of-thumb floor is exactly the kind of thing a competent +operator has a real reason to cross — a pruned node, a mode we have not measured, +a box we are wrong about — and a check with no override turns our estimate into +their outage. It is deliberately a config key rather than a flag: it survives a +restart, it is greppable, and the startup log states when it is set. + +This is a **precondition, not a limiter** — see the non-goal below. It refuses to +begin work it cannot finish; it does not cap, shrink, or police anything once +running. + +## Observability + +Derivation MUST be logged at startup at INFO, as a parseable journal event, with +every input and every output. Auto-sizing's failure mode is not being wrong — it +is being wrong **invisibly**, leaving the operator no line to point at. See +`facts/journal-events.md` § `memory_budget_derived`. + +An operator must be able to answer "why is my cache this size" from the log +alone, without reading this document. + +## Non-goals + +- **Not a memory limiter.** Derivation sizes caches and flush thresholds; it + does not enforce a ceiling, and cannot prevent an OOM caused by something it + does not size. The startup floor above is not an exception: it is a + precondition checked once, before any work begins. Nothing observes memory + after startup, and a node that passes the floor and then grows past its + budget is still killed by the kernel exactly as before. +- **Not adaptive under pressure.** The node does not shrink caches in response + to memory pressure. That is a larger design and is not this. +- **Does not size `blocks_to_keep`.** Retention is the biggest lever on at-tip + footprint and is a *policy* decision — archival versus pruned changes what the + node is, not just what it costs. diff --git a/facts/mempool.md b/facts/mempool.md index 0a495e8..f221bc2 100644 --- a/facts/mempool.md +++ b/facts/mempool.md @@ -75,14 +75,22 @@ intra-block output tracking. Also expose the state context builder for mempool use: ```rust -/// Build an ErgoStateContext from a header and preceding headers. -pub fn build_state_context( - header: &Header, +/// Build an ErgoStateContext whose preheader describes the NEXT block. +pub fn build_upcoming_state_context( + last_header: &Header, preceding_headers: &[Header], parameters: &Parameters, ) -> ErgoStateContext; ``` +⚠ **The mempool takes the *upcoming* context, never `build_state_context()`.** +An unconfirmed transaction is a candidate for the block after the tip, and +wallets set `creationHeight` accordingly. Validating against a preheader at the +current tip rejects every well-formed transaction on the network with `Creation +height H+1 > preheader height`. Full derivation and the JVM reference are in +`facts/validation.md` § "Free Functions: state context". The mempool does not +build this itself — the main crate publishes it after each applied block. + Both functions are pure — no mutable state, no side effects. ## UTXO State Reader Trait @@ -211,9 +219,38 @@ pub struct MempoolConfig { pub cost_per_block: u64, /// Maximum validation cost budget per peer per block interval. pub cost_per_peer_per_block: u64, + /// Miner reward delay, the only input to the fee proposition tree. + /// Mainnet monetary constant: 720. Default 720. + pub reward_delay: i32, } ``` +**The fee proposition is built once, not per transaction.** `Mempool::new` +derives it from `reward_delay` via +`ergo_lib::chain::ergo_tree_predef::fee_proposition()` and stores it; step 7a +compares each output's `ergo_tree` against the stored value. Deriving it per +output would recompile a tree for every output of every transaction. + +`Mempool::new` stays **infallible** and panics if the tree cannot be built. +`fee_proposition()` is a pure function of one integer with no external input, so +a failure is a build-integrity fault rather than a runtime or configuration +condition, and the only cheap fallback — treat the fee as zero — silently +declines every transaction on the network, which is the defect step 7a exists to +fix. A loud startup failure for an unreachable case beats a quiet one for a +reachable one. + +⚠ **That reasoning depends on `reward_delay` not being user-settable.** It is a +`MempoolConfig` field left at its default by both call sites today. If it is +ever wired to an operator-facing key, the panic becomes reachable from a config +file and `new` must become fallible. + +⚠ **`reward_delay` is a monetary constant, not a mining setting.** The node +must extract fees whether or not mining is enabled, so this does **not** read +`[mining].reward_delay`. It mirrors the JVM's +`chainSettings.monetary.minerRewardDelay`. Networks with a different delay need +this threaded from chain settings; today both default to 720 and mainnet is the +only network this has been exercised on. + ### `ExpiringCache` Time-bounded set. Entries expire after a configured TTL. Bounded by max capacity. @@ -400,16 +437,38 @@ impl Mempool { ## Processing a Transaction: Detailed Flow +⚠ **The fee check runs *after* validation, not before it.** Steps 3 and 4 used +to be listed ahead of input resolution, which is not implementable: the fee is a +function of the resolved input boxes. They are numbered 7 below, matching the +code. Nothing may reorder them ahead of step 5. + 1. **Check invalidated**: If `tx_id` is in `invalidated`, return `Invalidated`. 2. **Check duplicate**: If `tx_id` is in `by_id`, return `AlreadyInPool`. -3. **Compute fee**: Sum output values going to the fee proposition address. -4. **Check min fee**: If `fee < config.min_fee`, return `Declined`. 5. **Resolve input boxes**: For each input, look up via `utxo_reader.box_by_id()`. If any input is missing, return `Declined` (not invalidated — input may appear later). 6. **Resolve data-input boxes**: Same lookup for data inputs. +6a. **Check creation height** (transient guard): if any output's `creation_height` + exceeds `state_context.pre_header.height`, return `Declined` — **not** + `Invalidated`. The transaction was built against a tip newer than ours and + becomes valid as soon as we apply the next block. Caching it as invalid + suppresses every rebroadcast for `invalidation_ttl` (1800s ≈ 15 blocks) + without re-validating, so a momentary propagation race turns into a half-hour + blackout for that transaction. Same reasoning as step 5's missing input. 7. **Validate**: Call `validate_single_transaction(tx, inputs, data_inputs, state_context)`. On failure: return `Invalidated` (add to expiring cache). On success: receive `cost`. +7a. **Compute fee**: sum the values of outputs whose `ergo_tree` equals the fee + proposition. **Not `input_sum - output_sum`.** ergo-lib enforces exact ERG + preservation (`ErgPreservationError` when `input_sum != output_sum`, + `wallet/tx_context.rs:122`), so a difference-based fee is structurally zero + for every transaction that reaches this point, and step 7b then declines all + of them. Ergo has no implicit change-to-fee remainder: the fee is an explicit + output guarded by the fee proposition. JVM parity — + `ErgoMemPool.extractFee` filters on + `settings.chainSettings.monetary.feeProposition` + (`ErgoMemPool.scala:304-309`). +7b. **Check min fee**: If `fee < config.min_fee`, return `Declined` (fee may be + raised by a replacement; this is not an invalidation). 8. **Compute weight**: `fee_per_factor = fee * 1024 / fee_factor` where `fee_factor` is `tx_bytes.len()` (FeePerByte) or `cost` (FeePerCycle, with `FakeCost = 1000` fallback if cost is 0). diff --git a/facts/mining.md b/facts/mining.md index e9f9fff..178a767 100644 --- a/facts/mining.md +++ b/facts/mining.md @@ -64,6 +64,11 @@ current candidate is lost; miners just poll for a new one. - `ergo-chain-types` — `Header`, `AutolykosPowScheme`, `AutolykosSolution`, `ADDigest`, header serialization - `ergo-nipopow` — interlink vector computation for extension section +- `enr-chain` — `pow_target(n_bits)`, the single definition of the Autolykos + mining target served as `WorkMessage.b`. The crate consumed + `ergo-chain-types` directly and re-derived the target itself — from the + crate's completion through `v0.7.11` — which is how it came to serve the + difficulty instead. No cycle: `chain/` has no in-repo path dependencies. - `ergo-validation` — `compute_state_changes`, `validate_single_transaction`, `build_state_context`, `Parameters` - `ergo_avltree_rust` — via UtxoValidator (temporary prover operations for @@ -119,8 +124,10 @@ pub struct CandidateBlock { pub parent: Header, /// Block version. pub version: u8, - /// Encoded difficulty target for this block. - pub n_bits: u64, + /// Compact-encoded **difficulty** for this block (not the mining target — + /// see `WorkMessage.b`). `u32`, matching `Header.n_bits`; serialized as 4 + /// raw big-endian bytes, the one non-VLQ integer in the header. + pub n_bits: u32, /// New state root after applying selected transactions. pub state_root: ADDigest, /// Serialized AD proofs for the state transition. @@ -144,7 +151,9 @@ pub struct CandidateBlock { pub struct WorkMessage { /// Blake2b256 hash of the serialized HeaderWithoutPow. pub msg: [u8; 32], - /// Target value derived from nBits. Solution must satisfy hit < b. + /// Autolykos target: `q / decode_compact_bits(nBits)`, i.e. + /// `enr_chain::pow_target(nBits)` — NOT the decoded nBits itself, which + /// is the difficulty. Solution must satisfy hit < b. pub b: BigInt, /// Block height (used in Autolykos v2 index calculation). pub h: u32, @@ -312,6 +321,31 @@ struct SolvedLatch { } ``` +### Startup: the proof cache must be seeded + +`main` holds a `MiningProofData` cache — the parent header and emission box the +mining task builds each candidate from. It is written by +`Validator::update_mining_proofs`, which runs **only on the post-apply path**, +and the mining task refuses to build when it is absent or its `tip_height` +does not match the current validated height. + +⚠ **`main` MUST seed it at startup from the restored tip.** Otherwise a node +restarted while already at the chain tip serves 503 from `/mining/candidate` +until a peer delivers the next block — and for a node that is the only miner +on its network, never: no candidate, so no block, so no application, so no +candidate. Observed in the field as an hour of 503s after an at-tip restart, +with three peers connected. + +The gap is invisible during sync, which is why it survived: a catching-up node +applies a block within seconds and the cache fills itself. Only an at-tip +restart exposes it. + +Seeding depends on `emission_box_id()` being valid on a freshly constructed +validator — see `facts/validation.md` § "Recovering `emission_box_id` on +resume". **Seeding the cache alone does not fix this**; without that recovery +`update_mining_proofs` returns early at the emission-box check and the symptom +is unchanged, which makes it look like the diagnosis was wrong. + ### Lifecycle API ```rust @@ -388,6 +422,77 @@ sharing the storage backend. ## Candidate Assembly +### Regeneration triggers + +A cached candidate is rebuilt when **either**: + +- the validated tip advances, or +- **the cached candidate has aged past `candidate_ttl`** (default 15 s). + +⚠ **The second trigger was missing until v0.8.0, and its absence made mining +unusable.** Regeneration was gated on tip change alone, while `cached_work` +invalidates on TTL — so `/mining/candidate` served work for 15 s after each +block and returned **503 for the remaining ~105 s** of a mainnet interval. A +miner could fetch work for roughly 12% of each block. The config key is +documented as "maximum candidate lifetime before forced regeneration"; only the +invalidation half existed. + +Regenerating on expiry also keeps the candidate's `timestamp` current and, with +transaction selection wired, picks up transactions that arrived *after* the +last block — rather than repeatedly mining the near-empty mempool that block +left behind. **That failure mode — full mempool, empty blocks — is a caching +and trigger problem, not a selection problem**, and wiring selection without +this fix would not have produced fuller blocks. + +### Ownership — `generate_candidate` is the only entry point + +**Callers ask `mining` for a candidate. They do not assemble one.** +`ergo_mining::generate_candidate` performs steps 1–8 below and is the sole +supported path; constructing a `CandidateBlock` field-by-field outside this +crate is a contract violation regardless of whether the result happens to be +well-formed. + +⚠ **This was violated until v0.8.0, and the violation is what made steps 3 and +4 dead code. Fixed in `3aecc7f`; the history stays because the failure was +invisible for months.** `src/main.rs` built `CandidateBlock { .. }` inline — parent, +n_bits, state root, AD proofs, its own copy of the +`max(now, parent.timestamp + 1)` rule, `transactions: vec![emission_tx]` — and +never called `generate_candidate`. So the crate's entry point had no production +caller, and `select_transactions` and `build_fee_tx` had none either: the path +that would have called them was not the path that ran. Mined blocks carried the +emission transaction alone and miners collected no fees. + +The lesson generalises past mining: **a crate function with no caller outside +its own tests is not "not yet wired", it is a second implementation waiting to +diverge.** Here the timestamp rule already existed twice. + +`generate_candidate` therefore needs everything steps 3–4 require, passed in +rather than reached for — the caller owns the mempool and the UTXO set, the +crate owns the assembly: + +- the candidate transactions, prioritised (`mempool.all_prioritized()`), each + with its serialized size +- the **active** `Parameters` — `stateContext.currentParameters` in JVM terms, + not `boundary_params`, which is the epoch-boundary *proposal* and is a + different input +- **ancestor headers**, newest first, for the upcoming-block `ErgoStateContext` +- a UTXO lookup, for validating each candidate against accumulated state + +⚠ **The ancestor headers are not optional padding.** Selection validates each +transaction against a context built by `build_state_context(stub, +preceding_headers, parameters)`. Built from the parent alone that context +exposes a **one-header** `CONTEXT.headers` window, where block validation +exposes up to ten. A script reading `headers[5]` would then fail *during +selection* and the transaction would be evicted from the mempool as invalid — +a valid transaction destroyed by a selection-only artefact. Pass +`chain.headers_from(parent.height - 9, 10)` reversed, minus the parent; the +parent is prepended internally. + +`generate_candidate` returns `GeneratedCandidate { block, work, invalid_txs }`. +⚠ **`invalid_txs` MUST be routed to the mempool for eviction** or Step 3.6 is +silently lost — the crate identifies unusable transactions and has no way to +remove them itself. + ### Overview ``` @@ -488,17 +593,83 @@ protocol limits. 3. Get prioritized transactions from mempool: `mempool.all_prioritized()`. +`select_transactions` implements this sequence and is bounded by both limits. +See "Ownership" above for why it spent v0.8.0 development with no caller. + 4. For each candidate transaction (in priority order): - a. Check cumulative cost: if adding this tx exceeds `max_block_cost`, - skip. - b. Check cumulative size: if adding this tx exceeds `max_block_size`, - skip. - c. Validate the transaction against the "accumulated state" — the UTXO + a. Check cumulative size: if adding this tx exceeds `max_block_size`, + skip. Size is known from the serialized bytes without validating. + b. Validate the transaction against the "accumulated state" — the UTXO set augmented with outputs from the emission tx and already-selected transactions. Use `validate_single_transaction()` with the upcoming - state context. - d. If validation fails: record tx ID for mempool elimination, skip. - e. If valid: add to selected set, accumulate cost and size. + state context. **It returns the transaction's cost** — that return + value is the only source of the number step (d) needs. + c. If validation fails: record tx ID for mempool elimination, skip. + d. Check cumulative cost: if `accumulated_cost + tx_cost` would exceed + `max_block_cost`, skip **this** transaction — do not stop the scan, a + later cheaper transaction may still fit. + e. Otherwise add to selected set and accumulate both cost and size. + + ⚠ **The cost check cannot precede validation**, and an earlier draft of + this list had it first. A transaction's cost is produced *by* + `validate_single_transaction`; there is nothing to check against before + that call returns. + + ⚠ **The size bound is over the serialized SECTION, not the sum of + transaction sizes.** Validators enforce `max_block_size` against the + BlockTransactions section, which is + `[header_id: 32B][ver_or_count: VLQ][tx_count: VLQ if ver>1][txs…]` — + **37 bytes** for a current-version block under 128 transactions, 33 for v1. + + The middle field is not a version byte. The JVM writes + `putUInt(MaxTransactionsInBlock + blockVersion)`, i.e. a VLQ of ~10,000,00X, + which is **4 bytes**, and the sentinel is what tells a parser this is a + versioned section rather than a v1 one whose first field is the transaction + count. So the overhead is `32 + 4 + vlq_len(tx_count)`, and a "1-byte + version" reading of the layout undercounts by three. A candidate landing within that margin + of the limit is over it by the rule that actually decides validity. + + The JVM generator sums transaction sizes and carries the same undercount; + we do not copy it. **This is not a safety margin** — the overhead is + deterministic and computable, so counting it is accuracy, not headroom, and + the no-`safeGap` decision above is untouched. + + ⚠ **An UNRESOLVABLE input is skipped, not evicted — and that applies to + data inputs too.** A box that cannot be resolved is not evidence the + transaction is invalid; it may be a reorg race, a fastsync gap, or the + at-tip storage swap window, and the box may resolve on the next rebuild 15 + seconds later. Evicting there removes a valid transaction from the mempool + for the invalidation TTL. + + This must hold for **both** input kinds. Resolving data inputs with a + filter that silently drops misses produces a short `data_boxes`, which + `TransactionContext::new` rejects deterministically as + `DataInputBoxNotFound` — so the transaction is reported *invalid* rather + than *skipped*, and the caller evicts it. The truncation is caught, so + nothing is validated against a mismatched context; the damage is the wrong + verdict, not a wrong evaluation. + + ⚠ **A double-spend loser is skipped, not evicted.** Two mempool + transactions spending the same box both validate in isolation — validation + sees one transaction at a time and the box is unspent on chain — so + selection must reject the second against the accumulated block, before + script evaluation (the JVM's ordering, to save time). The JVM *evicts* the + loser; we skip it and leave it in the pool, because conflict resolution is + the mempool's job and a candidate is a poor place to adjudicate it. The + transaction may well be the winner next block. + + ⚠ **Bound at `accumulated + tx_cost <= max_block_cost`, with NO safety + margin.** The JVM subtracts a `safeGap` first — 0 below a 1M limit, + 150,000 below 5M, 500,000 above (`CandidateGenerator.scala:585`) — + because, in its own words, "different interpreter version can estimate + cost differently due to bugs in AOT costing". That guards against *its + own* historical costing divergence. Ours were closed and are graded by + SANTA, so we bound exactly, and the comparator matches our validator's: + `validation/src/tx_validation.rs` rejects on `total > max_cost`, and the + JVM's own validation accepts on `maxCost >= startCost` — the same + semantics. **Do not reintroduce a gap by citing the JVM generator**; its + strict `<` at `CandidateGenerator.scala:831` is generator conservatism, + not a consensus rule. 5. Stop when limits are reached or all mempool transactions are checked. @@ -509,6 +680,49 @@ transaction and fee transaction (zero-fee). This is valid. ### Step 4: Fee Transaction +⚠ **Cap aggregated fee tokens by serialized BOX SIZE, not by token count.** + +`MaxAssetsPerBox` (255) is **not** the binding limit and capping there does not +collect the fees. The consensus rule is `txBoxSize` — +`out.bytes.length <= MaxBoxSize` (4096), `ErgoTransaction.scala`. Measured at +`reward_delay = 720`: a miner reward box holds **121** minimal tokens at +**4087 bytes**; 122 is over. A 255-token box is **8509** bytes and can never +validate, so a count cap only moves the failure from "box will not build" to +"box will not validate": the block still ships collecting **zero** fees. + +⚠ **Measure `out.bytes`, not the candidate.** `txBoxSize` applies to the +serialized `ErgoBox`, which carries 32 bytes of transaction id plus the output +index on top of the `ErgoBoxCandidate` body — 33 bytes that an +estimate-from-the-candidate misses. Earlier drafts of this section said 122 and +8476 for exactly that reason. Any implementation must grow the real box and +measure it rather than assuming a per-token width. + +The JVM caps by the same wrong constant — `flatMap(_.additionalTokens).take(MaxAssetsPerBox)`, +unsorted — and loses the same fees. **We diverge deliberately.** Which fee +boxes a miner collects is miner policy, not consensus: the rule constrains the +box it produces, not the choice of what to put in it, and uncollected fee boxes +simply remain spendable later. So capping by size is legal, strictly better for +the miner, and not a parity break in any sense that matters. + +⚠ **The dust rule cannot bind here, and a guard for it would only misfire.** +`value >= bytes * minValuePerByte` is already satisfied by construction: every +fee box collected has cleared that rule while carrying the same tokens under +the *larger* fee-proposition tree (105 bytes against the reward script's 54), +values add across the collected boxes while the box overhead is paid once, and +duplicate token ids collapse to a single entry. The reward box is strictly +cheaper per byte than the boxes that fed it. A guard would also have to +hardcode `minValuePerByte` rather than read the block's voted value, which +`build_fee_tx` is not given — so it could only ever fire wrongly and burn +tokens that would have validated. Recorded because it looks like an obvious +second check to add. + +⚠ **The surviving tokens must be selected deterministically**, in a stable +traversal order — first-seen across the fee boxes, never hash-map iteration +order. The fee box's bytes determine its box id, which determines the +transaction id, which feeds the transactions root the miner hashes. A +seed-dependent order makes two runs over identical input produce different +work. This bit once, via `HashMap` aggregation. + Collect fees from all selected transactions and create a single fee output for the miner. @@ -654,7 +868,35 @@ Convert the candidate into the data miners need. 3. **Hash:** `msg = Blake2b256(serialized_header_without_pow)`. -4. **Target:** `b = decode_compact_bits(candidate.n_bits)`. +4. **Target:** `b = enr_chain::pow_target(candidate.n_bits)`. + + ⚠ **Not `decode_compact_bits(candidate.n_bits)` — that is the difficulty.** + The target is `q / difficulty` where `q` is the secp256k1 group order, and + `pow_target` in `facts/chain.md` § "Phase 2" is the single definition of it. + This step said `decode_compact_bits` and the implementation faithfully + matched it, so the serve path advertised a target tens of orders of + magnitude harder than the one `check_pow` actually enforces. Zero shares + submitted, at any hashrate. + + ⚠ **This is not a v0.8.0 regression. It dates to `5b65e49`, the commit that + completed this crate, and is present in every tagged release from `v0.1.0` + through `v0.7.11` — 42 of them.** External mining has never worked in a + released build of this node. It was caught on the `release/v0.8.0` branch, + before that tag existed, only because an operator pointed a real GPU at it. + + Nothing that came before could have caught it. "Verified at runtime against + mainnet tip" meant candidate *assembly* was correct — well-formed, JVM-shaped, + byte-identical `msg`. Even the two earlier JVM-compat serve fixes (`b` as a + bare number, `proof` omitted when empty) were about making a real miner + **parse** the candidate; once it parsed, it mined against an impossible bound + and the only symptom was silence. **A miner that receives, parses and accepts + your candidate and then reports nothing is not idle — check `b`.** + + The two numbers are not close enough to be confused at a glance: at testnet + height 485,897 the difficulty was `3912040448` (10 digits) and the target + `29598898778389163379010897437604384363675568080188445020547283242588` + (68 digits). Anything under ~20 digits appearing in `b` is a bug, not a + low-difficulty epoch. 5. **Assemble WorkMessage:** ```rust @@ -726,7 +968,12 @@ The candidate is cached and served to multiple miner polls: 5. **Verify PoW:** `AutolykosPowScheme::validate(header)`. - Compute `hit = pow_hit(header)` using the Autolykos v2 algorithm - - Verify `hit < target` where `target = decode_compact_bits(n_bits)` + - Verify `hit < target` where `target = enr_chain::pow_target(n_bits)`, + i.e. `q / decode_compact_bits(n_bits)` — the same value served as + `WorkMessage.b`. Serving one number and validating against another is + precisely the defect fixed in v0.8.0 (present since v0.1.0 — see + § "Candidate Assembly" step 4); these two must not be allowed to drift + apart again. - On failure (all candidates tried): return 400 "invalid PoW solution" 6. **Assemble full block:** @@ -1029,14 +1276,46 @@ return 503 with `"reason": "mining not configured"`. voting bytes are included. Epoch boundary produces parameter updates. 12. **JVM compatibility:** Generate a candidate from the same chain state - as a JVM node. Compare `WorkMessage.msg` byte-for-byte. This is the - ultimate correctness test — if `msg` matches, the header serialization - is correct. - -13. **Digest mode rejection:** Start node in digest mode, verify + as a JVM node. Compare `WorkMessage.msg` byte-for-byte — if `msg` + matches, the header serialization is correct. + + ⚠ **`msg` matching proves header serialization and nothing else.** This + item claimed to be "the ultimate correctness test" and it is not: a + candidate can have a byte-perfect `msg` and still be unmineable, which is + exactly what shipped in every release from v0.1.0 to v0.7.11. Compare + **every** served field against the JVM's, `b` included. + +13. **Target vector (regression):** Assert the served `b` against a fixed + known-good pair captured from a Scala node, not against our own formula — + a test that recomputes `pow_target` the way the implementation does will + pass on any consistent-but-wrong definition. + + ``` + n_bits 83945773 (0x0500e92d) + difficulty 3912040448 + b 29598898778389163379010897437604384363675568080188445020547283242588 + ``` + + Captured at testnet height 485,897. The `difficulty` and `b` values are + observed — `b` was cross-checked digit for digit against a Scala node + serving the same height, remainder `1309294913`. **`n_bits` is derived**, + by canonical compact-bits encoding of the observed difficulty, and was not + read off the wire. So assert the round-trip + `decode_compact_bits(83945773) == 3912040448` **first**: if the derivation + is wrong, that fails loudly instead of the target assertion quietly + testing some other difficulty. The pre-fix code served `3912040448` as + `b`. + + The three pre-existing tests over `b` asserted that it was non-empty + (`mine_blocks.rs`), stable across polls (`candidate_generator.rs`), and + emitted as a bare JSON number rather than a quoted string (`types.rs`). + All three passed throughout. **A field can be present, consistent and + well-typed while being the wrong quantity** — assert the value. + +14. **Digest mode rejection:** Start node in digest mode, verify `/mining/candidate` returns 503. -14. **No miner PK:** Start with empty `miner_pk` config, verify 503. +15. **No miner PK:** Start with empty `miner_pk` config, verify 503. -15. **Prover rollback:** After `proofs_for_transactions()`, verify the +16. **Prover rollback:** After `proofs_for_transactions()`, verify the validator's prover digest is identical to before the call. diff --git a/facts/openapi.yaml b/facts/openapi.yaml index 2f1da1a..b5972f2 100644 --- a/facts/openapi.yaml +++ b/facts/openapi.yaml @@ -1,7 +1,10 @@ openapi: 3.1.0 info: title: Ergo Node Rust REST API - version: 0.6.5 + # Tracks the node version. Bump with the workspace version at release — + # it sat at 0.6.5 through six releases because nothing tied the two + # together. Moves with Cargo.toml, the addons, and their lockfiles. + version: 0.8.0 description: | REST surface exposed by the `api/` crate. Most endpoints aim for byte- or shape-compatibility with the JVM reference node (`ergoplatform/ergo`) so @@ -1563,6 +1566,7 @@ components: - unconfirmedCount - isMining - currentTime + - launchTime - journalEventsVersion properties: name: @@ -1612,12 +1616,36 @@ components: type: integer format: int64 description: Local clock in Unix epoch milliseconds. + launchTime: + type: integer + format: int64 + description: | + Unix epoch milliseconds at which this process began serving. + Constant for the process lifetime; `currentTime - launchTime` + is uptime. Present for JVM parity. + maxPeerHeight: + type: integer + format: int64 + minimum: 0 + description: | + Highest header height announced by any peer in a SyncInfo message + since process start. Monotonic high-water mark — never decreases, + including on peer disconnect or reorg. + + OMITTED (not zero) until the first SyncInfo is parsed: a zero would + assert the network is at height 0. Consumers must treat absence as + unknown. + + Peer-supplied and unverified — advisory display data only + (`headersHeight / maxPeerHeight` = sync progress). Never an input + to consensus, validation, or storage decisions. journalEventsVersion: type: string description: | Version of the journal-events contract this build emits — see - `facts/journal-events.md`. - example: '1.0' + `facts/journal-events.md`. Major 2 as of v0.8.0; consumers pinned + to major 1 refuse to parse. + example: '2.0' statsVersion: type: string description: | @@ -2130,8 +2158,26 @@ components: $ref: '#/components/schemas/HexString' description: Pre-image bytes the miner hashes against. b: - type: string - description: Difficulty target as decimal big-int string. + type: number + description: | + Autolykos mining target: `q / decode_compact_bits(nBits)`, where + `q` is the secp256k1 group order. A solution is valid when its + `pow_hit` is strictly below this. **Not the difficulty** — that is + the decoded `nBits`, and serving it here instead is the defect + fixed in v0.8.0 that left external miners unable to find anything. + Present in every release from v0.1.0 through v0.7.11 — see + `facts/mining.md` § "Candidate Assembly" step 4. + + Emitted as a **bare JSON number**, matching JVM's + `ExternalCandidateBlock` — not a quoted string, which is what this + schema claimed until it was corrected in v0.8.0, while the wire has + always sent a number. A client generated from the old spec did not + merely lose precision, it failed to parse. + + Ranges to ~2^256 (typically ~68 decimal digits), so it exceeds + IEEE-754 double precision. Consumers must parse it with an + arbitrary-precision integer type, reading the raw token rather + than going through a native JSON number. h: type: integer format: int64 @@ -2141,7 +2187,19 @@ components: description: Miner public key (hex-encoded). proof: type: object - description: Optional sigma-proof fields when the candidate requires them. + description: | + `ProofOfUpcomingTransactions` — `msgPreimage` (the serialized + header-without-PoW, hex) plus `txProofs` (Merkle membership proofs + for mandatory transactions), letting a miner verify it is working + on a real candidate. Nothing to do with sigma proofs, which is what + this description claimed until it was corrected in v0.8.0. + + Omitted entirely today: the basic candidate carries only the + emission transaction, so there are no mandatory-tx proofs, and the + JVM encoder drops a `None` proof rather than emitting it. Omission + is also load-bearing — a nested proof object overflows the + reference Autolykos2 miner's fixed jsmn token buffer and it stops + mining. SolutionSubmission: type: object @@ -2241,16 +2299,122 @@ components: ComponentMemory: type: object - required: [chainHeaderEstimateBytes, chainHeaderCount, mempoolTxCount] + description: | + Per-component memory attribution. Every `*Bytes` field is a FORMULA + over a live count or capacity, never a measurement — ground truth for + "where did the memory go" is a heap profile, not this endpoint. + + Each formula is computed by the crate that owns the structure it + models and passed to the API layer. It is never computed in the API + layer from a constant describing another crate's internals: the + removed `chainHeaderEstimateBytes` sized a `Vec
` that `chain/` + retired, and reported 1.48 GB for a structure that no longer existed. + + Headers themselves are NOT resident — they have been served lazily + from storage since the Phase 3 retirement of the header Vec. Do not + reintroduce a field claiming to size resident headers. + required: + - chainIndexBytes + - chainHeaderCacheBytes + - chainScoreCacheBytes + - chainHeaderCount + - mempoolTxCount properties: - chainHeaderEstimateBytes: + chainIndexBytes: + type: integer + format: int64 + minimum: 0 + description: | + `HeaderChain::by_id` (BlockId → height). **Unbounded** — grows with + chain length for the life of the node and is never evicted. The + one chain structure whose growth an operator must plan for. + chainHeaderCacheBytes: + type: integer + format: int64 + minimum: 0 + description: | + `LazyHeaderStore` header LRU, current occupancy. Bounded by the + configured cache capacity. + chainScoreCacheBytes: type: integer format: int64 minimum: 0 + description: | + `LazyHeaderStore` cumulative-score LRU, current occupancy. Bounded + by the configured cache capacity. chainHeaderCount: type: integer format: int64 minimum: 0 + description: Chain length. A count, not an estimate. + storeCacheBytes: + type: integer + format: int64 + minimum: 0 + description: | + `modifiers.redb` page cache occupancy, from redb's own + `cache_stats()`. OMITTED — never zero — when unavailable; a zero + would assert the cache is empty. + + Historically the largest single consumer during cold sync and + entirely invisible here: it took redb's unconfigured 1 GiB default + until the cache budget landed. + stateCacheBytes: + type: integer + format: int64 + minimum: 0 + description: | + `state.redb` page cache occupancy. OMITTED, never zero, when + unavailable. + storeCacheEvictions: + type: integer + format: int64 + minimum: 0 + description: | + Cumulative `modifiers.redb` cache evictions. A rising count is the + signal that the cache is undersized — otherwise indistinguishable + from one that is comfortably large. OMITTED when unavailable. + proverModifiedNodesBytes: + type: integer + format: int64 + minimum: 0 + description: | + AVL prover pending modified-node buffer, cleared on flush. OMITTED + — never zero — until the owning crate has published a figure; a + zero here asserts an empty buffer, which is a different claim from + an unmeasured one. + + PUBLISHED, not read on demand: the prover lives inside the UTXO + validator, which `sync/` owns and does not share, so no request + path can reach it. UTXO mode only — digest mode has no prover and + omits all three. + proverResidentNodesBytes: + type: integer + format: int64 + minimum: 0 + description: | + AVL prover tree nodes held resident between blocks. Same publish + and omission rules as `proverModifiedNodesBytes`. + syncWindowBytes: + type: integer + format: int64 + minimum: 0 + description: | + `DeliveryTracker` bookkeeping in `sync/` — an id-keyed pending map + and evicted vec, bounded by the 192-block window. Same publish and + omission rules. + + Expect tens of kilobytes, not megabytes. `sync/` holds no section + payloads: the P2P pipeline writes bytes to the modifier store and + notifies sync with ids only, so downloaded-but-unapplied bytes are + already counted in `storeCacheBytes`. A large figure here means the + tracker is leaking entries, not that the node is buffering blocks. + + Added with the two prover fields on 2026-08-14: a node that had + caught up ~27k blocks held 1356 MB of live heap — 87% — that no + crate could name, while a node at the same tip that had not synced + held 214 MB unattributed. These three exist to close that gap; this + one closed it by exoneration. mempoolTxCount: type: integer format: int64 diff --git a/facts/p2p-node.md b/facts/p2p-node.md index e75fb2e..2d2205a 100644 --- a/facts/p2p-node.md +++ b/facts/p2p-node.md @@ -1,5 +1,39 @@ # P2P Node API Contract +## Module: `config::Config` + +### `from_toml_str(toml: &str) -> Result` + +Parse a `Config` from TOML **already in memory**. + +- **Precondition**: none. The caller owns where the text came from. +- **Postcondition**: identical to what `load()` produces for a file with the + same contents. There is exactly one parse, and this is it. + +### `load(path: &str) -> Result` + +Unchanged signature and behaviour. Now defined as +`from_toml_str(read_to_string(path)?)` rather than parsing independently, so the +two entry points cannot drift. + +⚠ **Why this exists.** `load()` reading the file itself was the only way in, and +it made the file on disk the unit of configuration. The node is moving to a +layered `/etc/ergo-node/conf.d/` (see `facts/config.md`): several files are +merged into one effective config, and **no single file on disk contains it**. +A path-only entry point cannot express that. + +The node parses the same configuration twice — once into its own `RootConfig`, +once into this `Config` for `[proxy]`, `[listen.*]`, `[outbound]` and +`[identity]`. Once the first parse consumes a merged document and the second +still reads one file, the two disagree about what the node was configured with, +and the p2p half silently ignores every `conf.d` layer. **That is the whole +point of this addition** — it is not a convenience overload. + +Do not solve it on the caller's side by serialising the merged config to a +temporary file and passing its path. That is precisely the adapter the +Interface Integrity rule in `CLAUDE.md` forbids: the mismatch is that this API +names a file where it means a document, and the fix belongs here. + ## Module: `node::P2pNode` The handle to a running P2P layer. Created by `P2pNode::start()`. The P2P layer runs as background tokio tasks — the caller owns the runtime. diff --git a/facts/state.md b/facts/state.md index b2852ec..acc16ea 100644 --- a/facts/state.md +++ b/facts/state.md @@ -232,6 +232,19 @@ block_height to `0`. **Postconditions on Err:** - Storage is unchanged +⚠ **Invariant — the returned `NodeId` must be a fresh allocation.** Step 6 +deserializes the root out of storage bytes (`tree.unpack`). It must never hand +back a cached live `Rc`, however tempting a node-level cache looks on the +short-circuit path. The prover's `modified_nodes` map is keyed by node +*address*, and `on_node_visit` inserts every **visited** node rather than only +modified ones, so a recycled handle is restored into a map that still holds its +stale entry; `pack_tree` then expands nodes it should have labelled and the +prover emits **a different proof for identical tree state** — upstream measured +740 vs 735 bytes at the same digest. Caching the packed *bytes* is safe and +carries none of this. This is the reason `rollback()` may not return a handle it +already holds. Cross-reference: `facts/validation.md` § "Err leaves the prover +clean". + ### `version()` — current ADDigest Returns `None` if no updates have been applied (empty storage). @@ -292,6 +305,67 @@ the canonical height is `block_height()`. Returns an iterator over ADDigests that `rollback()` can restore to. Ordered newest-first. Length bounded by `keep_versions`. +## Page cache observability (added 2026-08-12) + +### `RedbAVLStorage::cache_bytes_used(&self) -> u64` + +`Database::cache_stats().used_bytes()` for `state.redb`, surfaced by +`GET /debug/memory` as `stateCacheBytes`. + +Requires redb's `cache_metrics` feature, declared in this crate's `Cargo.toml` +as well as the workspace root — a root-only declaration is not unified into a +standalone `cargo test -p enr-state` build, and the accessor would silently +return 0. + +### `SnapshotReader::resolver(&self) -> Resolver` and `root_state(&self) -> Option<(Digest32, usize)>` + +The same two accessors `RedbAVLStorage` already exposes, reachable from a +reader. Together they are everything needed to build a **read-only prover** over +the committed tree. + +**Why they are needed.** Mining assembles a candidate and must compute AD proofs +and a state root for it. `UtxoValidator::compute_proofs` already does this +without touching the live prover — it loads the stored root into a fresh tree +with its own resolver, deliberately, so that mining cannot disturb validation. +But it is a method on the validator, and the validator is owned by `sync/` and +is `!Sync`; the mining task cannot reach it. A second `RedbAVLStorage` on the +same file is not an option either, because redb holds an exclusive file lock — +which is why `SnapshotReader` exists. + +So the requirement is not validator access. It is read-only storage access from +a handle mining already holds. + +**Invariants:** + +- Both are read-only. Neither may mutate the tree, the version chain, or any + metadata. +- `resolver()` returns a resolver over the same `Arc` the reader + holds, so nodes it resolves are fresh `Rc`s independent of any other prover. + ⚠ This is load-bearing: sharing node handles with the validator's prover is + the wrong-proof hazard recorded under `rollback()` above. +- `root_state()` returns the committed root, i.e. what a reader sees — never + uncommitted in-memory state. +- A reader that exists has a database, so neither call needs a liveness check. + +### `SnapshotReader::cache_bytes_used(&self) -> u64` + +Same figure, reachable from a reader rather than the storage. + +Required because the API's only handle on state is `SwappableReader → +Arc`; `RedbAVLStorage` itself is moved into the validator at +startup and is not reachable from `ergo_api::UtxoAccess`. `SnapshotReader` +already holds the same `Arc`, so this is the same `cache_stats()` +call from the type the API can actually see. + +Returns the live figure even mid-swap: a reader that exists has a database. +When no reader exists at all (`SwappableReader::current()` is `None`), the +adapter reports `None` and the field is omitted — consistent with every other +lookup through that reader returning `None` during the reopen window. + +`open()` already takes a `CacheSize` and needs no signature change; only the +value the caller passes changes, since `cache_mb` now describes a total shared +with `modifiers.redb` rather than this database alone. + ## Struct: `RedbAVLStorage` ```rust @@ -363,6 +437,54 @@ impl RedbAVLStorage { ## Resolver Strategy +### ⚠ The prover holds the ENTIRE UTXO tree in RAM (corrected 2026-08-15) + +**An earlier revision of this section said reads grow the resident tree +permanently, and named that as the cause of a node's unattributed heap. That was +wrong and is retracted.** The mechanism it described is real in the fork; it +simply does not fire on the path that matters, and it was never the explanation +for the memory. + +What is actually true: **the prover's tree is 100% resident, by construction.** +A UTXO node starting from genesis (`src/main.rs`) builds an in-memory `AVLTree` +and inserts the genesis boxes; every node since is created by insertion and +stays reachable from `tree.root`. No `Node::LabelOnly` can enter that tree — the +only constructors are `AVLTree::unpack`'s two children and the resolver's miss +path, both reachable only from `AVLTree::resolve`, which is a no-op unless the +child is *already* `LabelOnly`. The set starts empty and is closed under every +operation, so it stays empty. Measured over a 689k-block genesis sync: zero +resolver misses, zero rollbacks. + +So `resolve` having no inverse is true and irrelevant here. There is nothing to +evict because nothing was ever resolved — the tree is in RAM because it was +*put* there. + +**Both starting states converge on the same ceiling, the whole tree:** + +- *Genesis sync* — starts at the ceiling, 100% resident from block 1. +- *Resumed or rolled-back node* — `restore_root` drops to three nodes, then + resolution ratchets back up toward the same ceiling and never comes down. + +**`proverResidentNodesBytes` therefore measures the UTXO set, not a leak.** +`node_count` is exactly `2 × boxes − 1`: always odd, and every delta even. It +falls when the UTXO set contracts — confirmed against consensus data the node +cannot influence, since the last byte of `stateRoot` is the AVL height, and that +height fell 20 → 16 between h=205,440 and h=247,000, exactly where the gauge +dropped 54.5 MB → 5.3 MB. An AVL height only falls on deletion, and height 16 +caps the tree at 65,536 leaves. + +Cost: roughly **500 B of RAM per UTXO box** carrying ~86 B of box data — 160 B +per node (`RcBox` + `Node`), two nodes per box, plus keys. At h=688k that is +2.26 M nodes / 568 MB, about 28% of live heap. It grows with the UTXO set, which +grows with chain history. Bounded, and not reassuring. + +⚠ **Do not "fix" this by releasing subtrees after `update_internal` commits.** +That is not a leak fix; it is adding eviction that has never existed. The fork +has no inverse of `resolve`, so it would have to be written, and every +subsequent touch would pay a redb read plus `unpack` and re-materialise the same +nodes. It is a throughput-for-memory design decision with a real cost, not a +defect repair — and it must be measured, not assumed. + ### The problem `ergo_avltree_rust` defines `Resolver = fn(&Digest32) -> Node` — a bare @@ -882,6 +1004,54 @@ the state crate's contract enables it through three guarantees: (versions live in the persisted UNDO_TABLE, not in `RedbAVLStorage` in-memory state). A reopen does not invalidate `rollback_versions`. +### ⚠ An in-place resize moves only 90% of the budget (found 2026-08-12) + +`Builder::set_cache_size(n)` does **not** set one cache. It splits the budget +(`patches/redb/src/db.rs:1177`): + +```rust +self.read_cache_size_bytes = bytes / 10 * 9; // 90% +self.write_cache_size_bytes = bytes / 10; // 10% +``` + +The in-place path (`resize_cache` → `Database::set_read_cache_limit`) reaches +**only the read half**. `max_write_buffer_bytes` is fixed at `open()` and there +is no setter — redb carries a `TODO: allow dynamic expansion of the read/write +cache` immediately above `set_cache_size`. + +Consequences, both real: + +- After an at-tip resize, the process still holds a write buffer sized from the + **original** cold-sync `cache_mb`, not from `synced_cache_mb`. An operator + setting `synced_cache_mb = 128` gets roughly 115 MB of read cache plus 10% of + whatever the cold-sync total was — not 128 MB. +- `stateCacheBytes` on `/debug/memory` is `read_cache_bytes + + write_buffer_bytes`, so after a resize it reports a figure that the current + limit does not bound. Read it as "occupancy", never as "within the configured + ceiling". + +A full drop-and-reopen (guarantee 1 above) *does* move both halves, because the +new `open()` re-runs `set_cache_size`. Only the in-place path is partial. + +Not fixed here: bounding the write half at runtime needs a redb patch, which is +out of scope for the cache-budget work. Documented so the budget arithmetic and +the endpoint are both read correctly. + +**The read half does bind, and it binds warm** (measured 2026-08-13 by an +external operator, `synced_cache_mb = 128` at `cache_store_pct = 50`, so a 64 MB +read limit). The node was left down two hours to accumulate a real backlog, so +the cache was hot rather than freshly opened when the resize fired: peak 577.2 MB +at 22:45:30, `cache resized in-place cache_bytes=67108864` at 22:45:40, 31.0 MB +by 22:49:06 — 577 MB released in roughly ten seconds — and 63.9 MB twelve hours +later, holding at the limit rather than climbing back through it. Worth recording +because a limit that is merely *set* on a warm cache is not evidence it is +*enforced*; this is the enforcement, over a long enough window to see drift. + +Steady-state at tip from the same run: 723 MB allocated, 786 MB `rssAnon`, +254 MB store cache, 0 evictions. Read `retained` (1673 MB there) as virtual and +ignore it. The number that sizes an operator profile is the **cold-sync** peak, +which runs more than double this — at-tip footprint is not the constraint. + The integrator side: main repo holds a `SwappableReader` (a `parking_lot::RwLock>>`) shared with mempool, REST API, and the snapshot dump trigger. To reopen: diff --git a/facts/store.md b/facts/store.md index fb52d22..5affe2f 100644 --- a/facts/store.md +++ b/facts/store.md @@ -570,6 +570,61 @@ overwrites BEST_CHAIN at each height. This is the single write path for main-chain headers and keeps the invariant "main-chain is authoritative for its height slot" without a second atomic-swap API. +## Page cache (added 2026-08-12) + +### `RedbModifierStore::new(path: &Path, cache_bytes: usize) -> Result` + +`cache_bytes` sizes the redb page cache via `Builder::set_cache_size`. + +This constructor previously called bare `Database::create`, which inherits +redb's default of **1 GiB per handle** (`Builder::new()` ends with +`set_cache_size(1024*1024*1024)` — `patches/redb/src/db.rs:1143`). That +gigabyte was never chosen by anyone, was invisible to `/debug/memory`, and +heap profiling during the 2026-08-11 genesis resync put **89.3%** of +header-phase growth under `put_batch` → +`redb::tree_store::btree::BtreeMut::insert`. + +`cache_bytes` is a plain byte count, deliberately **not** `state/`'s +`CacheSize` enum: `store/` does not depend on `state/` and must not acquire +that dependency to share a type. The caller computes the split. + +### `RedbModifierStore::cache_bytes_used(&self) -> u64` + +`Database::cache_stats().used_bytes()`. + +### `RedbModifierStore::cache_evictions(&self) -> u64` + +`Database::cache_stats().evictions()`. A rising count is the signal that the +cache is undersized; without it, an undersized cache is indistinguishable from +a comfortable one. + +**Evictions are the only accessor that responds to `cache_bytes` (measured +2026-08-12).** `used_bytes()` reports the live working set, not the configured +ceiling: under an identical 2000 x 256 B load it returned 798,720 bytes at both +an 8 MiB and a 1 GiB cache, with zero evictions in each. Any `used <= N` +assertion loose enough to pass for a correctly-configured store therefore also +passes for one that ignores `cache_bytes` completely. + +Consequences: + +- A test that the budget is in force must drive enough data to exceed the small + cache and assert `evictions > 0`, with a large-cache control asserting zero. +- **`storeCacheBytes` on `/debug/memory` is not evidence that the configured + budget is being applied.** It answers "how much is cached right now", not + "is the ceiling what I set". `storeCacheEvictions` is the field to read for + the latter. + +Both require redb's `cache_metrics` feature, declared in this crate's +`Cargo.toml` as well as the workspace root — a root-only declaration is not +unified into a standalone `cargo test -p enr-store` build, and the accessors +would silently return 0. + +**`cache_bytes` is not one cache.** `set_cache_size(n)` splits it 90% read / +10% write (`patches/redb/src/db.rs:1177`), and `used_bytes()` reports the sum. +The store has no in-place resize path, so both halves are fixed at `new()` — +unlike `state/`, where an at-tip resize moves only the read half. See +`facts/state.md` § "An in-place resize moves only 90% of the budget". + ## Open-time cost `RedbModifierStore::new` must run in single-digit seconds even on a diff --git a/facts/sync.md b/facts/sync.md index 72f6bc6..6a6cf18 100644 --- a/facts/sync.md +++ b/facts/sync.md @@ -40,14 +40,10 @@ How the sync machine queries persistent storage. - Returns None if not found. Used during validation sweeps to load block sections (transactions, AD proofs, extensions) by type and ID. -#### `script_verified_height() -> Option` -- Read the persisted script_verified_height. Returns None if not set. -- Used on startup to detect the gap between script-verified and - state-applied heights after an unclean shutdown. - -#### `set_script_verified_height(height)` -- Persist the script_verified_height. Called every 100 blocks during - the sweep's drain of deferred eval results. +*(`script_verified_height()` / `set_script_verified_height()` were removed from +this trait in v0.8.0 with deferred evaluation. The metadata key they wrote +remains in `chain_meta` on existing nodes and is simply never read again — a +few stale bytes, no migration.)* #### `validated_height() -> Option` - Read the durably-recorded validated_height from @@ -59,9 +55,14 @@ How the sync machine queries persistent storage. #### `set_validated_height(height)` - Persist `validated_height` to `chain_meta` with `Durability::Immediate`. -- **Precondition**: caller MUST have called `validator.flush()` before - invoking this — see the flush ordering rule under "Cross-DB - Durability Handshake" below. +- **Precondition**: caller MUST have flushed the validator before invoking + this — see the flush ordering rule under "Cross-DB Durability Handshake" + below. Since v0.8.0 that flush is reached through + `validator.state_persistence()` — a required `BlockValidator` method, so it + is callable from sync's generic `V: BlockValidator` bound — which returns + `None` in digest mode; see + "Flushing a validator that owns no state" below for what the precondition + means there. ### `SyncChain` @@ -95,6 +96,12 @@ How the sync machine queries and updates chain state. download phase is a no-op without special-casing in the sync loop. - `store`: `SyncStore` for checking modifier existence - `validator`: `Option` for digest/UTXO-mode block validation. + Since v0.8.0 the storage-lifecycle methods live on a separate + `StatePersistence` trait reached through `state_persistence()`, so there are + two independent "absent" signals and they are **not** interchangeable: no + validator at all (light mode, below) bypasses the watermark scanner + entirely, while a validator whose `state_persistence()` is `None` (digest + mode) validates normally and merely has nothing to fsync. **`None` in `StateType::Light`** — the main crate's startup wiring branches on `state_type` and constructs no validator for light mode. The watermark scanner (`advance_state_applied_height`) is bypassed entirely when `validator` @@ -261,11 +268,12 @@ arrives that creates a better chain (higher cumulative difficulty), the pipeline 3. Executes `HeaderChain::try_reorg_deep()` to atomically swap the best chain. 4. Sends `DeliveryControl::Reorg { fork_point, old_tip, new_tip }` to the sync machine. -The sync machine responds by draining in-flight eval results, clearing its section -queue, resetting all three watermarks (`downloaded_height`, `state_applied_height`, -and `script_verified_height`) to the fork point, resetting the block validator's -state root, re-queuing sections for the new branch, and re-scanning the download -watermark. +The sync machine responds by clearing its section queue, resetting both +watermarks (`downloaded_height` and `state_applied_height`) to the fork point, +resetting the block validator's state root, re-queuing sections for the new +branch, and re-scanning the download watermark. There are no in-flight eval +results to drain — a reorg cannot arrive between a block's application and its +verification, because there is no longer any gap between them. For incomplete fork chains (parent not in store), the pipeline sends `DeliveryControl::NeedModifier` to request the missing parent header. Once it @@ -324,8 +332,10 @@ channels are consumed and re-entries are no-ops. **Sequence:** -1. `validator.flush()` to persist any in-memory write-tx state. - Failure reinstates the old validator and skips the rebuild. +1. `validator.state_persistence()` → `flush()` to persist any in-memory + write-tx state. Failure reinstates the old validator and skips the + rebuild; `None` (digest mode) proceeds — there is no write-tx state to + lose, and the reopen is a cache-size change either way. 2. `drop(validator)` — releases the AVL storage `Arc`. All other holders (mempool, REST API, mining) must release their `Arc`s in parallel for redb's exclusive @@ -362,8 +372,9 @@ can leave them on different durability horizons. On every flush point in the sync sweep loop: -1. `validator.flush()` — state.redb fsync with `Durability::Immediate`. - State is now durable at height M = `validator.validated_height()`. +1. `validator.state_persistence()` → `flush()` — state.redb fsync with + `Durability::Immediate`. State is now durable at height + M = `validator.validated_height()`. 2. `store.set_validated_height(M)` — modifiers.redb chain_meta write with `Durability::Immediate`. Records that state was durable at M. 3. `store.flush()` — modifiers.redb fsync covering section writes and @@ -375,6 +386,43 @@ below. A crash between (2) and (3) is covered by (2)'s Immediate commit; only ancillary modifier writes get rolled back, which sync re-fetches naturally. +### Flushing a validator that owns no state (added v0.8.0) + +`state_persistence()` returns `None` in digest mode, because +`DigestValidator` owns no redb and has nothing to fsync. That is **not** a +flush failure and must not be treated as one. + +Three distinct cases, and the middle one is the new spelling of what used to +be a defaulted `Ok(())`: + +| Case | Meaning | Effect on step (2)/(3) and pruning | +|---|---|---| +| `Some(p)`, `p.flush()` → `Ok` | state durable at M | proceed | +| `None` | nothing to persist | proceed — step (1) is vacuously satisfied, **and (2) still runs** | +| `Some(p)`, `p.flush()` → `Err` | durability UNKNOWN | **stop**: no `set_validated_height`, no prune | + +⚠ **`None` is NOT `FlushOutcome::NoValidator`.** That variant is light mode — +no validator at all — and it deliberately skips `set_validated_height`. Digest +mode has a validator and a real `validated_height()`, and today it reaches +`FlushOutcome::Flushed(M)` by way of the defaulted `flush` returning `Ok(())`, +so the store write happens. **It must keep happening**; reusing `NoValidator` +would silently drop it and turn a refactor into a behaviour change. + +A distinct outcome is therefore required — one that advances `last_flush` and +completes the store pair, while not claiming an fsync occurred. Digest mode +does not *resume* from this value (it rescans for the first complete block and +recovers state roots from headers — `src/main.rs`, the `StateType::Digest` +branch), so the write is bookkeeping rather than load-bearing. It is preserved +anyway, because the split is a refactor: dropping it is a separate decision +that would need its own justification, not a side effect of moving a method +between traits. + +⚠ The `None` and `Err` arms must not be collapsed. "Nothing to flush" and +"the flush failed" differ by exactly the bug this split was made to prevent — +a validator reporting success for work it never did. `sync/` already models +the shape (`FlushOutcome`, and the no-validator case in `StateType::Light`); +digest mode joins that arm rather than growing a new one. + ### Startup reconciliation Owned by the main crate; runs once before `HeaderSync::run()` is @@ -596,14 +644,17 @@ unclean shutdowns; clean shutdowns should preserve everything. In all three cases `run()` MUST flush before returning, using the same sequence as the per-flush-trigger ordering (see "Flush ordering"): -1. `validator.flush()` — state.redb fsync with `Durability::Immediate`. -2. `store.set_validated_height(M)` if (1) succeeded, where M is the - validator's reported `validated_height()` after flushing. +1. `validator.state_persistence()` → `flush()` — state.redb fsync with + `Durability::Immediate`. +2. `store.set_validated_height(M)` if (1) succeeded **or was vacuous** + (`state_persistence()` was `None`), where M is the validator's reported + `validated_height()` after flushing. 3. `store.flush()` — modifiers.redb fsync. -Failure of `validator.flush()` is logged but MUST NOT block return — -the host must be able to exit. The next startup's reconciliation -re-validates whatever gap results. +A flush *failure* is logged but MUST NOT block return — the host must be able +to exit, and the next startup's reconciliation re-validates whatever gap +results. A `None` is not a failure and is not logged as one; see "Flushing a +validator that owns no state". The structural pattern: `run_inner` owns the loop body; `run` wraps the `run_inner` call in `tokio::select!` against the shutdown receiver, @@ -778,6 +829,66 @@ JVM has no light-mode analog at the section-id level (it gates the entire download phase via `nipopowBootstrap`); our chain crate folds the gating into `required_section_ids` returning empty, which keeps the sync loop unchanged. +### Memory attribution (added 2026-08-14) + +`HeaderSync` exposes a best-effort estimate of what its in-flight structures +hold: + +```rust +/// Bytes held by in-flight delivery bookkeeping. `None` if not computable. +pub fn window_memory_estimate(&self) -> Option; + +pub struct SyncWindowEstimate { + /// `DeliveryTracker`'s pending map and evicted vec. Id-keyed + /// bookkeeping only — see below, this crate holds no payloads. + pub tracker_bytes: u64, + /// Live entries behind that figure. + pub tracker_entries: u64, +} +``` + +⚠ **An earlier revision of this contract named the first field +`buffered_section_bytes` and described it as "section payloads received and +awaiting application". No such thing exists in this crate** — that was my +assumption, not the architecture, and it is corrected here. + +**The window is cleared as a suspect for the unattributed heap.** `sync/` holds +**zero** section payload bytes and keeps no persistent download queue. +`ModifierResponse` is not a message this state machine handles: the P2P pipeline +writes bytes into the modifier store and notifies sync with **ids only**. The +sweep reads each block back out of the store, applies it, and drops it within a +single loop iteration. Downloaded-but-unapplied section bytes therefore live in +redb and are already attributed as `storeCacheBytes` — counting them here would +double-count. + +What remains is `DeliveryTracker` alone: an id-keyed pending map plus an evicted +vec, bounded by the 192-block window at roughly 64 B per entry — tens of +kilobytes. It cannot be the 1356 MB. That leaves the AVL prover as the sole +remaining suspect, which `facts/state.md` § "Resolution is one-way" then +identified. + +⚠ **Size the tracker from live entry counts, never `HashMap::capacity()`.** +`capacity()` returns items plus growth-left, and erasing only returns a slot to +growth-left when the probe sequence permits `EMPTY` over `DELETED` — which +depends on `RandomState`'s per-process seed. The identical workload reported +16640 B on one run and 8320 B on the next. Count live entries and document the +figure as a lower bound: real allocation is at most ~2.3× (power-of-two buckets, +7/8 load factor, one control byte per slot). `Vec::capacity()` is not affected +and is the honest figure there, since the allocation survives `clear()`. + +**Publish it, do not expose it.** `sync/` owns the validator and is not shared, +so no HTTP path can call this. `HeaderSync` writes the figure into a caller- +supplied `Arc` after each applied block — the same mechanism as +`shared_height` — and the main crate hands that atomic to the API. An atomic +never written must read as absent rather than zero (`facts/api.md`). + +Motivation: 1356 MB of live heap on a caught-up node is unattributed, and the +sliding window is one of the two structures that could hold it. Sizing this +either implicates the window or clears it, and both outcomes are progress. + +Compute from the actual buffers. A per-block constant times a block count is +the failure mode that produced a 1.48 GB phantom in `facts/api.md`. + ### Download queue The sync machine maintains an internal queue of `(type_id, modifier_id)` pairs @@ -919,7 +1030,7 @@ existing ingestion endpoints (see `facts/api.md`). The main node: - Receives headers and block sections, stores them via the normal store write path, and advances `downloaded_height` via the watermark scanner. - Runs the validation pipeline on delivered data concurrently, advancing - `state_applied_height` and `script_verified_height` normally. + `state_applied_height` normally. The main node does NOT supervise fastsync's peer selection, fetch strategy, or internal state. It waits for the subprocess to exit and then transitions @@ -954,78 +1065,72 @@ the mean block interval. The "far behind while running" case is rare enough that the added complexity of continuous monitoring and mode transitions isn't justified. Restart is an acceptable recovery mechanism. -## Block Assembly (state_applied_height / script_verified_height) +## Block Assembly (state_applied_height) -The sync machine tracks three watermarks: +The sync machine tracks two watermarks: - **`downloaded_height`** — highest height where all required block sections are present in the store. - **`state_applied_height`** — highest height where `apply_state()` returned Ok. External consumers (API, mempool, mining) see this height. -- **`script_verified_height`** — highest height where `evaluate_scripts()` has - completed successfully. Internal bookkeeping for rollback decisions. - Advances in-order as eval results arrive via crossbeam channel. -`downloaded_height` and `state_applied_height` are initialized from -`validator.validated_height()` on startup. `script_verified_height` is -persisted separately and loaded on startup. +**`script_verified_height` was deleted in v0.8.0**, along with deferred +evaluation itself. It tracked how far script verification trailed state +application; `apply_state` now evaluates before persisting, so `Ok` already +means the scripts passed, and a second watermark could only ever disagree with +the first. Everything hanging off it went too: the reorder buffer and its +`eval_generation` stamp, the dispatch gate and its byte/count bounds, the +failure-rollback path, the startup gap repair, and the checkpoint frontier +floor. + +*The history is kept because it is the argument against reintroducing any of +it.* The reorder buffer was drain-local until 2026-08-12, so any eval result +that overtook its predecessor was found non-contiguous and discarded — and the +channel never resends, so the frontier could never pass that hole for the life +of the process. Block 3522 (3 txs, 17 inputs, the first non-coinbase-only block +in its region) was overtaken by the trivial 3523 on a two-thread pool, and the +watermark froze there for **190,000+ blocks** while `eval_lag` read **187,711** +against `evals_in_flight` of **1** and flat memory. Scripts were still +evaluated and failures still rolled back, so it was bookkeeping rather than a +verification skip — but every consumer reading that watermark as "validated up +to here" was lied to from the first overtake onwards, which on a low-thread +host is almost immediate. + +That bug, the `handle_eval_failure` zeroing of `evals_in_flight` while its +rayon tasks still ran and still held their heap, and the unbounded backlog that +killed a 4-thread 1.8 GHz host at anon-rss 10.62 GiB, were three faces of one +root: **verification lagging application**. Removing the lag removed the class, +and it is the reason a "just make it async again for throughput" proposal +should be treated as a request to reopen all three. + +Both watermarks initialize from `validator.validated_height()` on startup. +Nothing script-related is persisted separately any more. ### Invariants -- `script_verified_height <= state_applied_height <= downloaded_height <= chain_height` -- `state_applied_height` is monotonically increasing (except on reorg or eval failure) -- Heights at or below `script_verified_height` are fully validated (state + scripts) - -### Eval dispatch - -Script evaluation is dispatched to the rayon thread pool via `rayon::spawn`. -Results are sent through `crossbeam_channel::Sender<(u32, Result<(), ValidationError>)>`. -The sync layer drains the receiver non-blocking between blocks during the -sweep, and blocking after the sweep completes. - -No backpressure. Memory per DeferredEval is ~25KB typical, ~410KB worst case. - -### At chain tip - -When `sweep_size == 1`, drain the eval channel synchronously after applying -state. No pipeline benefit for a single block during live sync. - -### Eval failure handling - -Eval failures are detected in `drain_eval_results` during the sweep loop. -Two detection points: +- `state_applied_height <= downloaded_height <= chain_height` +- `state_applied_height` is monotonically increasing (except on reorg) +- Heights at or below `state_applied_height` have had their state applied, and + their scripts either **verified** or **explicitly skipped by + `checkpoint_height` configuration**. One watermark carrying both facts, which + is what all of its consumers actually needed. -**In-loop detection:** After each non-blocking drain, the sweep compares -`state_applied_height` against its pre-drain value. If `handle_eval_failure` -reduced it during the drain, the sweep corrects `validated_to` and breaks -immediately — preventing the post-loop code from overwriting the rolled-back -watermark with the stale `validated_to`. Without this check, the sweep would -continue feeding blocks to a rolled-back validator (height mismatch) and then -clobber `state_applied_height` back to the pre-rollback value. +### Catch-up progress instrumentation -**Post-sweep detection:** The blocking drain after sweep completion can also -find failures. `handle_eval_failure` sets the correct watermarks directly; -no post-drain code overwrites them. +A periodic INFO record during catch-up: -`handle_eval_failure` sequence: -1. Drain and discard remaining channel results -2. Look up digest via `chain.header_at(failed_height - 1).state_root` -3. Call `validator.reset_to(failed_height - 1, digest)` -4. **On Ok**: reset `state_applied_height` and `script_verified_height` - to `failed_height - 1`, reset `downloaded_height` to match -5. **On Err (2026-06-12)**: the validator did NOT move — watermarks stay - exactly where they are (NO resets; retreating them onto un-rolled - state is the gap-wedge hole). Log loud (`validation_rollback_failed`) - and resume — the sweep/backoff machinery (v0.6.11) retries the stall. - Same rule for the Reorg-control path (the other `reset_to` site). -6. Log the error, resume sync +| Field | Source | +|---|---| +| `state_applied_height` | **the applied tip — `validator.validated_height()`**, NOT the struct field | +| `jemalloc_allocated` | the existing probe; the field is **omitted** when no probe is wired | -### Startup gap handling +Reduced in v0.8.0 from six fields to two. `evals_in_flight`, +`script_verified_height`, `eval_lag` and `eval_bytes_in_flight` all described a +queue that no longer exists. -On startup, if persisted `script_verified_height < state_applied_height`, -the gap is accepted — the AVL digest already proved state correctness during -`apply_state`, and proof boxes aren't available without re-running apply_state. -`script_verified_height` is advanced to match `state_applied_height`. +⚠ **Anything quoting `eval_lag` from a pre-2026-08-12 run is quoting a frozen +number** — see the watermark history above. Field reports and journals from +that era should not be used to argue about backlog depth. ### Watermark scanner diff --git a/facts/validation.md b/facts/validation.md index 1ac96b6..9cb8540 100644 --- a/facts/validation.md +++ b/facts/validation.md @@ -30,7 +30,60 @@ transaction validation: S9 P8 E6 C5 I8 A7 L8 validated height. Blocks must be validated in order. On reorg, the caller resets the validator to the fork point. -## Trait: `BlockValidator` +## Traits: `BlockValidator`, `StatePersistence`, `MiningState` + +**Split in v0.8.0.** Until then this was one trait whose last four methods +carried do-nothing default bodies, and that shape cost us a bug that ran +undetected for the life of the feature. + +`impl BlockValidator for Validator` (the enum wrapper in `src/main.rs`) is pure +delegation: every method had to forward to the active variant. When +`resize_cache` was added, the wrapper was not updated. It compiled, because the +trait supplied a default returning `Ok(())`. So the at-tip cache resize called +into the wrapper, hit the default, did nothing, returned `Ok`, and **logged +success** — `UtxoValidator::resize_cache` was correct throughout and simply +unreachable. The resize never once reached `state.redb`. Found by @odiseusme on +2026-08-12 by reading `main.rs` rather than trusting the metrics; confirmed +independently before the fix. `flush` is the same shape and far worse: a +forgotten forward there means state is never persisted while every caller is +told it was. + +The first fix considered was "declare every method, no defaults, let the +compiler catch the next wrapper that forgets." That works, but it still relies +on someone reading nine compiler errors correctly the next time a method is +added, and it costs 24 explicit no-op bodies across the workspace — 20 of them +in `sync/`'s test stubs. + +**The split removes the failure mode instead of detecting it.** The four +methods had one consumer each and did not share it: + +| Method | Consumer | +|---|---| +| `flush`, `resize_cache` | `sync/` — sweep flush points and at-tip cache tuning | +| `proofs_for_transactions`, `emission_box_id` | `main` — `Validator::update_mining_proofs` | + +So they become two traits, each implemented **only by `UtxoValidator`**. There +is no per-method forwarding left in the wrapper to forget: a method added to +either trait needs no wrapper change at all. + +One accessor does survive on `BlockValidator` — `state_persistence()`, and it +is **required, not defaulted**. `sync/` is generic over `V: BlockValidator` and +cannot name main's `Validator`, so a trait method is the only route by which it +can reach storage at all. That is a smaller surface than four defaulted methods +and a different kind of thing: it reports a capability rather than performing +work, so `None` is a truthful answer where `Ok(())` was a false claim. See +"How callers reach the split traits" below. + +⚠ **The absence of an impl is the mode signal.** `DigestValidator` does not +implement either new trait. Do not reintroduce "digest mode returns a harmless +value" anywhere — that is precisely the shape that produced the bug above. + +**This split also disambiguates two overloaded `None`s.** +`proofs_for_transactions` returned `Option>` where the outer `Option` +meant "wrong mode"; that layer is gone. `emission_box_id` returned `None` for +*either* "digest mode" *or* "all ERG emitted" — two unrelated facts wearing one +value, which `update_mining_proofs` then early-returned on identically. It now +means only **all ERG emitted**. ```rust pub trait BlockValidator { @@ -44,7 +97,7 @@ pub trait BlockValidator { /// for digest mode, None for UTXO mode /// - `extension` is the raw Extension section (type 108) /// - `preceding_headers` contains up to 10 headers before this block, - /// newest first (for ErgoStateContext in DeferredEval) + /// newest first (for ErgoStateContext in ScriptEvalInputs) /// - `active_params` is the current chain parameters /// - `expected_boundary_params` is Some iff header.height is an /// epoch boundary @@ -53,13 +106,16 @@ pub trait BlockValidator { /// - `self.validated_height()` == header.height /// - `self.current_digest()` == header.state_root /// - State transition persisted (UTXO mode) or digest updated (digest mode) - /// - `ApplyStateOutcome.deferred_eval` is Some if scripts need - /// evaluation (height > checkpoint), None otherwise + /// - The block's scripts have been evaluated and passed. `Ok` from + /// `apply_state` means exactly that; there is nothing left owed. /// - `ApplyStateOutcome.epoch_boundary_params` is Some if this was /// an epoch-boundary block with verified parameters /// /// Postconditions on Err: - /// - State is unchanged. validated_height and current_digest are unmodified. + /// - State is unchanged: validated_height, current_digest, AND THE + /// PROVER are exactly as before the call. See "Err leaves the prover + /// clean" below — this was aspirational until 2026-08-12 and the + /// digest-mismatch path violated it. /// - The error describes which check failed. fn apply_state( &mut self, @@ -70,6 +126,7 @@ pub trait BlockValidator { preceding_headers: &[Header], active_params: &Parameters, expected_boundary_params: Option<&Parameters>, + expected_proposed_update: Option<&[u8]>, ) -> Result; /// Current validated height. 0 means no blocks validated yet @@ -79,7 +136,7 @@ pub trait BlockValidator { /// Current state root digest (33 bytes: 32-byte hash + 1-byte tree height). fn current_digest(&self) -> &ADDigest; - /// Reset to a previous state. Used on reorg and deferred eval failure. + /// Reset to a previous state. Used on reorg. /// /// Preconditions: /// - `height < self.validated_height()` @@ -98,29 +155,366 @@ pub trait BlockValidator { /// always Ok. fn reset_to(&mut self, height: u32, digest: ADDigest) -> Result<(), ValidationError>; - /// Compute AD proofs for transactions without modifying state. - /// None for digest-mode validators (mining requires UTXO mode). + /// Does this validator own persistent state? `Some` hands out its storage + /// lifecycle; `None` means it owns none (digest mode). + /// + /// REQUIRED — no default body. `sync/` is generic over + /// `V: BlockValidator` (`HeaderSync`) and never names main's + /// `Validator`, so this is the only route by which a generic caller can + /// reach `StatePersistence`. `MiningState` needs no such accessor because + /// its only consumer is `main`, which does name the type. + /// + /// This method answers a capability question; it does NOT perform work. + /// That is what separates it from the defaulted no-ops it replaces — a + /// `None` return is a truthful answer, whereas `Ok(())` from a defaulted + /// `flush` was a claim that work happened. + fn state_persistence(&self) -> Option<&dyn StatePersistence>; +} + +/// Storage lifecycle. Implemented by `UtxoValidator` only — a validator that +/// owns no persistent state does not implement it, and the caller handles +/// that case explicitly rather than being handed a successful no-op. +pub trait StatePersistence { + /// Force a durable commit (fsync) of all outstanding storage writes. + /// Called at sweep flush points (bounds crash data loss) and on + /// graceful shutdown. + /// + /// Postconditions on Ok: every write issued before this call is durable. + /// Postconditions on Err: durability is UNKNOWN. The caller must not + /// advance any watermark that assumes persistence — see facts/sync.md + /// § "Flush ordering". + fn flush(&self) -> Result<(), ValidationError>; + + /// Resize the storage read cache at runtime (e.g. on reaching the tip). + /// + /// ⚠ Read cache only. `stateCacheBytes` covers read + write, so a 64 MB + /// resize gives roughly a 128 MB envelope, not 64. + fn resize_cache(&self, cache_bytes: usize) -> Result<(), ValidationError>; +} + +/// Mining support. Implemented by `UtxoValidator` only — candidate assembly +/// requires a live UTXO set, so digest mode does not implement it and +/// `main` skips mining rather than receiving a `None` that means "wrong mode". +pub trait MiningState { + /// Compute AD proofs and the resulting state root for a set of + /// transactions WITHOUT modifying persistent state. fn proofs_for_transactions(&self, txs: &[Transaction]) - -> Option, ADDigest), ValidationError>>; + -> Result<(Vec, ADDigest), ValidationError>; - /// Current emission box ID. None if digest mode or all ERG emitted. + /// Current emission box ID in the UTXO set. + /// + /// `None` means **all ERG has been emitted** — nothing else. It no + /// longer doubles as the digest-mode signal. + /// + /// ⚠ **Valid immediately after construction, not only after the first + /// applied block.** See "Recovering emission_box_id on resume" below. fn emission_box_id(&self) -> Option<[u8; 32]>; } ``` +### Recovering `emission_box_id` on resume + +`emission_box_id` is **derived state**: it is discovered by scanning a block's +insertions for an output whose `ErgoTree` matches the emission contract. That +scan runs during `apply_state`, so a freshly constructed `UtxoValidator` has +`None` until it applies a block. + +**That is a defect, and it must be recovered at construction.** `None` is not a +neutral placeholder here — it is indistinguishable from "all ERG emitted", and +the one consumer (`main`'s `update_mining_proofs`) returns early on it. A node +restarted while already at the chain tip therefore serves **no mining +candidate at all** until a peer delivers the next block. Where that node is +the only miner, it is a hard deadlock rather than a delay: no candidate, so no +block, so no application, so no candidate. Reported from the field after an +at-tip restart sat at 503 for over an hour. + +**Recover it from the tip block, not from a UTXO-set scan.** The emission box +is spent and recreated by the coinbase of every block that has one, so the +insertions of the block at the resume height contain the current emission box. +That is one block to parse. Scanning the UTXO set for a matching `ErgoTree` +would be O(millions of boxes) and is not an acceptable startup cost. + +**Reuse the existing scan; do not write a second one.** The apply-time loop +already does exactly this match. Extract it and call it from both paths — two +implementations of "which box is the emission box" is precisely the divergence +this contract exists to prevent. + +`None` after a successful recovery attempt remains correct and means what it +says: the tip block created no emission output, i.e. emission has ended. A +recovery that cannot read its input is a different case and must be logged +rather than silently leaving `None`. + +⚠ **Do not "fix" this by persisting the ID alongside the state digest.** It is +derivable from authoritative state, and a persisted copy is a second source of +truth that can disagree with the tree it describes. + +### Who implements what + +| Type | `BlockValidator` | `state_persistence()` returns | `StatePersistence` | `MiningState` | +|---|---|---|---|---| +| `UtxoValidator` | ✅ | `Some(self)` | ✅ | ✅ | +| `DigestValidator` | ✅ | `None` | — | — | +| `Validator` (enum wrapper, `src/main.rs`) | ✅ | match on variant | — | — | +| `sync/` stubs exercising flush (4) | ✅ | `Some(self)` | ✅ | — | +| `sync/` stubs that do not (2) | ✅ | `None` | — | — | + +### Prover memory attribution (added 2026-08-14) + +`StatePersistence` gains one method — it belongs there because the prover is +exactly the state the trait already governs: + +```rust +/// Resident bytes held by the AVL prover, best-effort. +/// `None` when a figure cannot be computed — never a fabricated zero. +fn prover_memory_estimate(&self) -> Option; + +pub struct ProverMemoryEstimate { + /// All THREE per-proof-cycle buffers: `modified_nodes` and both + /// `changed_nodes` Vecs. Same lifecycle, same purpose — reporting one of + /// three would be the omission this endpoint exists to prevent. + pub modified_nodes_bytes: u64, + /// Tree nodes held resident between blocks. The figure that matters. + pub resident_nodes_bytes: u64, + /// Node count behind the two figures above, for cross-checking a + /// bytes-per-node that drifts. + pub node_count: u64, +} +``` + +⚠ **The buffers are NOT cleared on flush** — an earlier revision of this +contract said so and was wrong. `StatePersistence::flush` is a redb fsync and +never touches the prover. The clear happens at the proof-cycle boundary inside +`apply_state`: `generate_proof` clears all three, `update_internal` the two +Vecs. So `modified_nodes_bytes` sampled between blocks reads its idle floor **by +construction** — it cannot show a leak, and a rising value there would mean +something is wrong with the cycle rather than with memory. Test it at the proof +cycle, not around a flush. + +⚠ **Publish on a bounded block interval, never per block.** The estimate walks +the resident tree, so it is O(resident nodes) — the same order as applying a +block at mainnet scale, and publishing it per block is a measurable sync +regression. The figure is a slow-moving gauge of a monotonically growing +structure; per-block resolution buys nothing. (`sync/`'s tracker estimate is +cheap and stays per block — the two cadences differ deliberately.) + +The interval is the main crate's `PROVER_GAUGE_INTERVAL_BLOCKS`, applied in the +post-`apply_state` hook, with `PROVER_GAUGE_MAX_INTERVAL` as a wall-clock +ceiling — whichever fires first. A block count alone is a sync-shaped rule: 512 +blocks is seconds during catch-up and roughly seventeen hours at tip, where a +gauge that updates twice a day cannot show a growth curve on exactly the node an +operator watches. The cost that motivates the block interval does not exist at +tip, so the two triggers never compete. An earlier revision said "flush cadence", which is not +implementable where the publish actually happens: `sync/` calls `flush()` +through `state_persistence()`, so the main crate's `Validator` never observes a +flush and has no hook to hang it on. Intercepting would mean `Validator` itself +implementing `StatePersistence` to wrap the delegate — a structural change to +the split traits for a diagnostic gauge, which is not a trade worth making. + +`old_top_node` is `pub(crate)` in the fork, so the previous cycle's baseline is +not reachable and is excluded. Documented rather than estimated. + +Measured motivation: a v0.8.0 node that caught up ~27k blocks holds 1356 MB of +live heap that no crate can name — 87% of the total — while a node at the same +tip that did not sync holds 214 MB unattributed. The prover is the prime +suspect and reports nothing today. + +**Compute from the real structures, do not derive from a constant.** A +bytes-per-node multiplier is precisely how `AVG_HEADER_BYTES` reported 1.48 GB +for a `Vec` that no longer existed (`facts/api.md`). If only a node count is +obtainable, return the count and omit the byte fields rather than multiplying. + +`DigestValidator` does not implement `StatePersistence` at all, so digest mode +reports absent — which is correct, it has no prover. + +### How callers reach the split traits + +**Two different mechanisms, because the two consumers differ.** + +`sync/` is generic (`HeaderSync`) and cannot name +main's `Validator`, so it reaches storage through the trait method: + +```rust +// in sync/, where V: BlockValidator +match validator.state_persistence() { + Some(p) => p.flush(), // real work, real Result + None => /* nothing to persist — see facts/sync.md */, +} +``` + +`main` names `Validator` directly and is `MiningState`'s only consumer, so +that one is an inherent accessor on the wrapper and needs no trait surface: + +```rust +impl Validator { + fn mining_state(&self) -> Option<&dyn MiningState>; +} +``` + +⚠ Do not "regularise" these into one shape. Putting `mining_state()` on +`BlockValidator` would force six `sync/` test stubs to declare a capability +they have no consumer for; making `state_persistence()` inherent would put it +out of reach of the only caller that needs it. + +Both return references borrowed from `&self`, so they cannot outlive the +caller's guard — relevant because `UtxoValidator` holds `Rc`s through the AVL +prover and the wrapper carries a hand-written `Send` assertion (`src/main.rs`, +see its SAFETY comment). Check that reasoning rather than inheriting it. + +⚠ **`E0034` hazard, found in step A.** With both `BlockValidator` and +`MiningState` in scope on a *concrete* `UtxoValidator`, +`proofs_for_transactions` resolves ambiguously and the call must be qualified +(`BlockValidator::proofs_for_transactions(&v, ..)`). Generic `V: BlockValidator` +call sites are unaffected. The ambiguity disappears in step E with the shims. + +### No behavioural delta + +The split changes no runtime behaviour. Digest mode previously returned +`Ok(())` from the defaulted `flush`/`resize_cache`; it now yields `None` from +`state_persistence()`, and **the caller treats that as the existing +"nothing to persist" arm, not as a failure** — `sync/` already models this +(`FlushOutcome`, the no-validator case). A flush that cannot happen must not +be reported as a flush that failed; nothing may be pruned or advanced on the +strength of it either way. + +## Script evaluation (deferred mode removed in v0.8.0) + +`apply_state` **always** evaluates the block's scripts itself, before +persisting. `Ok` therefore means the scripts passed. There is no mode, no +`ApplyStateOutcome::deferred_eval`, and no evaluation the caller still owes. + +⚠ **Deferred evaluation is gone and is not returning as an option.** It let +`apply_state` return before the scripts ran — buying sync throughput at the +price of a crash-consistency window and, worse, a second source of truth for +*how far this chain is actually verified*. Every bug in that machinery came +from the same root, that verification lagged application: + +- the frozen reorder-buffer watermark, which wedged a node for 190,000 blocks +- `handle_eval_failure` zeroing `evals_in_flight` while its rayon tasks were + still running and still holding their heap — the undercount that opened the + dispatch gate early +- the unbounded backlog that killed a 4-thread 1.8 GHz host at **anon-rss + 10.62 GiB** mid-catch-up +- the checkpoint frontier floor, needed only because heights at or below the + checkpoint never dispatched an eval and so could never advance a frontier + +Removing the lag removes the class. Anything that reintroduces "apply now, +verify later" reintroduces all of it. + +**Evaluation happens before persistence, not before application.** The +JVM validates scripts before touching its AVL tree, but it can afford to: it +reads each input box via `boxById` and removes it afterwards, paying two +traversals. Ours captures the box from the removal's own return value, so a +literal copy would add a read per input for no gain. + +The boundary that actually matters is **persistence**, because persistence is +what survives a crash. Everything from the first prover operation to just +before `storage.update_with_height` is in-memory only. Evaluating in that +window means no block whose **scripts** are unverified reaches `state.redb`, +which closed the startup-gap hole at the source rather than repairing it +afterwards — and then let `sync/` delete the repair machinery entirely. + +Ordering: apply operations and capture boxes → verify digest → **evaluate +scripts** → persist. The digest check stays first because it is cheap and +rejects malformed blocks before the expensive step. + +⚠ **This closes the script gap only. It does not make `apply_state` +crash-atomic.** The proof-digest consensus check still runs *after* +`update_with_height`, so a post-persist `Err` remains reachable and a crash in +that window can still leave a persisted block that failed a later check. Moving +proof generation earlier does not fix it and has already been tried: reverted +in `96a0186`, because `removed_nodes()` then returns empty and superseded nodes +stop being deleted — the orphan-growth bug that reached 235 GB. + +So the two windows are different sizes and only one of them closes. Anyone +citing "nothing unverified is persisted" should say **scripts**, or they are +overstating it. + +### Err leaves the prover clean + +**Requirement:** every `Err` from `apply_state` leaves the prover byte-for-byte +as it was on entry. Script failure, digest mismatch, box deserialization, the +persist, and the post-persist proof-digest check all have to satisfy it. + +**This already worked** and has since `2992645`. `apply_state` is a wrapper +around `apply_state_internal` that calls `rollback_prover_to` on any `Err`; +every bare `return`/`?` inside `_internal` undoes nothing on its own and does +not need to. Inline evaluation is one more `Err` arm under the same wrapper. + +*An earlier draft of this section asserted the opposite — that the +digest-mismatch path returned `Err` with the prover dirty and needed fixing. +That was wrong: it read `apply_state_internal`'s bare `return Err` and +attributed it to the public entry point. Recorded because the mistake is +repeatable — in a file with a `_internal` split, the enclosing function is part +of what a line means, and a grep result does not carry it.* + +**Why the rollback is sound before persistence**, which is the part that is +genuinely non-obvious: at the evaluation point the undo log does not run at +all. `RedbAVLStorage` sets `current_version` only after a successful commit +(`state/src/storage.rs:1002`), so it still equals the pre-block digest, and +`rollback()` takes its short-circuit branch (`storage.rs:1034-1044`) — re-read +`META_TOP_NODE_HASH` from redb, unpack, return. No write transaction, no undo +record, no version-chain mutation. It is a persisted-root re-read. + +The undo-log walk runs only for the **post-persist** proof-digest check, where +the block genuinely is the newest committed version and the walk is the correct +operation. Both paths work, for different reasons; do not collapse them. + +Precondition: `storage.current_version` must be `Some`, which +`UtxoValidator::new` already documents and guarantees. + +**`restore_root` clears `base.modified_nodes`** as of the fork rev this +workspace pins — `b955790`, in the `[patch.crates-io]` table of the root +`Cargo.toml`. Revs before it cleared the changed-node buffers and directions +and rebased `old_top_node` but left the address-keyed map `pack_tree` gates on +holding every node the failed block touched: not a correctness bug, since each +entry owns an `Rc` and a live address cannot be recycled, but an unbounded +retention that inline mode promotes from never-happens to once per hostile +block. `rollback_prover_to` carried a local `modified_nodes.clear()` for that; +it is redundant at this rev. Anyone reading an older tree should not conclude +the clear is load-bearing. + +⚠ **The upstream fix carries a precondition on `state/`.** The same defect has +a worse form that we are exempt from *by construction, not by luck*: two +provers driven to identical tree state emitting **740 vs 735 proof bytes** — +same digest, different proof. `on_node_visit` keys every **visited** node, not +only modified ones, so a storage layer whose `rollback` hands back a **live** +`NodeId` restores a root whose nodes are still keyed in the stale map, and +`pack_tree` then expands nodes it should have labelled. Both +`RedbAVLStorage::rollback` paths end at `tree.unpack` of bytes freshly copied +out of a redb read transaction (`state/src/storage.rs:1042`, `:1140`), so the +restored root is always a fresh allocation and the divergence is unreachable +here. **A node-level cache in `state/` returning live `Rc` handles from +`rollback` would make a wrong proof reachable.** Caching the *bytes* is fine; +caching the *handles* is a consensus bug. + +### Open: the block cost is discarded, and always was + +`evaluate_scripts` returns the block-accumulated transaction cost and +`apply_state` drops it. Nothing is unenforced — the `maxBlockCost` gate runs +inside `evaluate_scripts` regardless. + +*A previous draft of this section said the figure was "observable in deferred +mode and invisible in inline". That was wrong: the deferred path discarded it +too, at `sync/src/state.rs` — `evaluate_scripts(&eval).map(|_cost| ())`, with +the binding name documenting the discard. No caller has ever consumed it in +either mode, and the only cost-shaped value in the API is `max_block_cost`, +which is the parameter limit rather than what a block actually spent.* + +So this is not a regression to repair but an observable to add, and it wants a +consumer first — a `cost=` field on the sweep line, or a block-level API +field. Adding it is a field on `ApplyStateOutcome` and one assignment. + ## New Types ```rust pub struct ApplyStateOutcome { /// Some if this was an epoch-boundary block with verified parameters. pub epoch_boundary_params: Option, - /// Some if scripts need evaluation (height > checkpoint). - pub deferred_eval: Option, } -/// Everything needed to verify transaction spending proofs. -/// Owned, Send — can move to any thread. -pub struct DeferredEval { +/// Everything needed to verify transaction spending proofs. Built inside +/// `apply_state` and consumed by `evaluate_scripts` a few lines later. +pub struct ScriptEvalInputs { pub height: u32, pub transactions: Vec, pub proof_boxes: HashMap<[u8; 32], ErgoBox>, @@ -130,13 +524,158 @@ pub struct DeferredEval { } ``` +**Renamed from `DeferredEval` in v0.8.0**, along with the removal of its +`approx_heap_bytes` field and the `new()` that derived it. That weight existed +so `sync/` could bound a queue by bytes in flight rather than item count — +per-item weight varies by three orders of magnitude, since `proof_boxes` holds +every input and data-input box of the block. There is no queue now. The struct +is a plain input bundle that never leaves the stack frame that built it, so it +no longer needs to be `Send`, no longer needs to weigh itself, and no longer +carries a name describing a deferral that does not happen. + +## Free Function: proofs without a validator + +```rust +/// Compute AD proofs and the resulting state root for `txs` against a +/// committed tree, without a `UtxoValidator` and without touching any +/// live prover. +pub fn proofs_from_storage( + resolver: Resolver, + root: Option<(Digest32, usize)>, + txs: &[Transaction], +) -> Result<(Vec, ADDigest), ValidationError>; +``` + +`UtxoValidator::compute_proofs` delegates to this; the logic is unchanged and +lives in one place. + +**Why it exists.** Mining assembles a candidate and needs proofs for it, but the +validator is owned by `sync/` and is `!Sync`, so the mining task cannot reach +it. `compute_proofs` never needed the validator's live prover — it deliberately +builds a separate one from storage so mining cannot disturb validation — so the +dependency was always storage access, not validator access. `SnapshotReader` +now supplies both inputs (`facts/state.md`). + +⚠ **The resolver must be independent.** Every node it resolves must be a fresh +handle, never shared with another prover's tree. `state/` guarantees this +structurally — no caching, a fresh read transaction per resolve — and the +guarantee is load-bearing here: a prover working over nodes still keyed in +another prover's address-keyed map emits a **different proof for identical tree +state**. Same digest, different bytes. Do not add a node cache between these. + +⚠ **A missing root reports one error, not two.** The signature takes a +resolver rather than a storage handle, so the committed root is materialised by +calling the resolver rather than `get_node` + `unpack`. The unpack is +byte-identical — the resolver is that call plus a clone — but its miss path +yields `Node::LabelOnly` instead of an `Err`. `unpack` itself never produces +that variant, so it uniquely identifies the miss and is what the code gates on. +The consequence is diagnostic: the former "failed to read root node: {e}" and +"root node not found in storage" collapse into the latter. Same +`ValidationError::StateOperationFailed`; the underlying redb cause is still +logged at ERROR by `state/`, with the digest. + +⚠ **This is a read path.** It computes what proofs *would* be; it applies +nothing, persists nothing, and advances no watermark. A caller that treats its +success as evidence a block is valid has misread it. + +## Free Functions: state context + +Two builders, and **which one you call is a correctness decision, not a style +one.** + +```rust +/// Context for validating a block that HAS been mined. +/// The preheader is `header` itself. Use for block validation only. +pub fn build_state_context( + header: &Header, + preceding_headers: &[Header], + parameters: &Parameters, +) -> ErgoStateContext; + +/// Context for validating a transaction that is NOT yet in a block. +/// The preheader describes the *next* block, built on `last_header`. +/// Use for the mempool and for API-submitted transactions. +pub fn build_upcoming_state_context( + last_header: &Header, + preceding_headers: &[Header], + parameters: &Parameters, +) -> ErgoStateContext; +``` + +### Why two + +An unconfirmed transaction is not a member of the chain tip — it is a candidate +for the block *after* it. Wallets set `creationHeight` to the block they expect +to land in, so a transaction built against tip `H` carries `creationHeight = +H+1`. ergo-lib enforces `creationHeight <= preHeader.height`. Validate it +against a preheader at `H` and **every well-formed transaction on the network is +rejected**, with the reason `Creation height H+1 > preheader height`. + +This mirrors the JVM, which keeps the same split: block validation uses the +block's own header, while `ErgoMemPool` validates against +`ErgoStateContext.simplifiedUpcoming()` +(`ergo-core/.../nodeView/state/ErgoStateContext.scala:140`). + +### Field derivation for the upcoming preheader + +Mirrors `simplifiedUpcoming()` composed with `PreHeader.apply` and +`AutolykosPowScheme.derivedHeaderFields` (`PreHeader.scala:49-63`): + +| Field | Value | Note | +|---|---|---| +| `height` | `last_header.height + 1` | the whole point | +| `parent_id` | `last_header.id` | **not** `last_header.parent_id` | +| `version` | `last_header.version` | | +| `n_bits` | `last_header.n_bits` | difficulty carries over | +| `timestamp` | `last_header.timestamp + 1` | JVM's literal `+ 1`, not wall clock | +| `miner_pk` | `ec_point::generator()` | free fn in `ergo-chain-types`, not an assoc fn | +| `votes` | `Votes([0, 0, 0])` | see below — not equivalent to the JVM | + +`miner_pk` is the secp256k1 group generator rather than a real key because the +miner of the next block is unknown. A script reading `CONTEXT.preHeader.minerPk` +therefore sees a placeholder in the mempool and the real key once mined — the +same divergence the JVM has, and the reason a transaction can pass mempool +validation and still fail in a block. + +⚠ **`votes` cannot match the JVM, and the difference is observable.** The JVM +passes `Array.emptyByteArray`; `Votes` is three fixed bytes and has no empty +representation, so the upcoming preheader carries `[0, 0, 0]`. A script reading +`CONTEXT.preHeader.votes` sees a three-byte zero collection here and an empty +one on a JVM node. No known script does, and a transaction that depended on it +would fail once mined anyway — the real block carries real votes — but it is a +genuine divergence rather than an equivalent encoding, so do not record it as +parity. + +**Mining does not use this builder, deliberately.** `generate_candidate` builds +a stub header at `height + 1` with a real wall-clock timestamp and feeds that to +`build_state_context` (`facts/mining.md` § Selection). Its preheader height is +therefore already correct — which is why candidate assembly kept working while +the mempool rejected everything. The timestamp is the honest difference: a miner +knows the block's real timestamp, the mempool does not, so it uses the JVM's +`last.timestamp + 1` placeholder. Do not collapse the two paths into one. + +⚠ **Parameters are the caller's, and lag by one block at an epoch boundary.** +The JVM recomputes parameters for `height + 1` inside `simplifiedUpcoming()`. +We pass the parameters active for `last_header` instead. These differ only on +the single block where an epoch boundary is crossed, and only for +parameter-sensitive validation. Accepted as a bounded divergence; revisit if a +boundary-block mempool rejection is ever observed. + +### Invariant + +**Never validate an unconfirmed transaction against a preheader at the current +tip.** The mempool and the API validate against the upcoming context; block +validation validates against the block's own header. A caller that mixes these +is wrong even when it appears to work — the failure is silent, total, and looks +exactly like an idle network. + ## Free Function ```rust /// Verify spending proofs for all transactions in a block. /// Pure computation — no validator state needed. Uses rayon par_iter internally. /// On success returns the block-accumulated transaction cost. -pub fn evaluate_scripts(eval: &DeferredEval) -> Result; +pub fn evaluate_scripts(eval: &ScriptEvalInputs) -> Result; ``` ### Block cost semantics (added 2026-06-10) @@ -164,7 +703,7 @@ pub fn evaluate_scripts(eval: &DeferredEval) -> Result; wrap, never panic. - Degenerate cases return `Ok(0)`: empty transaction list; the height-1 no-preceding-headers guard. Blocks at or below a validator's - `checkpoint_height` never reach evaluation (no `DeferredEval` is built), + `checkpoint_height` never reach evaluation (no `ScriptEvalInputs` is built), matching the JVM's `Valid(0L)` checkpoint shortcut. - The per-tx sigma-rust JIT budget (`max_block_cost × 10` per tx, ergo-lib `tx_context.rs:202`) is unchanged — it bounds each evaluation's @@ -256,18 +795,18 @@ DigestValidator::new( - Verify `verifier.digest() == header.state_root` - On success, `current_digest` = `header.state_root` -5. **Advance state** (immediate, before script eval) - - `validated_height` = header.height - - `current_digest` = header.state_root - -6. **Build DeferredEval** (skipped below checkpoint_height) +5. **Evaluate scripts** (skipped below checkpoint_height) — BEFORE persisting - Deserialize old values from step 4 into `ErgoBox` instances - Bundle transactions, proof boxes, header, preceding headers, and - parameters into a `DeferredEval` struct - - Returned as `ApplyStateOutcome.deferred_eval` for the sync layer - to evaluate asynchronously via `evaluate_scripts()` - - `evaluate_scripts` uses rayon `par_iter` for intra-block parallelism - and returns the block-accumulated cost (see "Block cost semantics") + parameters into `ScriptEvalInputs` + - Call `evaluate_scripts()`, which uses rayon `par_iter` for intra-block + parallelism and returns the block-accumulated cost (see "Block cost + semantics"). On `Err` the block is rejected and the prover is rolled + back by the `apply_state` wrapper — nothing is persisted. + +6. **Persist, then advance state** + - `validated_height` = header.height + - `current_digest` = header.state_root ### Error causes @@ -315,14 +854,20 @@ DigestValidator::new( ### Watermarks - `state_applied_height` — AVL state advanced to here. External consumers see this. -- `script_verified_height` — scripts confirmed up to here. Internal bookkeeping. - `downloaded_height` — all required section bytes are present in the store. +`script_verified_height` was deleted in v0.8.0 along with deferred evaluation. +It existed to track how far behind application verification had fallen; since +`apply_state` now evaluates before persisting, application *is* verification +and a second watermark can only disagree with the first. + ### Invariants -- `script_verified_height <= state_applied_height <= downloaded_height <= chain_height` -- `state_applied_height` is monotonically increasing (except on reorg/eval-failure reset) -- Heights at or below `script_verified_height` are fully validated (state + scripts) +- `state_applied_height <= downloaded_height <= chain_height` +- `state_applied_height` is monotonically increasing (except on reorg reset) +- Heights at or below `state_applied_height` have had their state applied and + their scripts either verified or explicitly skipped by `checkpoint_height` — + one watermark, both facts. ### `advance_state_applied_height()` @@ -331,14 +876,11 @@ from `state_applied_height + 1` to `downloaded_height`: 1. Get header, sections, preceding headers, active params 2. Call `validator.apply_state(...)` -3. On Ok: advance `state_applied_height`, apply epoch boundary params, - spawn `evaluate_scripts(deferred_eval)` on rayon pool if Some +3. On Ok: advance `state_applied_height`, apply epoch boundary params. The + block's scripts have already passed — `Ok` is the only assertion sync + needs, and there is no second watermark to advance. 4. On Err: stop, log error, do NOT advance watermark -Between blocks: non-blocking drain of eval result channel to advance -`script_verified_height`. On eval failure: rollback (see Failure Handling -in spec). - ### SyncStore extension The `SyncStore` trait gains one method: @@ -350,22 +892,15 @@ fn get_modifier(&self, type_id: u8, id: &[u8; 32]) -> Option>; Reads section bytes from the store. The existing `has_modifier` checks existence; this returns the actual data for validation. -### Startup re-evaluation - -On startup, if `script_verified_height < state_applied_height`, rebuild -`DeferredEval` for gap blocks from stored sections and evaluate before -resuming normal sync. - ### Reorg handling On `DeliveryControl::Reorg { fork_point, .. }` (received via unbounded control channel): -1. Drain and discard in-flight eval results -2. Reset `downloaded_height` to fork_point -3. Get header at fork_point from chain +1. Reset `downloaded_height` to fork_point +2. Get header at fork_point from chain 4. Call `validator.reset_to(fork_point, header.state_root)` — on Err the validator did NOT move; sync must not perform step 5 (watermarks stay where they were; see facts/sync.md) -5. On Ok: `state_applied_height` and `script_verified_height` reset to fork_point +5. On Ok: `state_applied_height` resets to fork_point 6. Re-queue sections for the new branch, re-scan watermark 7. Re-validate from fork_point + 1 as sections become available @@ -461,6 +996,17 @@ starting height. A store populated in UTXO mode lacks ADProofs for historical blocks — only blocks synced after switching to digest mode will have ADProofs available for validation. +⚠ **Derived state must be reconstructed here, not left to the first applied +block.** `emission_box_id` was, and the gap was invisible during sync — a +catching-up node applies a block within seconds and the field fills in. It only +manifests on a node restarted **at tip**, where the next application may be +minutes away or, for a solo miner, never. See "Recovering `emission_box_id` on +resume" above. + +The general rule: any field maintained incrementally by `apply_state` is +suspect on resume. Ask what its value means before the first block, and +whether that value is distinguishable from a legitimate one. + ## Implementation Notes (Verified Against Testnet) ### ergo_avltree_rust Resolver diff --git a/install.sh b/install.sh index a508070..5a0674c 100755 --- a/install.sh +++ b/install.sh @@ -2,14 +2,48 @@ # # ergo-node-rust — interactive setup # -# Asks a handful of questions and writes a working ./ergo.toml in the -# current directory. Everything not asked here uses the binary's -# built-in defaults; see ergo.toml.example (shipped alongside this -# script) for the full annotated option set. +# Asks a handful of questions and writes a working ./conf.d/ in the current +# directory. Everything not asked here uses the binary's built-in defaults; +# see ergo.toml.example (shipped alongside this script) for the full annotated +# option set. +# +# ⚠ Network-dependent values — seed peers, listen port, API port — are NOT +# typed into this script. They are read from the shipped per-network defaults +# file, which is the single source for them. +# +# That is not tidiness. This script used to carry its own copy and it was +# WRONG: it wrote mainnet -> 9052 and testnet -> 9053, the values from before +# v0.6.10 inverted them, so anyone who ran it and accepted the default got a +# config pointing at the wrong API port for their network. The fix is not to +# correct the numbers here; it is for them not to be here. set -euo pipefail -OUTPUT="./ergo.toml" +CONFD="./conf.d" + +# Where the per-network defaults live: beside this script in a tarball, or +# under /usr/share on a .deb install. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +for candidate in \ + "${SCRIPT_DIR}/deploy/defaults" \ + "${SCRIPT_DIR}/defaults" \ + "/usr/share/ergo-node-rust/defaults" +do + if [[ -d "${candidate}" ]]; then + DEFAULTS_DIR="${candidate}" + break + fi +done + +if [[ -z "${DEFAULTS_DIR:-}" ]]; then + echo "error: could not find the per-network defaults directory." >&2 + echo " Looked in:" >&2 + echo " ${SCRIPT_DIR}/deploy/defaults" >&2 + echo " ${SCRIPT_DIR}/defaults" >&2 + echo " /usr/share/ergo-node-rust/defaults" >&2 + echo " Run this from the source tree, or install the .deb." >&2 + exit 1 +fi # ── Helpers ─────────────────────────────────────────────────────────── prompt() { @@ -20,7 +54,7 @@ prompt() { } prompt_choice() { - # prompt_choice VAR_NAME "Question" "default" "valid|values|pipe-separated" + # prompt_choice VAR_NAME "Question" "default" "valid|values" local var="$1" question="$2" default="$3" valid="$4" reply="" while true; do read -r -p "${question} [${default}]: " reply @@ -33,10 +67,17 @@ prompt_choice() { done } +want() { + # want "Topic name" -> 0 if the operator wants to configure it + local reply="" + read -r -p "Configure $1? [y/N]: " reply + [[ "${reply,,}" == y || "${reply,,}" == yes ]] +} + # ── Existing-config guard ───────────────────────────────────────────── -if [[ -e "${OUTPUT}" ]]; then - echo "A config already exists at ${OUTPUT}." - read -r -p "Overwrite? [y/N]: " reply +if [[ -e "${CONFD}" || -e "./ergo.toml" ]]; then + echo "A config already exists here (${CONFD} and/or ./ergo.toml)." + read -r -p "Overwrite the generated files? [y/N]: " reply case "${reply,,}" in y|yes) ;; *) echo "Aborted. Existing config left in place."; exit 0 ;; @@ -50,99 +91,151 @@ ergo-node-rust — interactive setup ────────────────────────────────── Press Enter to accept the bracketed default at each prompt. +Everything has a working default. You will be asked which topics you want +to change; skip them all and you still get a running node. + EOF -# ── Questions ───────────────────────────────────────────────────────── -prompt_choice NETWORK "Network (mainnet/testnet)" "testnet" "mainnet|testnet" -prompt_choice STATE_TYPE "State type (utxo/digest)" "utxo" "utxo|digest" -prompt DATA_DIR "Data directory" "./ergo-node-data" +# ── 1. Network ──────────────────────────────────────────────────────── +prompt_choice NETWORK "Network (mainnet/testnet)" "mainnet" "mainnet|testnet" -if [[ "${STATE_TYPE}" == "utxo" ]]; then - echo " ↳ blocks_to_keep: -1 = full archival, 0 = at-tip only, N = retain last N blocks" - prompt BLOCKS_TO_KEEP "Block history retention" "-1" -else - BLOCKS_TO_KEEP="-1" # ignored for digest, but kept for consistency in the file +if [[ ! -f "${DEFAULTS_DIR}/${NETWORK}.toml" ]]; then + echo "error: ${DEFAULTS_DIR}/${NETWORK}.toml is missing." >&2 + exit 1 fi -prompt CACHE_MB "redb cache size in MB (cold sync)" "256" -prompt FLUSH_HEAP "Live-heap flush threshold in MB (cold sync, 0 disables)" "4096" -prompt SYNCED_CACHE_MB "redb cache size in MB at tip (smaller = lower RSS)" "256" - -case "${NETWORK}" in - mainnet) DEFAULT_API="0.0.0.0:9052" ;; - testnet) DEFAULT_API="0.0.0.0:9053" ;; -esac -prompt API_ADDRESS "REST API bind address" "${DEFAULT_API}" - -# ── Network-specific peer + listen defaults ─────────────────────────── -case "${NETWORK}" in - mainnet) - LISTEN_PORT="9030" - SEED_PEERS=$(cat <<'EOF' - "213.239.193.208:9030", - "159.65.11.55:9030", - "165.227.26.175:9030", - "159.89.116.15:9030", - "136.244.110.145:9030", - "94.130.108.35:9030", - "221.165.214.185:9030", - "217.182.197.196:9030", - "173.212.220.9:9030", - "213.152.106.56:9030", - "[2001:41d0:700:6662::]:29031", -EOF - ) - ;; - testnet) - LISTEN_PORT="9030" - SEED_PEERS=$(cat <<'EOF' - "213.239.193.208:9023", - "128.253.41.110:9020", - "176.9.15.237:9021", +# ── Memory check ────────────────────────────────────────────────────── +# +# Advisory, and shown before the node-type question so a small box can pick +# light/digest in the same pass instead of finding out from a failed start. +# 4096 MB matches MEMORY_RECOMMENDED_BYTES in the node. +MEM_MB="" +if [[ -r /proc/meminfo ]]; then + MEM_MB=$(awk '/^MemTotal:/ { print int($2 / 1024); exit }' /proc/meminfo || true) +fi +if [[ -n "${MEM_MB}" && "${MEM_MB}" -lt 4096 ]]; then + cat < "${OUTPUT}" < "${CONFD}/50-local.toml" + +cat <> = input_ids.iter() - .map(|id| utxo_reader.box_by_id(id) - .or_else(|| self.pool.unconfirmed_box(id).cloned())) + let input_boxes: Option> = input_ids + .iter() + .map(|id| { + utxo_reader + .box_by_id(id) + .or_else(|| self.pool.unconfirmed_box(id).cloned()) + }) .collect(); let input_boxes = match input_boxes { @@ -56,12 +79,20 @@ impl super::Mempool { } }; - let data_boxes: Vec<_> = utx.tx.data_inputs.as_ref() - .map(|dis| dis.iter().filter_map(|di| { - let id = process::input_box_id_raw(&di.box_id); - utxo_reader.box_by_id(&id) - .or_else(|| self.pool.unconfirmed_box(&id).cloned()) - }).collect()) + let data_boxes: Vec<_> = utx + .tx + .data_inputs + .as_ref() + .map(|dis| { + dis.iter() + .filter_map(|di| { + let id = process::input_box_id_raw(&di.box_id); + utxo_reader + .box_by_id(&id) + .or_else(|| self.pool.unconfirmed_box(&id).cloned()) + }) + .collect() + }) .unwrap_or_default(); // Clone what we need before the mutable borrow @@ -101,9 +132,9 @@ impl super::Mempool { if valid.len() >= self.config.rebroadcast_count { break; } - let all_inputs_exist = process::input_box_ids(&utx.tx).iter().all(|id| - utxo_reader.box_by_id(id).is_some() - ); + let all_inputs_exist = process::input_box_ids(&utx.tx) + .iter() + .all(|id| utxo_reader.box_by_id(id).is_some()); if all_inputs_exist { valid.push(utx); } diff --git a/mempool/src/expiring_cache.rs b/mempool/src/expiring_cache.rs index d71e40b..f148449 100644 --- a/mempool/src/expiring_cache.rs +++ b/mempool/src/expiring_cache.rs @@ -45,8 +45,10 @@ impl ExpiringCache { /// Remove all expired entries. pub fn prune(&mut self) { let now = Instant::now(); - self.entries.retain(|_, inserted| now.duration_since(*inserted) < self.ttl); - self.insertion_order.retain(|k| self.entries.contains_key(k)); + self.entries + .retain(|_, inserted| now.duration_since(*inserted) < self.ttl); + self.insertion_order + .retain(|k| self.entries.contains_key(k)); } pub fn len(&self) -> usize { diff --git a/mempool/src/family.rs b/mempool/src/family.rs index 15f3397..012cc45 100644 --- a/mempool/src/family.rs +++ b/mempool/src/family.rs @@ -1,5 +1,5 @@ -use std::time::Instant; use crate::pool::OrderedPool; +use std::time::Instant; /// Maximum ancestor depth for family weight propagation (JVM: 500). const MAX_PARENT_SCAN_DEPTH: usize = 500; diff --git a/mempool/src/lib.rs b/mempool/src/lib.rs index c0444bf..fb6c1d2 100644 --- a/mempool/src/lib.rs +++ b/mempool/src/lib.rs @@ -1,17 +1,19 @@ -pub mod types; -pub mod weight; +pub mod cleanup; pub mod expiring_cache; -pub mod pool; pub mod family; +pub mod pool; pub mod process; -pub mod cleanup; pub mod stats; +pub mod types; +pub mod weight; use std::collections::HashMap; +use ergo_lib::chain::ergo_tree_predef; use ergo_lib::chain::transaction::Transaction; -use pool::OrderedPool; +use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; use expiring_cache::ExpiringCache; +use pool::OrderedPool; use stats::FeeStats; use types::{MempoolConfig, UnconfirmedTx}; use weight::TxWeight; @@ -21,6 +23,10 @@ pub struct Mempool { invalidated: ExpiringCache<[u8; 32]>, stats: FeeStats, config: MempoolConfig, + /// The tree a fee output is guarded by, derived once from + /// `config.reward_delay`. Step 7a compares every output against this, so + /// deriving it per output would recompile a tree per output per tx. + fee_proposition: ErgoTree, /// Validation cost since last block (rate limiting). interblock_cost: u64, /// Per-peer validation cost since last block. @@ -28,13 +34,33 @@ pub struct Mempool { } impl Mempool { + /// # Panics + /// + /// If the fee proposition cannot be built from `config.reward_delay`. + /// + /// `fee_proposition()` is a pure function of that one integer and takes no + /// external input, so a failure means the predef builder itself is broken — + /// a build-integrity fault, not a runtime or configuration condition. There + /// is no recovery worth having: a mempool that cannot recognise a fee + /// output reads every fee as zero and silently declines every transaction + /// on the network, which is precisely the bug this step exists to fix. + /// Failing loudly at startup is the honest outcome, and `new` stays + /// infallible so its two call sites in `main.rs` and `api/` are unaffected. pub fn new(config: MempoolConfig) -> Self { let capacity = config.capacity; + let fee_proposition = ergo_tree_predef::fee_proposition(config.reward_delay) + .unwrap_or_else(|e| { + panic!( + "fee proposition for reward_delay {}: {e}", + config.reward_delay + ) + }); Self { pool: OrderedPool::new(capacity), invalidated: ExpiringCache::new(config.invalidation_ttl, config.invalidation_capacity), stats: FeeStats::new(1000), config, + fee_proposition, interblock_cost: 0, per_peer_cost: HashMap::new(), } @@ -42,22 +68,45 @@ impl Mempool { // --- Query methods --- - pub fn get(&self, tx_id: &[u8; 32]) -> Option<&UnconfirmedTx> { self.pool.get(tx_id) } - pub fn contains(&self, tx_id: &[u8; 32]) -> bool { self.pool.contains(tx_id) || self.invalidated.contains(tx_id) } - pub fn is_invalidated(&self, tx_id: &[u8; 32]) -> bool { self.invalidated.contains(tx_id) } - pub fn len(&self) -> usize { self.pool.len() } - pub fn is_empty(&self) -> bool { self.pool.is_empty() } - pub fn top(&self, limit: usize) -> Vec<&UnconfirmedTx> { self.pool.top(limit) } - pub fn all_prioritized(&self) -> Vec<&UnconfirmedTx> { self.pool.all_prioritized() } - pub fn tx_ids(&self) -> Vec<[u8; 32]> { self.pool.tx_ids() } + pub fn get(&self, tx_id: &[u8; 32]) -> Option<&UnconfirmedTx> { + self.pool.get(tx_id) + } + pub fn contains(&self, tx_id: &[u8; 32]) -> bool { + self.pool.contains(tx_id) || self.invalidated.contains(tx_id) + } + pub fn is_invalidated(&self, tx_id: &[u8; 32]) -> bool { + self.invalidated.contains(tx_id) + } + pub fn len(&self) -> usize { + self.pool.len() + } + pub fn is_empty(&self) -> bool { + self.pool.is_empty() + } + pub fn top(&self, limit: usize) -> Vec<&UnconfirmedTx> { + self.pool.top(limit) + } + pub fn all_prioritized(&self) -> Vec<&UnconfirmedTx> { + self.pool.all_prioritized() + } + pub fn tx_ids(&self) -> Vec<[u8; 32]> { + self.pool.tx_ids() + } - pub fn unconfirmed_box(&self, box_id: &[u8; 32]) -> Option<&ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox> { + pub fn unconfirmed_box( + &self, + box_id: &[u8; 32], + ) -> Option<&ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox> { self.pool.unconfirmed_box(box_id) } - pub fn spent_inputs(&self) -> impl Iterator { self.pool.spent_inputs() } + pub fn spent_inputs(&self) -> impl Iterator { + self.pool.spent_inputs() + } - pub fn fee_histogram(&self, buckets: usize) -> Vec { self.stats.histogram(buckets) } + pub fn fee_histogram(&self, buckets: usize) -> Vec { + self.stats.histogram(buckets) + } pub fn expected_wait_time(&self, fee: u64, tx_size: usize) -> Option { let fee_per_factor = fee * 1024 / tx_size.max(1) as u64; @@ -65,7 +114,8 @@ impl Mempool { } pub fn recommended_fee(&self, target_wait_ms: u64, tx_size: usize) -> Option { - self.stats.recommended_fee(target_wait_ms) + self.stats + .recommended_fee(target_wait_ms) .map(|fpf| fpf * tx_size.max(1) as u64 / 1024) } @@ -86,7 +136,10 @@ impl Mempool { // Record stats if tx was in our pool if let Some(utx) = self.pool.get(&tx_id) { let wait_ms = utx.created.elapsed().as_millis() as u64; - let fee_per_factor = self.pool.by_id.get(&tx_id) + let fee_per_factor = self + .pool + .by_id + .get(&tx_id) .map(|w| w.fee_per_factor) .unwrap_or(0); self.stats.record_confirmation(fee_per_factor, wait_ms); @@ -102,9 +155,10 @@ impl Mempool { let input_id = process::input_box_id_raw(&input.box_id); if let Some(conflict_weight) = self.pool.spending_tx(&input_id).cloned() { if conflict_weight.tx_id != tx_id - && self.pool.remove(&conflict_weight.tx_id).is_some() { - removed.push(conflict_weight.tx_id); - } + && self.pool.remove(&conflict_weight.tx_id).is_some() + { + removed.push(conflict_weight.tx_id); + } } } } @@ -129,7 +183,11 @@ impl Mempool { let input_ids = process::input_box_ids(&utx.tx); let outputs = process::output_boxes(&utx.tx); let weight = TxWeight::new( - tx_id, utx.fee, utx.tx_bytes.len(), utx.cost, self.config.fee_strategy, + tx_id, + utx.fee, + utx.tx_bytes.len(), + utx.cost, + self.config.fee_strategy, ); self.pool.insert(weight, utx, &input_ids, outputs); } diff --git a/mempool/src/pool.rs b/mempool/src/pool.rs index d4baed5..5f283eb 100644 --- a/mempool/src/pool.rs +++ b/mempool/src/pool.rs @@ -1,8 +1,8 @@ -use std::collections::{BTreeMap, HashMap}; use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; +use std::collections::{BTreeMap, HashMap}; -use crate::weight::TxWeight; use crate::types::UnconfirmedTx; +use crate::weight::TxWeight; /// Core ordered transaction pool with secondary indexes. pub struct OrderedPool { @@ -29,11 +29,17 @@ impl OrderedPool { } } - pub fn len(&self) -> usize { self.ordered.len() } + pub fn len(&self) -> usize { + self.ordered.len() + } - pub fn is_empty(&self) -> bool { self.ordered.is_empty() } + pub fn is_empty(&self) -> bool { + self.ordered.is_empty() + } - pub fn is_full(&self) -> bool { self.len() >= self.capacity } + pub fn is_full(&self) -> bool { + self.len() >= self.capacity + } /// Insert a transaction with its pre-computed weight. pub fn insert( @@ -74,7 +80,9 @@ impl OrderedPool { } /// Check if tx ID is in pool. - pub fn contains(&self, tx_id: &[u8; 32]) -> bool { self.by_id.contains_key(tx_id) } + pub fn contains(&self, tx_id: &[u8; 32]) -> bool { + self.by_id.contains_key(tx_id) + } /// Find which pool tx spends a given input box (for double-spend check). pub fn spending_tx(&self, input_box_id: &[u8; 32]) -> Option<&TxWeight> { diff --git a/mempool/src/process.rs b/mempool/src/process.rs index 32b8ef9..d69864a 100644 --- a/mempool/src/process.rs +++ b/mempool/src/process.rs @@ -1,16 +1,36 @@ use ergo_lib::chain::transaction::Transaction; use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; +use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; use ergo_validation::{validate_single_transaction, ErgoStateContext}; -use crate::weight::TxWeight; -use crate::types::*; use crate::family::propagate_family_weight; +use crate::types::*; +use crate::weight::TxWeight; -/// Extract the transaction fee: input_sum - output_sum. -pub fn extract_fee(tx: &Transaction, input_boxes: &[ErgoBox]) -> u64 { - let input_sum: u64 = input_boxes.iter().map(|b| *b.value.as_u64()).sum(); - let output_sum: u64 = tx.outputs.iter().map(|b| *b.value.as_u64()).sum(); - input_sum.saturating_sub(output_sum) +/// The transaction fee: the summed value of every output guarded by the fee +/// proposition. +/// +/// **Not `input_sum - output_sum`.** Ergo has no implicit remainder that +/// becomes the fee — ergo-lib enforces exact ERG preservation +/// (`ErgPreservationError` when `input_sum != output_sum`, +/// `wallet/tx_context.rs:122`), so a difference-based fee is structurally zero +/// for every transaction that survives validation, and the minimum-fee check +/// then declines all of them. The fee is an explicit output instead. +/// +/// Mirrors `ErgoMemPool.extractFee` (`ErgoMemPool.scala:304-309`), which +/// filters outputs on `chainSettings.monetary.feeProposition`. +/// +/// Compared by `ErgoTree` value rather than by serialized bytes: that is the +/// JVM's own structural `==`, sigma-rust's `PartialEq` deliberately ignores +/// `ParsedErgoTree`'s memoisation field, and it avoids serializing every +/// output's script on a per-transaction path. An output whose script failed to +/// parse is an `ErgoTree::Unparsed` and correctly matches nothing. +pub fn extract_fee(tx: &Transaction, fee_proposition: &ErgoTree) -> u64 { + tx.outputs + .iter() + .filter(|out| &out.ergo_tree == fee_proposition) + .map(|out| *out.value.as_u64()) + .sum() } /// Extract transaction ID as [u8; 32]. @@ -22,11 +42,14 @@ pub fn tx_id_bytes(tx: &Transaction) -> [u8; 32] { /// Extract input box IDs as Vec<[u8; 32]>. pub fn input_box_ids(tx: &Transaction) -> Vec<[u8; 32]> { - tx.inputs.iter().map(|i| { - let mut arr = [0u8; 32]; - arr.copy_from_slice(i.box_id.as_ref()); - arr - }).collect() + tx.inputs + .iter() + .map(|i| { + let mut arr = [0u8; 32]; + arr.copy_from_slice(i.box_id.as_ref()); + arr + }) + .collect() } /// Extract a raw box_id reference as [u8; 32]. @@ -36,13 +59,38 @@ pub fn input_box_id_raw(box_id: &ergo_lib::ergotree_ir::chain::ergo_box::BoxId) arr } +/// The first output whose creation height sits above the context's preheader, +/// or `None` if every output is at or below it. +/// +/// This is the *transient* half of ergo-lib's `InvalidHeightError`: the sender +/// built the transaction against a tip one block ahead of ours, and applying +/// the next block makes it valid. Callers must decline such a transaction, not +/// invalidate it — see the contract's step 6a. +/// +/// The comparison is signed, mirroring `verify_output()` in ergo-lib's +/// `wallet/tx_context.rs` byte for byte. A creation height with bit 31 set is +/// "negative" under the V1 rules ergo-lib still honours, so it does *not* trip +/// that check; it trips `NegativeHeight` instead, which is permanent. Widening +/// this to an unsigned comparison would swallow those into the transient path +/// and re-validate the same garbage on every rebroadcast, forever. +pub fn output_above_preheader(tx: &Transaction, state_context: &ErgoStateContext) -> Option { + let preheader_height = state_context.pre_header.height as i32; + tx.outputs + .iter() + .map(|out| out.creation_height) + .find(|h| *h as i32 > preheader_height) +} + /// Extract output box IDs and boxes as Vec<([u8; 32], ErgoBox)>. pub fn output_boxes(tx: &Transaction) -> Vec<([u8; 32], ErgoBox)> { - tx.outputs.iter().map(|b| { - let mut arr = [0u8; 32]; - arr.copy_from_slice(b.box_id().as_ref()); - (arr, b.clone()) - }).collect() + tx.outputs + .iter() + .map(|b| { + let mut arr = [0u8; 32]; + arr.copy_from_slice(b.box_id().as_ref()); + (arr, b.clone()) + }) + .collect() } impl super::Mempool { @@ -99,29 +147,57 @@ impl super::Mempool { let input_ids = input_box_ids(&tx); let mut input_boxes = Vec::with_capacity(input_ids.len()); for id in &input_ids { - match utxo_reader.box_by_id(id) + match utxo_reader + .box_by_id(id) .or_else(|| self.pool.unconfirmed_box(id).cloned()) { Some(b) => input_boxes.push(b), - None => return ProcessingOutcome::Declined { - reason: format!("input box {} not found", hex::encode(id)), - }, + None => { + return ProcessingOutcome::Declined { + reason: format!("input box {} not found", hex::encode(id)), + } + } } } // 5. Resolve data-input boxes - let data_boxes: Vec = tx.data_inputs.as_ref() + let data_boxes: Vec = tx + .data_inputs + .as_ref() .map(|dis| { - dis.iter().filter_map(|di| { - let id = input_box_id_raw(&di.box_id); - utxo_reader.box_by_id(&id) - .or_else(|| self.pool.unconfirmed_box(&id).cloned()) - }).collect() + dis.iter() + .filter_map(|di| { + let id = input_box_id_raw(&di.box_id); + utxo_reader + .box_by_id(&id) + .or_else(|| self.pool.unconfirmed_box(&id).cloned()) + }) + .collect() }) .unwrap_or_default(); + // 6a. Transient creation-height guard. A transaction built one block + // ahead of us is not invalid, it is early — the next applied block + // makes it valid. Letting it reach step 6 would cache it as invalid + // for `invalidation_ttl`, so every rebroadcast in the next half hour + // is dropped at step 1 without ever being re-validated. Same reasoning + // as the missing-input decline above. + if let Some(height) = output_above_preheader(&tx, state_context) { + return ProcessingOutcome::Declined { + reason: format!( + "creation height {height} above preheader height {}", + state_context.pre_header.height + ), + }; + } + // 6. Validate (returns script evaluation cost in block cost units) - let cost = match validate_single_transaction(&tx, input_boxes.clone(), data_boxes, state_context) { + let cost = match validate_single_transaction( + &tx, + input_boxes.clone(), + data_boxes, + state_context, + ) { Ok(script_cost) => script_cost.max(tx_bytes.len() as u64) as u32, Err(e) => { self.invalidated.insert(tx_id); @@ -137,8 +213,10 @@ impl super::Mempool { *self.per_peer_cost.entry(peer).or_insert(0) += cost as u64; } - // 7. Check minimum fee - let fee = extract_fee(&tx, &input_boxes); + // 7a. Compute fee from the outputs paying the fee proposition. + let fee = extract_fee(&tx, &self.fee_proposition); + + // 7b. Check minimum fee if fee < self.config.min_fee { return ProcessingOutcome::Declined { reason: format!("fee {fee} below minimum {}", self.config.min_fee), @@ -146,9 +224,7 @@ impl super::Mempool { } // 8. Compute weight - let weight = TxWeight::new( - tx_id, fee, tx_bytes.len(), cost, self.config.fee_strategy, - ); + let weight = TxWeight::new(tx_id, fee, tx_bytes.len(), cost, self.config.fee_strategy); // 9. Double-spend resolution let mut conflicts: Vec<[u8; 32]> = Vec::new(); @@ -161,7 +237,8 @@ impl super::Mempool { } if !conflicts.is_empty() { - let total_conflict_weight: u64 = conflicts.iter() + let total_conflict_weight: u64 = conflicts + .iter() .filter_map(|id| self.pool.by_id.get(id)) .map(|w| w.weight) .sum(); diff --git a/mempool/src/stats.rs b/mempool/src/stats.rs index 57e037c..474c41e 100644 --- a/mempool/src/stats.rs +++ b/mempool/src/stats.rs @@ -46,8 +46,8 @@ impl FeeStats { let max_fee = self.history.iter().map(|(f, _)| *f).max().unwrap(); if min_fee == max_fee { - let avg_wait = self.history.iter().map(|(_, w)| *w).sum::() - / self.history.len() as u64; + let avg_wait = + self.history.iter().map(|(_, w)| *w).sum::() / self.history.len() as u64; return vec![FeeBucket { count: self.history.len(), min_fee, @@ -68,14 +68,17 @@ impl FeeStats { buckets[idx].1 += wait; } - buckets.iter().enumerate().filter(|(_, (count, _))| *count > 0).map(|(i, (count, total_wait))| { - FeeBucket { + buckets + .iter() + .enumerate() + .filter(|(_, (count, _))| *count > 0) + .map(|(i, (count, total_wait))| FeeBucket { count: *count, min_fee: min_fee + i as u64 * bucket_width, max_fee: min_fee + (i as u64 + 1) * bucket_width, avg_wait_ms: *total_wait / *count as u64, - } - }).collect() + }) + .collect() } /// Estimated wait time for a transaction with given fee_per_factor. @@ -101,7 +104,11 @@ impl FeeStats { } } - if count == 0 { None } else { Some(closest_wait / count) } + if count == 0 { + None + } else { + Some(closest_wait / count) + } } /// Recommended fee_per_factor to achieve target wait time. @@ -111,7 +118,8 @@ impl FeeStats { } // Find the lowest fee that achieved wait_ms <= target - self.history.iter() + self.history + .iter() .filter(|(_, wait)| *wait <= target_wait_ms) .map(|(fee, _)| *fee) .min() diff --git a/mempool/src/types.rs b/mempool/src/types.rs index 7a87a37..fb1a4e1 100644 --- a/mempool/src/types.rs +++ b/mempool/src/types.rs @@ -1,6 +1,6 @@ -use std::time::{Duration, Instant}; use ergo_lib::chain::transaction::Transaction; use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; +use std::time::{Duration, Instant}; /// Read-only access to the UTXO set for transaction validation. /// @@ -35,7 +35,10 @@ pub enum ProcessingOutcome { /// Transaction accepted and added to the pool. Accepted { tx_id: [u8; 32] }, /// Transaction replaced one or more double-spending transactions. - Replaced { tx_id: [u8; 32], removed: Vec<[u8; 32]> }, + Replaced { + tx_id: [u8; 32], + removed: Vec<[u8; 32]>, + }, /// Transaction rejected — higher-fee txs already spend the same inputs. DoubleSpendLoser { winner_ids: Vec<[u8; 32]> }, /// Transaction temporarily declined — may succeed later. @@ -75,6 +78,13 @@ pub struct MempoolConfig { pub cost_per_block: u64, /// Maximum validation cost budget per peer per block interval. pub cost_per_peer_per_block: u64, + /// Miner reward delay, the only input to the fee proposition tree. + /// + /// A monetary constant, not a mining setting: the node must extract fees + /// whether or not it mines, so this mirrors the JVM's + /// `chainSettings.monetary.minerRewardDelay` rather than `[mining]`. + /// Mainnet is 720, which is the default. + pub reward_delay: i32, } impl Default for MempoolConfig { @@ -89,6 +99,7 @@ impl Default for MempoolConfig { invalidation_capacity: 10_000, cost_per_block: 12_000_000, cost_per_peer_per_block: 10_000_000, + reward_delay: 720, } } } diff --git a/mempool/src/weight.rs b/mempool/src/weight.rs index 10edc0c..aa4b551 100644 --- a/mempool/src/weight.rs +++ b/mempool/src/weight.rs @@ -22,7 +22,9 @@ pub struct TxWeight { impl Ord for TxWeight { fn cmp(&self, other: &Self) -> Ordering { - other.weight.cmp(&self.weight) + other + .weight + .cmp(&self.weight) .then(self.tx_id.cmp(&other.tx_id)) } } @@ -48,10 +50,18 @@ impl TxWeight { let fee_factor = match strategy { FeeStrategy::FeePerByte => tx_byte_size as u64, FeeStrategy::FeePerCycle => { - if cost == 0 { FAKE_COST as u64 } else { cost as u64 } + if cost == 0 { + FAKE_COST as u64 + } else { + cost as u64 + } } }; - let fee_per_factor = if fee_factor == 0 { 0 } else { fee * 1024 / fee_factor }; + let fee_per_factor = if fee_factor == 0 { + 0 + } else { + fee * 1024 / fee_factor + }; Self { weight: fee_per_factor, fee_per_factor, diff --git a/mempool/tests/common/mod.rs b/mempool/tests/common/mod.rs new file mode 100644 index 0000000..3a9988c --- /dev/null +++ b/mempool/tests/common/mod.rs @@ -0,0 +1,189 @@ +//! Fixtures shared by the `process()`-level integration tests. +//! +//! Everything here builds *real* artefacts — a real `ErgoStateContext`, real +//! `ErgoTree`s, transactions that reach ergo-lib's evaluator — because the +//! properties under test (which outcome a validation failure maps to, what +//! counts as a fee) are only observable when validation really runs. A stubbed +//! validator would let every one of these tests pass while proving nothing. +//! +//! Scripts are the two trivial sigma propositions: `sigmaProp(true)` verifies +//! against an empty proof, `sigmaProp(false)` cannot. No key material, and the +//! false one's failure does not depend on any particular sigma reduction. + +#![allow(dead_code)] // each test binary uses a subset + +use std::collections::HashMap; + +use ergo_chain_types::{ + ADDigest, AutolykosSolution, BlockId, Digest32, EcPoint, Header, PreHeader, Votes, +}; +use ergo_lib::chain::transaction::input::prover_result::ProverResult; +use ergo_lib::chain::transaction::input::Input; +use ergo_lib::chain::transaction::Transaction; +use ergo_lib::ergotree_interpreter::sigma_protocol::prover::ProofBytes; +use ergo_lib::ergotree_ir::chain::context_extension::ContextExtension; +use ergo_lib::ergotree_ir::chain::ergo_box::box_value::BoxValue; +use ergo_lib::ergotree_ir::chain::ergo_box::{ErgoBox, ErgoBoxCandidate, NonMandatoryRegisters}; +use ergo_lib::ergotree_ir::chain::tx_id::TxId; +use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; +use ergo_lib::ergotree_ir::mir::bool_to_sigma::BoolToSigmaProp; +use ergo_lib::ergotree_ir::mir::expr::Expr; + +use ergo_mempool::types::{MempoolConfig, UtxoReader}; +use ergo_validation::{ErgoStateContext, Parameters}; + +/// Well past genesis, so nothing here trips a near-genesis special case. +pub const TIP_HEIGHT: u32 = 1_000; +/// Comfortably above `min_value_per_byte × box size` for these small boxes. +pub const BOX_VALUE: u64 = 1_000_000_000; + +/// `sigmaProp()` — reduces to a trivial true or false with no proof and +/// no prover key. +pub fn sigma_bool_tree(value: bool) -> ErgoTree { + let expr = Expr::BoolToSigmaProp(BoolToSigmaProp { + input: Box::new(Expr::Const(value.into())), + }); + ErgoTree::try_from(expr).expect("sigmaProp(bool) is a valid ErgoTree") +} + +/// The tree a fee output is guarded by, at the configured reward delay — the +/// same derivation the mempool itself performs. +pub fn fee_tree() -> ErgoTree { + ergo_lib::chain::ergo_tree_predef::fee_proposition(MempoolConfig::default().reward_delay) + .expect("fee proposition for the default reward delay") +} + +/// A UTXO guarded by `sigmaProp(spendable)`, distinguished from its siblings by +/// `seed` (which varies the source tx id, hence the box id). +pub fn make_box(spendable: bool, seed: u8, creation_height: u32) -> ErgoBox { + ErgoBox::new( + BoxValue::try_from(BOX_VALUE).expect("value above the minimum"), + sigma_bool_tree(spendable), + None, + NonMandatoryRegisters::empty(), + creation_height, + TxId::from(Digest32::from([seed; 32])), + 0, + ) + .expect("box construction") +} + +/// A transaction spending `inputs` into a single always-true output at +/// `out_creation_height`. Value is preserved exactly — ergo-lib rejects any +/// other ratio outright, and an unbalanced transaction would fail for a reason +/// that has nothing to do with what these tests measure. +pub fn spend_tx(inputs: &[ErgoBox], out_creation_height: u32) -> Transaction { + let total: u64 = inputs.iter().map(|b| *b.value.as_u64()).sum(); + spend_tx_to( + inputs, + &[(total, sigma_bool_tree(true))], + out_creation_height, + ) +} + +/// A transaction spending `inputs` into the given `(value, script)` outputs. +/// +/// The caller is responsible for the outputs summing to the inputs: ergo-lib +/// enforces exact ERG preservation, so anything else fails validation before +/// reaching whatever the test is actually about. +pub fn spend_tx_to( + inputs: &[ErgoBox], + outputs: &[(u64, ErgoTree)], + out_creation_height: u32, +) -> Transaction { + let out_candidates: Vec = outputs + .iter() + .map(|(value, ergo_tree)| ErgoBoxCandidate { + value: BoxValue::try_from(*value).expect("output value above the minimum"), + ergo_tree: ergo_tree.clone(), + tokens: None, + additional_registers: NonMandatoryRegisters::empty(), + creation_height: out_creation_height, + }) + .collect(); + let tx_inputs: Vec = inputs + .iter() + .map(|b| { + Input::new( + b.box_id(), + ProverResult { + proof: ProofBytes::Empty, + extension: ContextExtension::empty(), + }, + ) + }) + .collect(); + Transaction::new_from_vec(tx_inputs, vec![], out_candidates).expect("transaction construction") +} + +pub fn make_header(height: u32) -> Header { + Header { + version: 3, + id: BlockId(Digest32::zero()), + parent_id: BlockId(Digest32::zero()), + ad_proofs_root: Digest32::zero(), + state_root: ADDigest::zero(), + transaction_root: Digest32::zero(), + timestamp: 1_600_000_000_000 + height as u64, + n_bits: 100_000, + height, + extension_root: Digest32::zero(), + autolykos_solution: AutolykosSolution { + miner_pk: Box::new(EcPoint::default()), + pow_onetime_pk: None, + nonce: vec![0u8; 8], + pow_distance: None, + }, + votes: Votes([0, 0, 0]), + unparsed_bytes: Box::new([]), + } +} + +/// A state context whose preheader sits at `preheader_height` — i.e. describing +/// the block about to be mined. Version 3 so the monotonic-height rule is live, +/// matching mainnet. +pub fn state_context_at(preheader_height: u32) -> ErgoStateContext { + let pre_header = PreHeader { + version: 3, + parent_id: BlockId(Digest32::zero()), + timestamp: 1_600_000_000_000 + preheader_height as u64, + n_bits: 100_000, + height: preheader_height, + miner_pk: Box::new(EcPoint::default()), + votes: Votes([0, 0, 0]), + }; + let headers = vec![make_header(preheader_height.saturating_sub(1))] + .try_into() + .expect("one header is within the 1..10 bound"); + ErgoStateContext::new(pre_header, headers, Parameters::default()) +} + +/// A `UtxoReader` over a fixed set of boxes. +pub struct StaticUtxo(pub HashMap<[u8; 32], ErgoBox>); + +impl StaticUtxo { + pub fn new(boxes: &[ErgoBox]) -> Self { + Self( + boxes + .iter() + .map(|b| { + let mut id = [0u8; 32]; + id.copy_from_slice(b.box_id().as_ref()); + (id, b.clone()) + }) + .collect(), + ) + } +} + +impl UtxoReader for StaticUtxo { + fn box_by_id(&self, box_id: &[u8; 32]) -> Option { + self.0.get(box_id).cloned() + } +} + +pub fn tx_id_bytes(tx: &Transaction) -> [u8; 32] { + let mut arr = [0u8; 32]; + arr.copy_from_slice(tx.id().as_ref()); + arr +} diff --git a/mempool/tests/creation_height_test.rs b/mempool/tests/creation_height_test.rs new file mode 100644 index 0000000..977dd8b --- /dev/null +++ b/mempool/tests/creation_height_test.rs @@ -0,0 +1,267 @@ +//! Step 6a: a creation height above the preheader is transient, not fatal. +//! +//! Fixtures live in `common/` — real `ErgoStateContext`, real `ErgoTree`s, +//! transactions that reach ergo-lib's evaluator. The bug under test is +//! invisible to a stubbed validator: it lives precisely in *which* outcome a +//! real validation failure is mapped to. + +mod common; + +use std::time::Instant; + +use ergo_lib::ergotree_ir::serialization::SigmaSerializable; + +use common::{make_box, spend_tx, state_context_at, tx_id_bytes, StaticUtxo, TIP_HEIGHT}; +use ergo_mempool::types::{MempoolConfig, ProcessingOutcome, UnconfirmedTx}; +use ergo_mempool::Mempool; + +/// These fixtures spend into a single always-true output and pay no fee output, +/// so their fee is legitimately 0 and step 7b would decline them before they +/// could demonstrate anything about creation height. `min_fee = 0` isolates the +/// guard. Fee extraction itself is covered in `fee_test.rs`. +fn test_config() -> MempoolConfig { + MempoolConfig { + min_fee: 0, + ..MempoolConfig::default() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// The whole point. A transaction one block ahead of us is declined, leaves no +/// trace in the invalidation cache, and is accepted on the retry after a block +/// is applied. The bug was that the retry never got that far: the first attempt +/// cached the tx as invalid, and step 1 dropped every rebroadcast for the next +/// `invalidation_ttl` without re-validating. +#[test] +fn future_creation_height_declines_then_succeeds_next_block() { + let mut mempool = Mempool::new(test_config()); + + let input = make_box(true, 1, TIP_HEIGHT - 1); + let utxo = StaticUtxo::new(std::slice::from_ref(&input)); + + // The sender is a block ahead of us: they built against tip TIP_HEIGHT, + // so their outputs carry creation height TIP_HEIGHT + 1. Our preheader is + // still describing block TIP_HEIGHT. + let tx = spend_tx(&[input], TIP_HEIGHT + 1); + let tx_id = tx_id_bytes(&tx); + let tx_bytes = tx.sigma_serialize_bytes().expect("tx serialization"); + + let outcome = mempool.process( + tx.clone(), + tx_bytes.clone(), + &utxo, + &state_context_at(TIP_HEIGHT), + Some(7), + ); + + match &outcome { + ProcessingOutcome::Declined { reason } => { + assert!( + reason.contains(&(TIP_HEIGHT + 1).to_string()) + && reason.contains(&TIP_HEIGHT.to_string()), + "the reason should name both heights, got: {reason}" + ); + } + other => panic!("expected Declined, got {other:?}"), + } + + // The load-bearing assertion: nothing was cached. + assert!( + !mempool.is_invalidated(&tx_id), + "a transient creation height must not enter the invalidation cache" + ); + assert!( + !mempool.contains(&tx_id), + "declined tx is neither pooled nor remembered as invalid" + ); + assert_eq!(mempool.len(), 0); + + // One block later, the very same transaction — as a rebroadcast would + // deliver it — is now on time. + let outcome = mempool.process( + tx, + tx_bytes, + &utxo, + &state_context_at(TIP_HEIGHT + 1), + Some(7), + ); + + assert!( + matches!(outcome, ProcessingOutcome::Accepted { tx_id: id } if id == tx_id), + "the retry must be re-validated and accepted, got {outcome:?}" + ); + assert_eq!(mempool.len(), 1); +} + +/// A transaction at exactly the preheader height is on time and must pass +/// through 6a untouched into real validation. +#[test] +fn creation_height_at_preheader_is_accepted() { + let mut mempool = Mempool::new(test_config()); + + let input = make_box(true, 2, TIP_HEIGHT - 1); + let utxo = StaticUtxo::new(std::slice::from_ref(&input)); + + let tx = spend_tx(&[input], TIP_HEIGHT); + let tx_id = tx_id_bytes(&tx); + let tx_bytes = tx.sigma_serialize_bytes().expect("tx serialization"); + + let outcome = mempool.process(tx, tx_bytes, &utxo, &state_context_at(TIP_HEIGHT), Some(7)); + + assert!( + matches!(outcome, ProcessingOutcome::Accepted { tx_id: id } if id == tx_id), + "a transaction at the preheader height is on time, got {outcome:?}" + ); + assert_eq!(mempool.len(), 1); +} + +/// The guard must not blunt the real path: a transaction whose script cannot be +/// satisfied is still permanently invalid and is still cached as such. +#[test] +fn unsatisfiable_script_still_invalidates_and_caches() { + let mut mempool = Mempool::new(test_config()); + + // sigmaProp(false) cannot be proven, and the empty proof does not satisfy it. + let input = make_box(false, 3, TIP_HEIGHT - 1); + let utxo = StaticUtxo::new(std::slice::from_ref(&input)); + + let tx = spend_tx(&[input], TIP_HEIGHT); + let tx_id = tx_id_bytes(&tx); + let tx_bytes = tx.sigma_serialize_bytes().expect("tx serialization"); + + let outcome = mempool.process( + tx.clone(), + tx_bytes.clone(), + &utxo, + &state_context_at(TIP_HEIGHT), + Some(7), + ); + + assert!( + matches!(outcome, ProcessingOutcome::Invalidated { .. }), + "an unsatisfiable script is permanently invalid, got {outcome:?}" + ); + assert!( + mempool.is_invalidated(&tx_id), + "a genuine failure must still be cached" + ); + + // And the cache must still short-circuit the resubmission. + let outcome = mempool.process(tx, tx_bytes, &utxo, &state_context_at(TIP_HEIGHT), Some(7)); + assert!( + matches!(outcome, ProcessingOutcome::Invalidated { ref reason } if reason.contains("previously invalidated")), + "resubmission should be dropped by the cache, got {outcome:?}" + ); +} + +/// A creation height with bit 31 set is "negative" under the V1 rules ergo-lib +/// still honours, so it is *not* the transient condition — it is permanently +/// malformed and must reach the invalidation cache. This pins the signed +/// comparison: an unsigned one would swallow these into the declined path and +/// re-validate the same garbage on every rebroadcast forever. +#[test] +fn negative_creation_height_is_invalidated_not_declined() { + let mut mempool = Mempool::new(test_config()); + + let input = make_box(true, 4, TIP_HEIGHT - 1); + let utxo = StaticUtxo::new(std::slice::from_ref(&input)); + + let tx = spend_tx(&[input], 1 << 31); + let tx_id = tx_id_bytes(&tx); + let tx_bytes = tx.sigma_serialize_bytes().expect("tx serialization"); + + let outcome = mempool.process(tx, tx_bytes, &utxo, &state_context_at(TIP_HEIGHT), Some(7)); + + assert!( + matches!(outcome, ProcessingOutcome::Invalidated { .. }), + "a negative creation height is malformed, not early, got {outcome:?}" + ); + assert!( + mempool.is_invalidated(&tx_id), + "malformed heights belong in the cache" + ); +} + +/// `revalidate()` re-runs validation against the current context and caches the +/// failures, so a pooled transaction ahead of our preheader — which a reorg +/// produces, since `return_to_pool()` re-inserts without validating — would be +/// invalidated by the same transient condition. It must be left alone instead. +#[test] +fn revalidate_skips_pooled_tx_ahead_of_preheader() { + let mut config = test_config(); + // Revalidate on the next call rather than waiting out the interval. + config.cleanup_interval = std::time::Duration::ZERO; + let mut mempool = Mempool::new(config); + + let input = make_box(true, 5, TIP_HEIGHT - 1); + let utxo = StaticUtxo::new(std::slice::from_ref(&input)); + + // Accepted while the preheader was at TIP_HEIGHT + 1 ... + let tx = spend_tx(&[input], TIP_HEIGHT + 1); + let tx_id = tx_id_bytes(&tx); + let tx_bytes = tx.sigma_serialize_bytes().expect("tx serialization"); + let outcome = mempool.process(tx, tx_bytes, &utxo, &state_context_at(TIP_HEIGHT + 1), None); + assert!( + matches!(outcome, ProcessingOutcome::Accepted { .. }), + "setup: expected the tx to enter the pool, got {outcome:?}" + ); + + // ... and a reorg drops the preheader back a block underneath it. + let removed = mempool.revalidate(&utxo, &state_context_at(TIP_HEIGHT)); + + assert!( + removed.is_empty(), + "a transaction that is merely early must not be evicted" + ); + assert!( + !mempool.is_invalidated(&tx_id), + "and it must certainly not be cached as invalid" + ); + assert_eq!( + mempool.len(), + 1, + "it stays pooled until the chain catches up" + ); +} + +/// The cleanup guard must not blunt cleanup either: a pooled transaction that +/// is genuinely invalid is still evicted and cached. Seeded through +/// `return_to_pool()`, which is how an unvalidated transaction really gets into +/// the pool — a reorg hands back the contents of the rolled-back blocks. +#[test] +fn revalidate_still_invalidates_genuine_failures() { + let mut config = test_config(); + config.cleanup_interval = std::time::Duration::ZERO; + let mut mempool = Mempool::new(config); + + // Unsatisfiable script, on time. Only the script is wrong. + let input = make_box(false, 6, TIP_HEIGHT - 1); + let utxo = StaticUtxo::new(std::slice::from_ref(&input)); + + let tx = spend_tx(&[input], TIP_HEIGHT); + let tx_id = tx_id_bytes(&tx); + let tx_bytes = tx.sigma_serialize_bytes().expect("tx serialization"); + let now = Instant::now(); + mempool.return_to_pool(vec![UnconfirmedTx { + cost: tx_bytes.len() as u32, + tx, + tx_bytes, + fee: 0, + created: now, + last_checked: now, + source: None, + }]); + assert_eq!(mempool.len(), 1, "setup: return_to_pool skips validation"); + + let removed = mempool.revalidate(&utxo, &state_context_at(TIP_HEIGHT)); + + assert_eq!(removed, vec![tx_id], "a real failure is still evicted"); + assert!( + mempool.is_invalidated(&tx_id), + "and still cached as invalid" + ); + assert_eq!(mempool.len(), 0); +} diff --git a/mempool/tests/expiring_cache_test.rs b/mempool/tests/expiring_cache_test.rs index e12f070..cb674d9 100644 --- a/mempool/tests/expiring_cache_test.rs +++ b/mempool/tests/expiring_cache_test.rs @@ -1,14 +1,11 @@ -use std::time::Duration; use std::thread; +use std::time::Duration; use ergo_mempool::expiring_cache::ExpiringCache; #[test] fn insert_and_contains() { - let mut cache: ExpiringCache<[u8; 32]> = ExpiringCache::new( - Duration::from_secs(60), - 100, - ); + let mut cache: ExpiringCache<[u8; 32]> = ExpiringCache::new(Duration::from_secs(60), 100); let key_a = [1u8; 32]; let key_b = [2u8; 32]; @@ -25,10 +22,7 @@ fn insert_and_contains() { #[test] fn insert_is_idempotent() { - let mut cache: ExpiringCache<[u8; 32]> = ExpiringCache::new( - Duration::from_secs(60), - 100, - ); + let mut cache: ExpiringCache<[u8; 32]> = ExpiringCache::new(Duration::from_secs(60), 100); let key = [1u8; 32]; cache.insert(key); @@ -57,7 +51,10 @@ fn capacity_evicts_oldest() { // Inserting a 4th should evict the oldest (key_a) cache.insert(key_d); assert_eq!(cache.len(), 3); - assert!(!cache.contains(&key_a), "oldest entry should have been evicted"); + assert!( + !cache.contains(&key_a), + "oldest entry should have been evicted" + ); assert!(cache.contains(&key_b)); assert!(cache.contains(&key_c)); assert!(cache.contains(&key_d)); @@ -77,7 +74,10 @@ fn expired_entries_not_found() { // Sleep long enough for the entry to expire thread::sleep(Duration::from_millis(5)); - assert!(!cache.contains(&key), "expired entry should not be found via contains()"); + assert!( + !cache.contains(&key), + "expired entry should not be found via contains()" + ); // Note: len() still counts expired entries until prune() is called assert_eq!(cache.len(), 1, "expired entry is still stored until pruned"); } @@ -108,15 +108,16 @@ fn prune_removes_expired() { assert!(!cache.contains(&key_a)); assert!(!cache.contains(&key_b)); assert!(cache.contains(&key_c)); - assert_eq!(cache.len(), 1, "only the fresh entry should remain after prune"); + assert_eq!( + cache.len(), + 1, + "only the fresh entry should remain after prune" + ); } #[test] fn clear_empties_cache() { - let mut cache: ExpiringCache<[u8; 32]> = ExpiringCache::new( - Duration::from_secs(60), - 100, - ); + let mut cache: ExpiringCache<[u8; 32]> = ExpiringCache::new(Duration::from_secs(60), 100); cache.insert([1u8; 32]); cache.insert([2u8; 32]); diff --git a/mempool/tests/fee_test.rs b/mempool/tests/fee_test.rs new file mode 100644 index 0000000..3684934 --- /dev/null +++ b/mempool/tests/fee_test.rs @@ -0,0 +1,175 @@ +//! Step 7a: the fee is the value of the outputs paying the fee proposition. +//! +//! The defect these pin down: `extract_fee` used to return +//! `input_sum - output_sum`, but ergo-lib enforces *exact* ERG preservation, so +//! that difference is structurally zero for every transaction that survives +//! validation. Under the default `min_fee` of 1,000,000 the mempool therefore +//! declined the entire network. Ergo has no implicit change-to-fee remainder — +//! the fee is an explicit output guarded by the fee proposition, which is what +//! the JVM's `ErgoMemPool.extractFee` filters on. + +mod common; + +use ergo_lib::ergotree_ir::serialization::SigmaSerializable; + +use common::{ + fee_tree, make_box, sigma_bool_tree, spend_tx_to, state_context_at, tx_id_bytes, StaticUtxo, + BOX_VALUE, TIP_HEIGHT, +}; +use ergo_mempool::process::extract_fee; +use ergo_mempool::types::{MempoolConfig, ProcessingOutcome}; +use ergo_mempool::Mempool; + +const FEE: u64 = 2_000_000; + +/// An explicit fee output is counted at its own value. +#[test] +fn fee_output_is_counted() { + let input = make_box(true, 1, TIP_HEIGHT - 1); + let tx = spend_tx_to( + &[input], + &[(BOX_VALUE - FEE, sigma_bool_tree(true)), (FEE, fee_tree())], + TIP_HEIGHT, + ); + + assert_eq!(extract_fee(&tx, &fee_tree()), FEE); +} + +/// The regression test for the original defect. Every transaction that ergo-lib +/// accepts has `input_sum == output_sum` — that is enforced, not incidental — so +/// the old difference-based implementation returned 0 here. Asserting the +/// balance explicitly is the point: it is exactly the condition under which the +/// old code was wrong. +#[test] +fn exact_erg_preservation_still_yields_a_nonzero_fee() { + let input = make_box(true, 2, TIP_HEIGHT - 1); + let tx = spend_tx_to( + &[input], + &[(BOX_VALUE - FEE, sigma_bool_tree(true)), (FEE, fee_tree())], + TIP_HEIGHT, + ); + + let input_sum = BOX_VALUE; + let output_sum: u64 = tx.outputs.iter().map(|b| *b.value.as_u64()).sum(); + assert_eq!( + input_sum, output_sum, + "ergo-lib enforces this for every valid transaction" + ); + + assert_eq!( + extract_fee(&tx, &fee_tree()), + FEE, + "input_sum - output_sum would be 0 here — that was the bug" + ); +} + +/// Several fee outputs sum, matching the JVM's `.filter(...).map(_.value).sum`. +#[test] +fn multiple_fee_outputs_sum() { + let input = make_box(true, 3, TIP_HEIGHT - 1); + let tx = spend_tx_to( + &[input], + &[ + (BOX_VALUE - 2 * FEE, sigma_bool_tree(true)), + (FEE, fee_tree()), + (FEE, fee_tree()), + ], + TIP_HEIGHT, + ); + + assert_eq!(extract_fee(&tx, &fee_tree()), 2 * FEE); +} + +/// Only the proposition decides. An ordinary output of identical value is not a +/// fee — the filter is on the guarding tree, never on the amount. +#[test] +fn non_fee_output_of_equal_value_is_not_counted() { + let input = make_box(true, 4, TIP_HEIGHT - 1); + let tx = spend_tx_to( + &[input], + &[ + (BOX_VALUE - FEE, sigma_bool_tree(true)), + // Same value as the fee in the other tests, ordinary script. + (FEE, sigma_bool_tree(true)), + ], + TIP_HEIGHT, + ); + + assert_eq!( + extract_fee(&tx, &fee_tree()), + 0, + "value alone must not make an output a fee" + ); +} + +/// No fee output means no fee. Declining at 7b is correct behaviour, not a +/// regression — the transaction genuinely pays the miner nothing. +#[test] +fn no_fee_output_yields_zero_and_is_declined() { + let mut mempool = Mempool::new(MempoolConfig::default()); + + let input = make_box(true, 5, TIP_HEIGHT - 1); + let tx = spend_tx_to( + std::slice::from_ref(&input), + &[(BOX_VALUE, sigma_bool_tree(true))], + TIP_HEIGHT, + ); + assert_eq!(extract_fee(&tx, &fee_tree()), 0); + + let tx_bytes = tx.sigma_serialize_bytes().expect("tx serialization"); + let outcome = mempool.process( + tx, + tx_bytes, + &StaticUtxo::new(&[input]), + &state_context_at(TIP_HEIGHT), + Some(7), + ); + + match &outcome { + ProcessingOutcome::Declined { reason } => { + assert!( + reason.contains("fee 0"), + "should decline on the fee, got: {reason}" + ); + } + other => panic!("expected Declined, got {other:?}"), + } + assert_eq!(mempool.len(), 0); +} + +/// End to end at the real default `min_fee`: a fee-paying transaction now +/// reaches the pool. This is the whole point of the change — before it, no +/// transaction on the network could clear step 7b at all. +#[test] +fn fee_paying_tx_is_accepted_at_the_default_min_fee() { + let config = MempoolConfig::default(); + assert_eq!( + config.min_fee, 1_000_000, + "guarding the premise of this test" + ); + assert!(FEE >= config.min_fee); + let mut mempool = Mempool::new(config); + + let input = make_box(true, 6, TIP_HEIGHT - 1); + let tx = spend_tx_to( + std::slice::from_ref(&input), + &[(BOX_VALUE - FEE, sigma_bool_tree(true)), (FEE, fee_tree())], + TIP_HEIGHT, + ); + let tx_id = tx_id_bytes(&tx); + let tx_bytes = tx.sigma_serialize_bytes().expect("tx serialization"); + + let outcome = mempool.process( + tx, + tx_bytes, + &StaticUtxo::new(&[input]), + &state_context_at(TIP_HEIGHT), + Some(7), + ); + + assert!( + matches!(outcome, ProcessingOutcome::Accepted { tx_id: id } if id == tx_id), + "a fee-paying transaction must reach the pool, got {outcome:?}" + ); + assert_eq!(mempool.get(&tx_id).expect("pooled").fee, FEE); +} diff --git a/mempool/tests/pool_test.rs b/mempool/tests/pool_test.rs index d70320b..3536392 100644 --- a/mempool/tests/pool_test.rs +++ b/mempool/tests/pool_test.rs @@ -1,20 +1,19 @@ use std::time::Instant; -use ergo_lib::chain::transaction::Transaction; use ergo_lib::chain::transaction::input::UnsignedInput; +use ergo_lib::chain::transaction::Transaction; +use ergo_lib::ergotree_ir::chain::context_extension::ContextExtension; use ergo_lib::ergotree_ir::chain::ergo_box::{ - BoxId, ErgoBox, ErgoBoxCandidate, NonMandatoryRegisters, - box_value::BoxValue, + box_value::BoxValue, BoxId, ErgoBox, ErgoBoxCandidate, NonMandatoryRegisters, }; -use ergo_lib::ergotree_ir::chain::context_extension::ContextExtension; use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; use ergo_lib::ergotree_ir::serialization::SigmaSerializable; use ergo_chain_types::Digest32; use ergo_mempool::pool::OrderedPool; -use ergo_mempool::weight::TxWeight; use ergo_mempool::types::{FeeStrategy, UnconfirmedTx}; +use ergo_mempool::weight::TxWeight; /// Minimal ErgoTree that represents `true` proposition. /// This is the simplest valid tree: header byte 0x00 + constant `true` as SigmaProp. @@ -91,11 +90,7 @@ fn make_tx(input_seed: u8, output_value: u64) -> Transaction { creation_height: 1, }; - Transaction::new_from_vec( - vec![input], - vec![], - vec![output], - ).unwrap() + Transaction::new_from_vec(vec![input], vec![], vec![output]).unwrap() } /// Extract tx_id as [u8; 32]. @@ -123,20 +118,26 @@ fn make_utx(tx: Transaction, fee: u64) -> UnconfirmedTx { /// Extract input box IDs from a Transaction. fn input_box_ids(tx: &Transaction) -> Vec<[u8; 32]> { - tx.inputs.iter().map(|i| { - let mut arr = [0u8; 32]; - arr.copy_from_slice(i.box_id.as_ref()); - arr - }).collect() + tx.inputs + .iter() + .map(|i| { + let mut arr = [0u8; 32]; + arr.copy_from_slice(i.box_id.as_ref()); + arr + }) + .collect() } /// Build output (box_id, ErgoBox) pairs from a Transaction. fn output_box_pairs(tx: &Transaction) -> Vec<([u8; 32], ErgoBox)> { - tx.outputs.iter().map(|b| { - let mut arr = [0u8; 32]; - arr.copy_from_slice(b.box_id().as_ref()); - (arr, b.clone()) - }).collect() + tx.outputs + .iter() + .map(|b| { + let mut arr = [0u8; 32]; + arr.copy_from_slice(b.box_id().as_ref()); + (arr, b.clone()) + }) + .collect() } // ----------------------------------------------------------------------- @@ -153,7 +154,13 @@ fn insert_and_lookup() { let outputs = output_box_pairs(&tx); let utx = make_utx(tx, 2_000_000); - let weight = TxWeight::new(tx_id, 2_000_000, utx.tx_bytes.len(), utx.cost, FeeStrategy::FeePerByte); + let weight = TxWeight::new( + tx_id, + 2_000_000, + utx.tx_bytes.len(), + utx.cost, + FeeStrategy::FeePerByte, + ); pool.insert(weight, utx, &input_ids, outputs); assert_eq!(pool.len(), 1); @@ -215,7 +222,13 @@ fn remove_cleans_indexes() { let output_box_id = outputs[0].0; let utx = make_utx(tx, 2_000_000); - let weight = TxWeight::new(tx_id, 2_000_000, utx.tx_bytes.len(), utx.cost, FeeStrategy::FeePerByte); + let weight = TxWeight::new( + tx_id, + 2_000_000, + utx.tx_bytes.len(), + utx.cost, + FeeStrategy::FeePerByte, + ); pool.insert(weight, utx, &input_ids, outputs); assert!(pool.contains(&tx_id)); @@ -227,8 +240,14 @@ fn remove_cleans_indexes() { assert!(removed.is_some()); assert_eq!(pool.len(), 0); assert!(!pool.contains(&tx_id)); - assert!(pool.spending_tx(&input_ids[0]).is_none(), "input index should be cleaned"); - assert!(pool.unconfirmed_box(&output_box_id).is_none(), "output index should be cleaned"); + assert!( + pool.spending_tx(&input_ids[0]).is_none(), + "input index should be cleaned" + ); + assert!( + pool.unconfirmed_box(&output_box_id).is_none(), + "output index should be cleaned" + ); } #[test] @@ -255,7 +274,13 @@ fn double_spend_detection() { let inputs_a = input_box_ids(&tx_a); let outputs_a = output_box_pairs(&tx_a); let utx_a = make_utx(tx_a, 3_000_000); - let w_a = TxWeight::new(id_a, 3_000_000, utx_a.tx_bytes.len(), utx_a.cost, FeeStrategy::FeePerByte); + let w_a = TxWeight::new( + id_a, + 3_000_000, + utx_a.tx_bytes.len(), + utx_a.cost, + FeeStrategy::FeePerByte, + ); pool.insert(w_a, utx_a, &inputs_a, outputs_a); // The input should be tracked as spent by tx_a @@ -268,7 +293,13 @@ fn double_spend_detection() { let inputs_b = input_box_ids(&tx_b); let outputs_b = output_box_pairs(&tx_b); let utx_b = make_utx(tx_b, 5_000_000); - let w_b = TxWeight::new(id_b, 5_000_000, utx_b.tx_bytes.len(), utx_b.cost, FeeStrategy::FeePerByte); + let w_b = TxWeight::new( + id_b, + 5_000_000, + utx_b.tx_bytes.len(), + utx_b.cost, + FeeStrategy::FeePerByte, + ); pool.insert(w_b, utx_b, &inputs_b, outputs_b); // spending_tx should now return tx_b (last writer wins in HashMap) @@ -298,14 +329,26 @@ fn evict_lowest() { let inputs = input_box_ids(&tx_low); let outputs = output_box_pairs(&tx_low); let utx = make_utx(tx_low, fee_low); - let w = TxWeight::new(id_low, fee_low, utx.tx_bytes.len(), utx.cost, FeeStrategy::FeePerByte); + let w = TxWeight::new( + id_low, + fee_low, + utx.tx_bytes.len(), + utx.cost, + FeeStrategy::FeePerByte, + ); pool.insert(w, utx, &inputs, outputs); // Insert high-fee tx let inputs = input_box_ids(&tx_high); let outputs = output_box_pairs(&tx_high); let utx = make_utx(tx_high, fee_high); - let w = TxWeight::new(id_high, fee_high, utx.tx_bytes.len(), utx.cost, FeeStrategy::FeePerByte); + let w = TxWeight::new( + id_high, + fee_high, + utx.tx_bytes.len(), + utx.cost, + FeeStrategy::FeePerByte, + ); pool.insert(w, utx, &inputs, outputs); assert_eq!(pool.len(), 2); @@ -322,14 +365,23 @@ fn evict_lowest() { fn lowest_weight_tracks_minimum() { let mut pool = OrderedPool::new(100); - assert!(pool.lowest_weight().is_none(), "empty pool has no lowest weight"); + assert!( + pool.lowest_weight().is_none(), + "empty pool has no lowest weight" + ); let tx1 = make_tx(1, 1_000_000); let id1 = tx_id_bytes(&tx1); let inputs = input_box_ids(&tx1); let outputs = output_box_pairs(&tx1); let utx = make_utx(tx1, 5_000_000); - let w1 = TxWeight::new(id1, 5_000_000, utx.tx_bytes.len(), utx.cost, FeeStrategy::FeePerByte); + let w1 = TxWeight::new( + id1, + 5_000_000, + utx.tx_bytes.len(), + utx.cost, + FeeStrategy::FeePerByte, + ); let expected_weight = w1.fee_per_factor; pool.insert(w1, utx, &inputs, outputs); @@ -341,7 +393,13 @@ fn lowest_weight_tracks_minimum() { let inputs = input_box_ids(&tx2); let outputs = output_box_pairs(&tx2); let utx = make_utx(tx2, 1_000_000); - let w2 = TxWeight::new(id2, 1_000_000, utx.tx_bytes.len(), utx.cost, FeeStrategy::FeePerByte); + let w2 = TxWeight::new( + id2, + 1_000_000, + utx.tx_bytes.len(), + utx.cost, + FeeStrategy::FeePerByte, + ); let lower_weight = w2.fee_per_factor; pool.insert(w2, utx, &inputs, outputs); @@ -359,7 +417,13 @@ fn is_full_respects_capacity() { let inputs = input_box_ids(&tx1); let outputs = output_box_pairs(&tx1); let utx = make_utx(tx1, 2_000_000); - let w = TxWeight::new(id1, 2_000_000, utx.tx_bytes.len(), utx.cost, FeeStrategy::FeePerByte); + let w = TxWeight::new( + id1, + 2_000_000, + utx.tx_bytes.len(), + utx.cost, + FeeStrategy::FeePerByte, + ); pool.insert(w, utx, &inputs, outputs); assert!(!pool.is_full()); @@ -368,7 +432,13 @@ fn is_full_respects_capacity() { let inputs = input_box_ids(&tx2); let outputs = output_box_pairs(&tx2); let utx = make_utx(tx2, 3_000_000); - let w = TxWeight::new(id2, 3_000_000, utx.tx_bytes.len(), utx.cost, FeeStrategy::FeePerByte); + let w = TxWeight::new( + id2, + 3_000_000, + utx.tx_bytes.len(), + utx.cost, + FeeStrategy::FeePerByte, + ); pool.insert(w, utx, &inputs, outputs); assert!(pool.is_full()); } @@ -387,7 +457,13 @@ fn tx_ids_returns_all() { let inputs = input_box_ids(&tx); let outputs = output_box_pairs(&tx); let utx = make_utx(tx, fee); - let w = TxWeight::new(tx_id, fee, utx.tx_bytes.len(), utx.cost, FeeStrategy::FeePerByte); + let w = TxWeight::new( + tx_id, + fee, + utx.tx_bytes.len(), + utx.cost, + FeeStrategy::FeePerByte, + ); pool.insert(w, utx, &inputs, outputs); } @@ -411,13 +487,25 @@ fn update_weight_reorders() { let inputs_a = input_box_ids(&tx_a); let outputs_a = output_box_pairs(&tx_a); let utx_a = make_utx(tx_a, 1_000_000); - let w_a = TxWeight::new(id_a, 1_000_000, utx_a.tx_bytes.len(), utx_a.cost, FeeStrategy::FeePerByte); + let w_a = TxWeight::new( + id_a, + 1_000_000, + utx_a.tx_bytes.len(), + utx_a.cost, + FeeStrategy::FeePerByte, + ); pool.insert(w_a, utx_a, &inputs_a, outputs_a); let inputs_b = input_box_ids(&tx_b); let outputs_b = output_box_pairs(&tx_b); let utx_b = make_utx(tx_b, 5_000_000); - let w_b = TxWeight::new(id_b, 5_000_000, utx_b.tx_bytes.len(), utx_b.cost, FeeStrategy::FeePerByte); + let w_b = TxWeight::new( + id_b, + 5_000_000, + utx_b.tx_bytes.len(), + utx_b.cost, + FeeStrategy::FeePerByte, + ); pool.insert(w_b, utx_b, &inputs_b, outputs_b); // Before update: tx_b is top @@ -428,5 +516,9 @@ fn update_weight_reorders() { pool.update_weight(&id_a, very_high); // Now tx_a should be top - assert_eq!(pool.top(1)[0].fee, 1_000_000, "boosted tx_a should now be at the top"); + assert_eq!( + pool.top(1)[0].fee, + 1_000_000, + "boosted tx_a should now be at the top" + ); } diff --git a/mempool/tests/stats_test.rs b/mempool/tests/stats_test.rs index ca62ffc..562dfe1 100644 --- a/mempool/tests/stats_test.rs +++ b/mempool/tests/stats_test.rs @@ -4,9 +4,18 @@ use ergo_mempool::stats::FeeStats; fn empty_stats_returns_none() { let stats = FeeStats::new(100); - assert!(stats.histogram(5).is_empty(), "empty stats should produce empty histogram"); - assert!(stats.expected_wait(1000).is_none(), "empty stats should return None for expected_wait"); - assert!(stats.recommended_fee(1000).is_none(), "empty stats should return None for recommended_fee"); + assert!( + stats.histogram(5).is_empty(), + "empty stats should produce empty histogram" + ); + assert!( + stats.expected_wait(1000).is_none(), + "empty stats should return None for expected_wait" + ); + assert!( + stats.recommended_fee(1000).is_none(), + "empty stats should return None for recommended_fee" + ); } #[test] @@ -21,16 +30,25 @@ fn record_and_histogram() { } let buckets = stats.histogram(3); - assert!(!buckets.is_empty(), "histogram should not be empty after recording data"); + assert!( + !buckets.is_empty(), + "histogram should not be empty after recording data" + ); // Total entries across all buckets should equal 10 let total: usize = buckets.iter().map(|b| b.count).sum(); - assert_eq!(total, 10, "total bucket counts should equal number of recorded confirmations"); + assert_eq!( + total, 10, + "total bucket counts should equal number of recorded confirmations" + ); // Buckets should be non-overlapping and cover the fee range for b in &buckets { assert!(b.count > 0, "empty buckets should be filtered out"); - assert!(b.min_fee < b.max_fee, "bucket min_fee should be less than max_fee"); + assert!( + b.min_fee < b.max_fee, + "bucket min_fee should be less than max_fee" + ); assert!(b.avg_wait_ms > 0, "avg_wait should be positive"); } } @@ -45,7 +63,11 @@ fn histogram_single_fee_value() { } let buckets = stats.histogram(3); - assert_eq!(buckets.len(), 1, "single fee value produces a single bucket"); + assert_eq!( + buckets.len(), + 1, + "single fee value produces a single bucket" + ); assert_eq!(buckets[0].count, 5); assert_eq!(buckets[0].avg_wait_ms, 3000); } @@ -56,7 +78,10 @@ fn histogram_zero_buckets() { stats.record_confirmation(1000, 3000); let buckets = stats.histogram(0); - assert!(buckets.is_empty(), "zero buckets should produce empty result"); + assert!( + buckets.is_empty(), + "zero buckets should produce empty result" + ); } #[test] @@ -72,7 +97,10 @@ fn expected_wait() { assert_eq!(wait, 1000, "exact fee match should return exact wait time"); let wait_low = stats.expected_wait(1000).unwrap(); - assert_eq!(wait_low, 5000, "exact low fee match should return high wait"); + assert_eq!( + wait_low, 5000, + "exact low fee match should return high wait" + ); // Query for a fee between the two — should find closest let wait_mid = stats.expected_wait(3000).unwrap(); @@ -98,15 +126,18 @@ fn recommended_fee() { let mut stats = FeeStats::new(100); stats.record_confirmation(1000, 10000); // low fee, 10s wait - stats.record_confirmation(3000, 5000); // mid fee, 5s wait - stats.record_confirmation(5000, 2000); // high fee, 2s wait - stats.record_confirmation(8000, 500); // very high fee, 0.5s wait + stats.record_confirmation(3000, 5000); // mid fee, 5s wait + stats.record_confirmation(5000, 2000); // high fee, 2s wait + stats.record_confirmation(8000, 500); // very high fee, 0.5s wait // Target 6000ms — fees 1000 (10000ms) and 3000 (5000ms) and 5000 (2000ms) and 8000 (500ms) // Fees with wait <= 6000: 3000 (5000ms), 5000 (2000ms), 8000 (500ms) // Lowest qualifying fee: 3000 let fee = stats.recommended_fee(6000).unwrap(); - assert_eq!(fee, 3000, "should recommend the lowest fee that achieved target wait"); + assert_eq!( + fee, 3000, + "should recommend the lowest fee that achieved target wait" + ); // Target 100ms — only fee 8000 has wait <= 100... actually 500 > 100 // None qualify diff --git a/mempool/tests/weight_test.rs b/mempool/tests/weight_test.rs index 1803e75..dcaed4d 100644 --- a/mempool/tests/weight_test.rs +++ b/mempool/tests/weight_test.rs @@ -13,21 +13,33 @@ fn fee_per_byte_computation() { // fee_per_factor = fee * 1024 / size = 1_000_000 * 1024 / 500 = 2_048_000 let w = TxWeight::new(make_tx_id(1), 1_000_000, 500, 0, FeeStrategy::FeePerByte); assert_eq!(w.fee_per_factor, 1_000_000 * 1024 / 500); - assert_eq!(w.weight, w.fee_per_factor, "initial weight equals fee_per_factor"); + assert_eq!( + w.weight, w.fee_per_factor, + "initial weight equals fee_per_factor" + ); } #[test] fn fee_per_byte_zero_size() { // Zero-size tx should not divide by zero let w = TxWeight::new(make_tx_id(1), 1_000_000, 0, 0, FeeStrategy::FeePerByte); - assert_eq!(w.fee_per_factor, 0, "zero-size tx gives zero fee_per_factor"); + assert_eq!( + w.fee_per_factor, 0, + "zero-size tx gives zero fee_per_factor" + ); } #[test] fn fee_per_cycle_computation() { // fee = 2_000_000, cost = 1000 // fee_per_factor = fee * 1024 / cost = 2_000_000 * 1024 / 1000 = 2_048_000 - let w = TxWeight::new(make_tx_id(1), 2_000_000, 500, 1000, FeeStrategy::FeePerCycle); + let w = TxWeight::new( + make_tx_id(1), + 2_000_000, + 500, + 1000, + FeeStrategy::FeePerCycle, + ); assert_eq!(w.fee_per_factor, 2_000_000 * 1024 / 1000); } @@ -84,7 +96,11 @@ fn ord_consistent_for_same_weight_and_id() { let id = make_tx_id(1); let w1 = TxWeight::new(id, 1_000_000, 500, 0, FeeStrategy::FeePerByte); let w2 = TxWeight::new(id, 1_000_000, 500, 0, FeeStrategy::FeePerByte); - assert_eq!(w1.cmp(&w2), Ordering::Equal, "same weight+id should be Ordering::Equal"); + assert_eq!( + w1.cmp(&w2), + Ordering::Equal, + "same weight+id should be Ordering::Equal" + ); assert_eq!(w1.weight, w2.weight); assert_eq!(w1.fee_per_factor, w2.fee_per_factor); assert_eq!(w1.tx_id, w2.tx_id); diff --git a/mining/CLAUDE.md b/mining/CLAUDE.md index 373e13d..cd49f81 100644 --- a/mining/CLAUDE.md +++ b/mining/CLAUDE.md @@ -37,7 +37,11 @@ This crate does NOT own: - `ergo-chain-types` — Header, Autolykos PoW, compact nBits - `ergo-merkle-tree` — transactions root - `sigma-ser` — serialization -- `enr-chain` — header/PoW primitives +- `enr-chain` — header/PoW primitives, and `pow_target(n_bits)`: the single + sanctioned definition of the Autolykos target served as `WorkMessage.b`. + Never re-derive it from `decode_compact_bits` here — that returns the + difficulty, and re-deriving it is exactly how v0.8.0 shipped a serve path + no miner could satisfy. ## JVM Reference diff --git a/mining/Cargo.toml b/mining/Cargo.toml index 46d842d..4e9b4d4 100644 --- a/mining/Cargo.toml +++ b/mining/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergo-mining" -version = "0.1.0" +version.workspace = true edition = "2021" license = "MIT" description = "Block candidate assembly and PoW solution validation for external miners" @@ -10,6 +10,12 @@ ergo-lib = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" ergo-chain-types = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } ergo-nipopow = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } ergo-merkle-tree = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } +# pow_target(n_bits) — the single sanctioned definition of the Autolykos +# mining target served as WorkMessage.b. This crate re-derived it from +# decode_compact_bits until v0.8.0 and stopped one division short, which is +# how it came to serve the difficulty. No cycle: chain/ has no in-repo path +# dependencies. BigInt is re-exported from the crate root. +enr-chain = { path = "../chain" } ergo-validation = { path = "../validation" } sigma-ser = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } blake2 = "0.10" diff --git a/mining/src/candidate.rs b/mining/src/candidate.rs index 5e09ea3..a120eb8 100644 --- a/mining/src/candidate.rs +++ b/mining/src/candidate.rs @@ -1,10 +1,7 @@ //! Header construction and WorkMessage derivation for mining candidates. use blake2::Digest as Blake2Digest; -use ergo_chain_types::autolykos_pow_scheme::decode_compact_bits; -use ergo_chain_types::{ - AutolykosSolution, BlockId, Digest, Digest32, EcPoint, Header, Votes, -}; +use ergo_chain_types::{AutolykosSolution, BlockId, Digest, Digest32, EcPoint, Header, Votes}; use ergo_lib::chain::transaction::Transaction; use ergo_merkle_tree::{MerkleNode, MerkleTree}; @@ -21,10 +18,7 @@ type Blake2b256 = blake2::Blake2b; /// witness IDs (`txIds ++ witnessIds` — two concatenated lists, not /// interleaved). Mainnet and testnet are both version >= 2 today, so a /// tx-IDs-only root is rejected by every JVM peer. -pub fn transactions_root( - txs: &[Transaction], - block_version: u8, -) -> Result { +pub fn transactions_root(txs: &[Transaction], block_version: u8) -> Result { if txs.is_empty() { return Err(MiningError::AssemblyFailed("no transactions".into())); } @@ -124,8 +118,15 @@ pub fn build_work_message( hash }; - // b = target from nBits - let target = decode_compact_bits(candidate.n_bits); + // b = the Autolykos TARGET: q / decode_compact_bits(n_bits), where q is the + // secp256k1 group order. It is NOT decode_compact_bits(n_bits) itself — + // that is the DIFFICULTY, and serving it is what made v0.8.0 unmineable: + // external miners were handed a bound tens of orders of magnitude too tight + // and submitted zero shares at any hashrate, while `check_pow` on the + // solution path — dividing correctly — stood ready to accept a block nobody + // could find. `enr_chain::pow_target` is the one definition of this value; + // do not re-derive the division here. See ../facts/chain.md § "Phase 2". + let target = enr_chain::pow_target(candidate.n_bits); // pk = compressed EcPoint hex let pk_hex: String = (*miner_pk).into(); diff --git a/mining/src/emission.rs b/mining/src/emission.rs index 69ae5f5..baa7ee7 100644 --- a/mining/src/emission.rs +++ b/mining/src/emission.rs @@ -163,9 +163,8 @@ pub fn build_emission_tx( .map_err(|e| MiningError::Emission(format!("emission box build: {e}")))?; // --- Output 1: miner reward box (time-locked) --- - let reward_script = - ergo_tree_predef::reward_output_script(reward_delay, miner_pk.clone()) - .map_err(|e| MiningError::Emission(format!("reward script: {e}")))?; + let reward_script = ergo_tree_predef::reward_output_script(reward_delay, miner_pk.clone()) + .map_err(|e| MiningError::Emission(format!("reward script: {e}")))?; let mut reward_builder = ErgoBoxCandidateBuilder::new( (miner_reward as u64) @@ -276,15 +275,9 @@ mod tests { fn reemission_at_floor() { let rules = ReemissionRules::mainnet(); // Total emission exactly 3 ERG → 0 (not > floor) - assert_eq!( - rules.reemission_for_height(2_500_000, 3_000_000_000), - 0 - ); + assert_eq!(rules.reemission_for_height(2_500_000, 3_000_000_000), 0); // Total emission below floor → 0 - assert_eq!( - rules.reemission_for_height(2_500_000, 2_000_000_000), - 0 - ); + assert_eq!(rules.reemission_for_height(2_500_000, 2_000_000_000), 0); } #[test] @@ -307,9 +300,6 @@ mod tests { 14_999_999_999 - 3_000_000_000 ); // Just above floor - assert_eq!( - rules.reemission_for_height(h, 3_000_000_001), - 1 - ); + assert_eq!(rules.reemission_for_height(h, 3_000_000_001), 1); } } diff --git a/mining/src/extension.rs b/mining/src/extension.rs index b719f55..df6af3c 100644 --- a/mining/src/extension.rs +++ b/mining/src/extension.rs @@ -36,9 +36,7 @@ pub fn unpack_parent_interlinks(parent_extension_bytes: &[u8]) -> Vec { let ec = match ErgoExtensionCandidate::new(fields) { Ok(ec) => ec, Err(e) => { - tracing::warn!( - "mining: ExtensionCandidate::new failed: {e}; using empty interlinks" - ); + tracing::warn!("mining: ExtensionCandidate::new failed: {e}; using empty interlinks"); return vec![]; } }; diff --git a/mining/src/fee.rs b/mining/src/fee.rs index a6ad973..e8dc83b 100644 --- a/mining/src/fee.rs +++ b/mining/src/fee.rs @@ -6,26 +6,44 @@ use ergo_lib::chain::transaction::input::prover_result::ProverResult; use ergo_lib::chain::transaction::{Input, Transaction}; use ergo_lib::ergotree_interpreter::sigma_protocol::prover::ProofBytes; use ergo_lib::ergotree_ir::chain::context_extension::ContextExtension; -use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; -use ergo_lib::ergotree_ir::chain::token::Token; -use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; +use ergo_lib::ergotree_ir::chain::ergo_box::box_value::BoxValue; +use ergo_lib::ergotree_ir::chain::ergo_box::{ + BoxTokens, ErgoBox, ErgoBoxCandidate, NonMandatoryRegisters, +}; +use ergo_lib::ergotree_ir::chain::token::{Token, TokenId}; +use ergo_lib::ergotree_ir::chain::tx_id::TxId; +use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; use ergo_lib::ergotree_ir::serialization::SigmaSerializable; +use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; use std::collections::{HashMap, HashSet}; use crate::MiningError; /// Build the fee collection transaction. /// -/// Scans outputs of `selected_txs` for boxes matching the fee proposition. -/// Aggregates all fee values into a single miner reward box. Fee boxes -/// that are spent by other selected transactions within the same block are -/// excluded (they belong to chained transactions, not the miner). +/// Scans outputs of `block_txs` — every transaction the block carries so far, +/// emission transaction included, matching the JVM's `newTxs` — for boxes +/// matching the fee proposition. Aggregates all fee values into a single +/// miner reward box. Fee boxes that are spent by another transaction within +/// the same block are excluded (they belong to chained transactions, not the +/// miner). +/// +/// `height` is the CANDIDATE's height: the fee proposition requires +/// `HEIGHT == creationHeight(OUTPUTS(0))`, so the miner box is created at the +/// height the block will have (JVM `collectRewards`, `nextHeight`). +/// +/// Tokens carried by the fee boxes are aggregated onto the miner box and +/// capped by the reward box's MEASURED serialized size, not by a token count; +/// the excess is burned rather than allowed to make the box unbuildable — or, +/// worse, buildable but invalid — and cost the miner every fee in the block. +/// See [`fit_tokens`]. /// -/// Returns None if total fee is zero or no fee boxes exist. +/// Returns None if total fee is zero or no fee boxes exist — a zero-fee block +/// is a normal outcome, not an error. /// /// Reference: JVM `CandidateGenerator.collectFees()`. pub fn build_fee_tx( - selected_txs: &[Transaction], + block_txs: &[Transaction], height: u32, reward_delay: i32, miner_pk: &ProveDlog, @@ -37,8 +55,8 @@ pub fn build_fee_tx( .sigma_serialize_bytes() .map_err(|e| MiningError::Emission(format!("fee tree serialize: {e}")))?; - // Collect all input box IDs across selected txs (these outputs are spent, not fees) - let spent_ids: HashSet<[u8; 32]> = selected_txs + // Every box spent by the block so far — such an output is a chained spend, not a fee + let spent_ids: HashSet<[u8; 32]> = block_txs .iter() .flat_map(|tx| tx.inputs.iter()) .map(|input| { @@ -48,18 +66,15 @@ pub fn build_fee_tx( }) .collect(); - // Collect fee outputs from selected transactions + // Collect fee outputs from the block's transactions let mut fee_boxes: Vec<&ErgoBox> = Vec::new(); - for tx in selected_txs { + for tx in block_txs { for output in tx.outputs.iter() { - let output_tree_bytes = output - .ergo_tree - .sigma_serialize_bytes() - .unwrap_or_default(); + let output_tree_bytes = output.ergo_tree.sigma_serialize_bytes().unwrap_or_default(); if output_tree_bytes == fee_tree_bytes { let mut id = [0u8; 32]; id.copy_from_slice(output.box_id().as_ref()); - // Only include if not spent by another selected tx + // Only include if not spent by another transaction in the block if !spent_ids.contains(&id) { fee_boxes.push(output); } @@ -76,17 +91,57 @@ pub fn build_fee_tx( return Ok(None); } - // Aggregate tokens from fee boxes (capped at what fits in a single box) - let mut token_map: HashMap = HashMap::new(); + // Aggregate the fee boxes' tokens, in first-seen order. + // + // Order rather than `HashMap` iteration because it decides two things that + // must not depend on a hash seed: which tokens survive the cap below, and + // the reward box's serialized bytes — hence its id, the fee transaction's + // id, and the transactions root the miner is handed to hash. The traversal + // matches the JVM's `feeBoxes.toArray.toColl.flatMap(_.additionalTokens)`: + // fee boxes in block order, tokens in box order. + let mut aggregated: Vec<(TokenId, u64)> = Vec::new(); + let mut position: HashMap = HashMap::new(); for fee_box in &fee_boxes { - if let Some(ref tokens) = fee_box.tokens { - let token_slice: &[Token] = tokens.as_ref(); - for token in token_slice { - *token_map.entry(token.token_id).or_insert(0) += u64::from(token.amount); + let Some(ref tokens) = fee_box.tokens else { + continue; + }; + let token_slice: &[Token] = tokens.as_ref(); + for token in token_slice { + let amount = u64::from(token.amount); + match position.get(&token.token_id) { + Some(&at) => aggregated[at].1 = aggregated[at].1.saturating_add(amount), + None => { + position.insert(token.token_id, aggregated.len()); + aggregated.push((token.token_id, amount)); + } } } } + // Build the miner reward output. + let reward_script = ergo_tree_predef::reward_output_script(reward_delay, miner_pk.clone()) + .map_err(|e| MiningError::Emission(format!("reward script: {e}")))?; + let reward_value: BoxValue = (total_fee as u64) + .try_into() + .map_err(|e| MiningError::Emission(format!("fee value: {e}")))?; + + // Cap at what one box can hold, measured rather than counted — see + // `fit_tokens`. The dropped tokens are burned: their fee boxes are still + // spent as inputs and Ergo permits an output carrying fewer tokens than + // its inputs. The JVM's `take(MaxAssetsPerBox)` burns them too, just at + // the wrong limit. + let kept = fit_tokens(&aggregated, reward_value, &reward_script, height)?; + if kept.len() < aggregated.len() { + tracing::warn!( + height, + distinct_tokens = aggregated.len(), + collected = kept.len(), + burned = aggregated.len() - kept.len(), + "mining: fee boxes carry more distinct tokens than one reward box \ + holds; burning the excess so the block still collects its fees" + ); + } + // Build inputs from fee boxes (empty proofs — fee proposition allows same-block spending) let inputs: Vec = fee_boxes .iter() @@ -101,27 +156,9 @@ pub fn build_fee_tx( }) .collect(); - // Build miner reward output - let reward_script = - ergo_tree_predef::reward_output_script(reward_delay, miner_pk.clone()) - .map_err(|e| MiningError::Emission(format!("reward script: {e}")))?; - - let mut reward_builder = ErgoBoxCandidateBuilder::new( - (total_fee as u64) - .try_into() - .map_err(|e| MiningError::Emission(format!("fee value: {e}")))?, - reward_script, - height, - ); - - // Add aggregated tokens to reward box - for (token_id, amount) in token_map { - reward_builder.add_token(Token { - token_id, - amount: amount - .try_into() - .map_err(|e| MiningError::Emission(format!("fee token amount: {e}")))?, - }); + let mut reward_builder = ErgoBoxCandidateBuilder::new(reward_value, reward_script, height); + for token in kept { + reward_builder.add_token(token); } let reward_candidate = reward_builder @@ -133,3 +170,92 @@ pub fn build_fee_tx( Ok(Some(tx)) } + +/// The longest PREFIX of `aggregated` that keeps the reward box inside the +/// box-size window — measured, not derived. +/// +/// The rule is `txBoxSize`: `out.bytes.length <= MaxBoxSize` (4096), JVM +/// `ErgoTransaction.scala:175`, ergo-lib `BoxSizeExceeded`. It is checked +/// against the bytes a validator counts — `ErgoBox::sigma_serialize`, +/// transaction id and output index included, not the candidate body, which is +/// 33 bytes shorter and would put the cut one token too high. +/// +/// ⚠ Nothing here is arithmetic over assumed widths. The reward tree's length +/// is a function of `reward_delay` and the miner key; a token entry is 32 +/// bytes plus a VLQ amount, not a fixed 33. A table of constants would have to +/// be re-derived every time either moved, and being wrong by one byte means a +/// box that cannot validate and a block that collects nothing — the exact +/// failure this cap exists to prevent. +/// +/// ⚠ A prefix, not a greedy fill. The surviving set stays a plain take in +/// first-seen traversal order, which is what makes two runs over identical fee +/// boxes produce identical box bytes — hence the same box id, transaction id, +/// and transactions root the miner is handed to hash. +/// +/// The other rule on an output box — dust, +/// `out.value >= out.bytes.length * minValuePerByte` — cannot bind here and is +/// deliberately not checked. Every fee box collected is an output of a +/// transaction this node already validated, so it cleared that same rule +/// carrying those same tokens under a **105-byte** fee proposition; the reward +/// box carries them under a **54-byte** reward script. Values add across fee +/// boxes while the box overhead is paid once, and a token in two fee boxes +/// becomes one entry here, freeing 32 bytes against at most a byte or two of +/// VLQ growth on the summed amount. The reward box is therefore strictly +/// cheaper per byte than the boxes that fed it. A guard here would be +/// unreachable, and — pinned to a constant rather than the block's voted +/// `minValuePerByte`, which this function is not given — could only ever fire +/// wrongly, burning tokens that would have validated. +fn fit_tokens( + aggregated: &[(TokenId, u64)], + value: BoxValue, + script: &ErgoTree, + height: u32, +) -> Result, MiningError> { + let mut kept: Vec = Vec::new(); + // `BoxTokens` is a bounded vec. The size rule stops the loop long before + // this — 255 minimal tokens are 8415 bytes on their own — but the bound is + // structural, not a consequence of the loop. + for (token_id, amount) in aggregated.iter().take(ErgoBox::MAX_TOKENS_COUNT) { + kept.push(Token { + token_id: *token_id, + amount: (*amount) + .try_into() + .map_err(|e| MiningError::Emission(format!("fee token amount: {e}")))?, + }); + if reward_box_size(value, script, &kept, height)? > ErgoBox::MAX_BOX_SIZE { + kept.pop(); + break; + } + } + Ok(kept) +} + +/// Serialized length of the reward box exactly as a validator measures it. +/// +/// The placeholder transaction id measures the real box to the byte: an id is +/// a 32-byte digest whatever its value, and the index is 0 either way — the +/// reward box is the fee transaction's only output. +fn reward_box_size( + value: BoxValue, + script: &ErgoTree, + tokens: &[Token], + height: u32, +) -> Result { + let candidate = ErgoBoxCandidate { + value, + ergo_tree: script.clone(), + tokens: match tokens { + [] => None, + t => Some( + BoxTokens::from_vec(t.to_vec()) + .map_err(|e| MiningError::Emission(format!("fee tokens: {e}")))?, + ), + }, + additional_registers: NonMandatoryRegisters::empty(), + creation_height: height, + }; + ErgoBox::from_box_candidate(&candidate, TxId::zero(), 0) + .and_then(|b| b.sigma_serialize_bytes()) + .map(|bytes| bytes.len()) + .map_err(|e| MiningError::Emission(format!("fee reward box measure: {e}"))) +} diff --git a/mining/src/lib.rs b/mining/src/lib.rs index b98a6b2..bce4634 100644 --- a/mining/src/lib.rs +++ b/mining/src/lib.rs @@ -8,13 +8,20 @@ pub mod types; pub use types::*; +use std::collections::HashMap; use std::sync::RwLock; use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use ergo_chain_types::{ADDigest, BlockId, Header}; +use ergo_chain_types::{ADDigest, AutolykosSolution, BlockId, Digest, Digest32, Header, Votes}; +use ergo_lib::chain::parameters::Parameters; use ergo_lib::chain::transaction::Transaction; use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; -use ergo_validation::ValidationError; +use ergo_lib::ergotree_ir::serialization::SigmaSerializable; +use ergo_validation::{ + build_state_context, validate_single_transaction, ErgoStateContext, ValidationError, +}; + +use crate::selection::{id_bytes, CostedTx}; /// Errors from mining operations. #[derive(Debug, thiserror::Error)] @@ -47,12 +54,39 @@ pub enum MiningError { /// AD proofs + resulting state digest for a candidate's transactions. pub type ValidatorProofsResult = Option, ADDigest), ValidationError>>; +/// The product of a candidate generation: the block, the miner-facing work +/// message derived from it, and the mempool bookkeeping it produced. +pub struct GeneratedCandidate { + /// The assembled candidate — `[emission_tx, selected.., fee_tx?]`. + pub block: CandidateBlock, + /// Work message derived from the candidate's header. + pub work: WorkMessage, + /// Ids of candidate transactions that failed validation during selection. + /// Report these to the mempool for eviction (contract Step 3.6). Empty + /// when no mempool transactions were offered. + pub invalid_txs: Vec<[u8; 32]>, +} + /// Generate a block candidate from the current chain state. /// -/// Builds the emission transaction, computes the state root via the validator, -/// and assembles the header + WorkMessage for miners. Currently produces -/// empty-mempool candidates (emission tx only). Transaction selection and -/// fee collection are added in later tasks. +/// Performs contract steps 1–8: emission transaction, mempool selection, fee +/// collection, state root, extension, assembly, work message. **This is the +/// only supported way to produce a `CandidateBlock`** — assembling one +/// field-by-field outside the crate is a contract violation, and it is what +/// left selection and fee collection dead through v0.8.0 development. +/// +/// The caller owns the mempool, the chain and the UTXO set; this crate owns +/// the assembly. So the caller passes in: +/// - `candidate_txs` — prioritized mempool transactions with their serialized +/// sizes (`mempool.all_prioritized()`), highest fee-rate first +/// - `parameters` — the ACTIVE protocol parameters, for `max_block_cost` and +/// `max_block_size`. Not `boundary_params`: those are the parameters the +/// epoch boundary will *install*, and the JVM likewise bounds assembly with +/// `stateContext.currentParameters` (`CandidateGenerator.scala:591-598`) +/// - `ancestor_headers` — headers before `parent`, newest first. `parent` is +/// prepended internally to form the ≤10-header window the upcoming block's +/// `ErgoStateContext` needs, so this may be empty near genesis +/// - `utxo_lookup` — resolve a box id against the UTXO set /// /// `validator_proofs` is a closure that calls /// `validator.proofs_for_transactions()`. This avoids the mining crate @@ -64,10 +98,14 @@ pub fn generate_candidate( n_bits: u32, parent_interlinks: &[BlockId], emission_box: &ErgoBox, - boundary_params: Option<&ergo_lib::chain::parameters::Parameters>, + boundary_params: Option<&Parameters>, proposed_update_bytes: &[u8], + candidate_txs: &[(Transaction, usize)], + parameters: &Parameters, + ancestor_headers: &[Header], + utxo_lookup: &dyn Fn(&[u8; 32]) -> Option, validator_proofs: &dyn Fn(&[Transaction]) -> ValidatorProofsResult, -) -> Result<(CandidateBlock, WorkMessage), MiningError> { +) -> Result { let height = parent.height + 1; let timestamp = { let now = SystemTime::now() @@ -76,8 +114,9 @@ pub fn generate_candidate( .as_millis() as u64; std::cmp::max(now, parent.timestamp + 1) }; + let version = parent.version; - // 1. Build emission transaction + // 1. Build emission transaction — always first in the block. let emission_tx = emission::build_emission_tx( emission_box, height, @@ -86,15 +125,41 @@ pub fn generate_candidate( &config.reemission_rules, )?; - // 2. Transaction list: [emission_tx] (no mempool txs yet, no fee tx) - let transactions = vec![emission_tx]; + // 2-4. Select mempool transactions and collect their fees. Yields the + // full ordered list [emission, selected.., fee?]. + let (transactions, invalid_txs) = if candidate_txs.is_empty() { + // Empty mempool: emission only. Short-circuited rather than run + // through selection, so no state context is built and the emission + // transaction is not re-validated for a budget nothing competes for. + (vec![emission_tx], Vec::new()) + } else { + let upcoming = upcoming_header(parent, version, n_bits, height, timestamp, config); + let mut window = Vec::with_capacity(1 + ancestor_headers.len().min(9)); + window.push(parent.clone()); + window.extend(ancestor_headers.iter().take(9).cloned()); + let state_context = build_state_context(&upcoming, &window, parameters); + + select_and_collect_fees( + config, + height, + version, + emission_tx, + emission_box, + candidate_txs, + parameters, + &state_context, + utxo_lookup, + )? + }; - // 3. Compute state root and AD proofs via validator + // 5. Compute state root and AD proofs via validator let (ad_proof_bytes, state_root) = validator_proofs(&transactions) - .ok_or(MiningError::Unavailable("UTXO mode required for mining".into()))? + .ok_or(MiningError::Unavailable( + "UTXO mode required for mining".into(), + ))? .map_err(MiningError::Validation)?; - // 4. Build extension + // 6. Build extension let extension = extension::build_extension( parent, parent_interlinks, @@ -102,10 +167,10 @@ pub fn generate_candidate( proposed_update_bytes, )?; - // 5. Assemble candidate + // 7. Assemble candidate let mut block = CandidateBlock { parent: parent.clone(), - version: parent.version, + version, n_bits, state_root, ad_proof_bytes, @@ -116,11 +181,278 @@ pub fn generate_candidate( header_bytes: vec![], }; - // 6. Build WorkMessage (also fills header_bytes) + // 8. Build WorkMessage (also fills header_bytes) let (header_bytes, work) = candidate::build_work_message(&block, &config.miner_pk.h)?; block.header_bytes = header_bytes; - Ok((block, work)) + Ok(GeneratedCandidate { + block, + work, + invalid_txs, + }) +} + +/// The header the candidate's transactions will execute under. +/// +/// Only the `PreHeader` fields matter — that is all `build_state_context` +/// takes from it — but the miner public key among them is load-bearing: the +/// fee proposition compares `OUTPUTS(0)`'s script against +/// `expectedMinerOutScriptBytes(delay, MINER_PUBKEY)`, and `MINER_PUBKEY` +/// comes from here. A stub carrying anything but the configured miner key +/// makes every fee transaction fail validation. +fn upcoming_header( + parent: &Header, + version: u8, + n_bits: u32, + height: u32, + timestamp: u64, + config: &MinerConfig, +) -> Header { + Header { + version, + id: BlockId(Digest::from([0u8; 32])), + parent_id: parent.id, + ad_proofs_root: Digest32::from([0u8; 32]), + state_root: parent.state_root, + transaction_root: Digest32::from([0u8; 32]), + timestamp, + n_bits, + height, + extension_root: Digest32::from([0u8; 32]), + autolykos_solution: AutolykosSolution { + miner_pk: config.miner_pk.h.clone(), + pow_onetime_pk: None, + nonce: vec![0u8; 8], + pow_distance: None, + }, + votes: Votes(config.votes), + unparsed_bytes: Box::new([]), + } +} + +/// Contract steps 3 and 4: select from the mempool, collect the fees the +/// selection created, and return `[emission, selected.., fee?]`. +/// +/// ## How the fee transaction is counted against the block limits +/// +/// The fee transaction is a transaction in the block: it has serialized size +/// and it has script cost, and both are charged to `max_block_size` / +/// `max_block_cost` by every validator (ours sums per-transaction costs over +/// the whole block — `validation/src/tx_validation.rs`, `enforce_block_cost`; +/// the size limit is enforced over the serialized BlockTransactions section, +/// so `block_transactions_overhead` is part of the measurement here too). +/// `select_transactions` accumulates over mempool transactions only and knows +/// nothing about it, so selecting up to `max_block_cost` and *then* appending +/// a fee transaction produces exactly the over-limit block the cost bound +/// exists to prevent. The JVM checks limits over the whole set including the +/// recomputed fee transaction (`correctLimits(blockTxs, ..)`, +/// `CandidateGenerator.scala:901`). +/// +/// The arrangement here is measure-then-fit, in two parts: +/// +/// 1. **The emission transaction is reserved before selecting.** It is known +/// up front, so it is validated once and passed to `select_transactions` +/// as pre-committed cost and size. Selection then bounds +/// `emission + selected` rather than `selected` alone. +/// 2. **The fee transaction is measured after selecting, and selected +/// transactions are dropped from the tail until the whole set fits.** It +/// cannot be reserved: it does not exist until the selection does, and its +/// inputs *are* the selection's fee outputs. Reserving a guessed allowance +/// would be an estimate; this is a measurement. +/// +/// Dropping from the tail is both the cheapest and the safest direction. +/// Candidates arrive highest-fee-rate first, so the tail is the least +/// valuable; and a selected transaction can only ever spend outputs of an +/// *earlier* selected one (`select_transactions` publishes a transaction's +/// outputs only after committing it), so intra-block dependencies point +/// backwards and dropping from the tail can never orphan a transaction that +/// is kept. Each drop strictly shrinks the total — the transaction's own cost +/// and size go, and its fee box leaves the fee transaction — so the loop +/// terminates, at worst at emission-only. +/// +/// The common case costs one fee-transaction build and validation. The +/// alternative arrangement — the JVM's, rebuilding and revalidating the fee +/// transaction after every accepted candidate — is exact at every step but +/// pays that per accepted transaction, and candidate assembly now runs every +/// 15 s (TTL regeneration) rather than once a block. +#[allow(clippy::too_many_arguments)] +fn select_and_collect_fees( + config: &MinerConfig, + height: u32, + block_version: u8, + emission_tx: Transaction, + emission_box: &ErgoBox, + candidate_txs: &[(Transaction, usize)], + parameters: &Parameters, + state_context: &ErgoStateContext, + utxo_lookup: &dyn Fn(&[u8; 32]) -> Option, +) -> Result<(Vec, Vec<[u8; 32]>), MiningError> { + // Both limits are i32 in the parameters table. A negative value is + // unreachable through voting bounds; clamp to 0 rather than sign-extend + // into "everything fits", matching the validator's own handling. + let max_block_cost = u64::try_from(parameters.max_block_cost()).unwrap_or(0); + let max_block_size = usize::try_from(parameters.max_block_size()).unwrap_or(0); + + // Reserve the emission transaction's share of both budgets. + // + // If its cost cannot be measured we cannot bound the block, so we add + // nothing to it: the candidate degrades to emission-only, which is + // exactly what the node produced before selection was wired in. Failing + // outright would turn a measurement problem into "mining stops", a + // failure mode this step must not introduce. + let emission_cost = match validate_single_transaction( + &emission_tx, + vec![emission_box.clone()], + vec![], + state_context, + ) { + Ok(cost) => cost, + Err(e) => { + tracing::warn!( + height, + "mining: emission transaction did not validate ({e}); \ + serving an emission-only candidate" + ); + return Ok((vec![emission_tx], Vec::new())); + } + }; + let emission = CostedTx { + cost: emission_cost, + size: serialized_size(&emission_tx)?, + tx: emission_tx, + }; + + let (mut selected, invalid_txs) = selection::select_transactions( + candidate_txs, + std::slice::from_ref(&emission), + state_context, + block_version, + max_block_size, + max_block_cost, + utxo_lookup, + ); + + // Bound the ASSEMBLED set — emission + selected + fee — dropping the + // lowest-priority selected transaction until it fits. + let fee_tx = loop { + // The JVM builds the fee transaction over the accumulator including + // the emission transaction; its outputs never match the fee + // proposition, so this only matters for staying faithful. + let mut block_txs: Vec = Vec::with_capacity(1 + selected.len()); + block_txs.push(emission.tx.clone()); + block_txs.extend(selected.iter().map(|c| c.tx.clone())); + + let (fee_tx, fee_cost, fee_size) = + match measure_fee_tx(&block_txs, height, config, state_context) { + Ok(Some((tx, cost, size))) => (Some(tx), cost, size), + Ok(None) => (None, 0, 0), + Err(reason) => { + // Ship the block without collecting fees rather than + // without a candidate: the fee boxes simply stay unspent + // and the block is still valid. The JVM does the same + // ("Fee collecting tx is invalid, not including it"). + tracing::warn!(height, "mining: fee transaction unusable: {reason}"); + (None, 0, 0) + } + }; + + let total_cost = selected + .iter() + .fold(emission.cost, |acc, c| acc.saturating_add(c.cost)) + .saturating_add(fee_cost); + // Size is measured over the serialized SECTION — the framing bytes + // sit inside `max_block_size` alongside the transactions, and the + // transaction count in them is this iteration's, fee tx included. + let tx_count = 1 + selected.len() + usize::from(fee_tx.is_some()); + let total_size = selected + .iter() + .fold(emission.size, |acc, c| acc.saturating_add(c.size)) + .saturating_add(fee_size) + .saturating_add(selection::block_transactions_overhead( + block_version, + tx_count, + )); + + if total_cost <= max_block_cost && total_size <= max_block_size { + break fee_tx; + } + + if selected.pop().is_none() { + // Nothing left to shed: the emission transaction alone is over + // budget. Ship it — the block must carry its coinbase, and this + // is the same block the node produced before selection existed. + tracing::warn!( + height, + total_cost, + total_size, + max_block_cost, + max_block_size, + "mining: emission transaction alone exceeds the block limits" + ); + break None; + } + }; + + let mut transactions: Vec = Vec::with_capacity(2 + selected.len()); + transactions.push(emission.tx); + transactions.extend(selected.into_iter().map(|c| c.tx)); + // The fee transaction goes LAST: it spends fee boxes that are outputs of + // the selected transactions, so it cannot precede them. + transactions.extend(fee_tx); + + Ok((transactions, invalid_txs)) +} + +/// A fee transaction with its measured `(cost, size)`, or `None` when there +/// was nothing to collect. +type MeasuredFeeTx = Option<(Transaction, u64, usize)>; + +/// Build the fee transaction over `block_txs` and measure what it costs the +/// block. `Ok(None)` means there was nothing to collect — a zero-fee block is +/// a normal outcome, not an error. +/// +/// `Err` carries a reason for the caller to log and continue without fees. +fn measure_fee_tx( + block_txs: &[Transaction], + height: u32, + config: &MinerConfig, + state_context: &ErgoStateContext, +) -> Result { + let Some(fee_tx) = fee::build_fee_tx(block_txs, height, config.reward_delay, &config.miner_pk) + .map_err(|e| format!("build: {e}"))? + else { + return Ok(None); + }; + + // The fee transaction's inputs are fee boxes created by `block_txs`; the + // JVM resolves them the same way (`newBoxes.find(..)`). + let mut outputs: HashMap<[u8; 32], ErgoBox> = HashMap::new(); + for tx in block_txs { + for output in tx.outputs.iter() { + outputs.insert(id_bytes(output.box_id().as_ref()), output.clone()); + } + } + let input_boxes: Option> = fee_tx + .inputs + .iter() + .map(|i| outputs.get(&id_bytes(i.box_id.as_ref())).cloned()) + .collect(); + let input_boxes = + input_boxes.ok_or_else(|| "fee input box not found among block outputs".to_string())?; + + let cost = validate_single_transaction(&fee_tx, input_boxes, vec![], state_context) + .map_err(|e| format!("validation: {e}"))?; + let size = serialized_size(&fee_tx).map_err(|e| format!("{e}"))?; + + Ok(Some((fee_tx, cost, size))) +} + +/// Serialized size of a transaction — the unit both the JVM generator and the +/// block-transactions size limit count in. +fn serialized_size(tx: &Transaction) -> Result { + tx.sigma_serialize_bytes() + .map(|bytes| bytes.len()) + .map_err(|e| MiningError::AssemblyFailed(format!("transaction serialize: {e}"))) } /// Stateful candidate manager — two candidate slots plus a solved-block @@ -180,12 +512,7 @@ impl CandidateGenerator { /// Store a freshly generated candidate. The old candidate (if any) /// is preserved as `previous` so stale solutions can still be accepted. - pub fn cache_candidate( - &self, - block: CandidateBlock, - work: WorkMessage, - tip_height: u32, - ) { + pub fn cache_candidate(&self, block: CandidateBlock, work: WorkMessage, tip_height: u32) { let mut guard = self.cached.write().unwrap(); // Move current → previous before overwriting if let Some(old) = guard.take() { diff --git a/mining/src/selection.rs b/mining/src/selection.rs index ae714ee..5df18a8 100644 --- a/mining/src/selection.rs +++ b/mining/src/selection.rs @@ -1,51 +1,202 @@ //! Transaction selection from mempool for block inclusion. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use ergo_lib::chain::transaction::Transaction; use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; use ergo_validation::{validate_single_transaction, ErgoStateContext}; +/// A transaction together with the two numbers the block limits are counted +/// in: the script cost `validate_single_transaction` charged for it, and the +/// serialized size it contributes to the block transactions section. +/// +/// Selection returns these rather than bare transactions because the caller +/// has to keep counting after selection finishes — the fee transaction is +/// itself a transaction in the block, and bounding the assembled set needs +/// the per-transaction numbers that produced the selection. +#[derive(Clone, Debug)] +pub struct CostedTx { + pub tx: Transaction, + /// Script evaluation cost in block cost units. + pub cost: u64, + /// Serialized size in bytes. + pub size: usize, +} + +/// Sentinel the BlockTransactions serializer adds to the block version, so a +/// reader can tell a versioned section from a pre-v2 one where the first VLQ +/// *was* the transaction count (JVM `BlockTransactionsSerializer`, +/// `MaxTransactionsInBlock`). +const BLOCK_VERSION_SENTINEL: u64 = 10_000_000; + +/// Framing bytes of the BlockTransactions section — everything the section +/// carries that is not a transaction: +/// +/// ```text +/// [header_id: 32B][ver_or_count: VLQ][tx_count: VLQ if ver > 1][txs…] +/// ``` +/// +/// These count against `max_block_size`, because the limit is enforced over +/// the **section** and not over a sum of transactions: the JVM validates +/// `fb.blockTransactions.size <= currentParameters.maxBlockSize` +/// (`ErgoStateContext.scala:308-310`, rule `bsBlockTransactionsSize`), and +/// `.size` there is the serialized section. A candidate landing within this of +/// the limit is over it by the rule that decides validity, and gets rejected by +/// every peer. Comes to 37 bytes for a current-version block carrying fewer +/// than 128 transactions. +/// +/// ⚠ **Not a safety margin, and must not become one.** The overhead is +/// deterministic and computable, so counting it is accuracy — the no-`safeGap` +/// decision (see the cost bound below) is untouched. The JVM's own generator +/// sums transaction sizes and carries the undercount +/// (`CandidateGenerator.correctLimits`); we do not copy that. +pub fn block_transactions_overhead(block_version: u8, tx_count: usize) -> usize { + // JVM `BlockTransactionsSerializer.serialize`: 32 header-id bytes, then + // `putUInt(MaxTransactionsInBlock + blockVersion)` for a versioned section, + // then `putUInt(txs.size)`. A version-1 section omits the first field + // entirely — the reader recognises the difference by the sentinel. + let version_bytes = if block_version > 1 { + vlq_len(BLOCK_VERSION_SENTINEL + u64::from(block_version)) + } else { + 0 + }; + 32 + version_bytes + vlq_len(tx_count as u64) +} + +/// Encoded length of `v` under Scorex's unsigned VLQ: 7 payload bits per byte, +/// high bit as the continuation flag. +/// +/// Computed rather than encoded-and-measured, so the size accounting carries no +/// error path to swallow. `vlq_len_matches_the_encoder` pins it to `sigma_ser`'s +/// writer — the one that produces the bytes being counted. +fn vlq_len(v: u64) -> usize { + let mut len = 1; + let mut rest = v >> 7; + while rest != 0 { + len += 1; + rest >>= 7; + } + len +} + /// Select transactions from mempool for block inclusion. /// /// Takes prioritized candidates (highest-fee-rate first) and validates /// each against the upcoming block state. Transactions whose inputs can't /// be resolved or that fail validation are reported as invalid. /// +/// `committed` is what the block already carries before selection starts — +/// the emission transaction. Its cost and size seed the accumulators, its +/// outputs become spendable by candidates (intra-block chaining), and its +/// inputs are already consumed. The JVM feeds the emission transaction +/// through the same selection loop (`CandidateGenerator.scala:894`, +/// `emissionTxs ++ prioritizedTransactions ++ poolTxs`); passing it as +/// pre-committed gets the same accounting without the loop being able to +/// *skip* it, which for us — skipping where the JVM stops — would silently +/// produce a block with no coinbase. +/// +/// Bounded by both protocol limits from `Parameters`: the serialized +/// BlockTransactions **section** against `max_block_size`, and cumulative +/// ErgoScript evaluation cost against `max_block_cost`. Cost is bounded at +/// exactly `accumulated + tx_cost <= max_block_cost` with **no safety margin** — +/// the JVM's `safeGap` guards its own AOT costing divergence, not ours (see +/// `../facts/mining.md`, Step 3). Exceeding either limit skips that one +/// transaction and the scan continues, so a later cheaper or smaller +/// transaction can still use the remaining budget. +/// +/// `block_version` is the candidate's version, and it is a size input, not +/// decoration: the section's framing bytes depend on it +/// (`block_transactions_overhead`), and those bytes are inside the limit. +/// +/// ⚠ The fee transaction is NOT accounted for here — it does not exist until +/// the selection is known. Bounding the assembled set is the caller's job; +/// `generate_candidate` does it. +/// /// Returns `(selected_txs, invalid_tx_ids)`. Invalid IDs should be -/// reported to the mempool for cleanup. +/// reported to the mempool for cleanup. A transaction skipped for exceeding +/// a limit, for conflicting with one already selected, or for an input or +/// data input that doesn't resolve is *not* invalid — it stays in the mempool +/// for a later block. +#[allow(clippy::too_many_arguments)] pub fn select_transactions( candidates: &[(Transaction, usize)], - emission_outputs: &[ErgoBox], + committed: &[CostedTx], state_context: &ErgoStateContext, + block_version: u8, max_block_size: usize, + max_block_cost: u64, utxo_lookup: &dyn Fn(&[u8; 32]) -> Option, -) -> (Vec, Vec<[u8; 32]>) { - let mut selected = Vec::new(); +) -> (Vec, Vec<[u8; 32]>) { + let mut selected: Vec = Vec::new(); let mut invalid_ids = Vec::new(); - let mut accumulated_size: usize = 0; + // Transaction bytes only. The section's framing bytes are added at each + // bound check rather than seeded here, because one of them — the VLQ + // transaction count — grows with the selection. + let mut accumulated_tx_bytes: usize = 0; + let mut accumulated_cost: u64 = 0; - // Track outputs from emission tx and selected txs (available as inputs for later txs) + // Outputs of the emission tx and of already-selected txs — spendable as + // inputs by later candidates. let mut available_outputs: HashMap<[u8; 32], ErgoBox> = HashMap::new(); - for output in emission_outputs { - let mut id = [0u8; 32]; - id.copy_from_slice(output.box_id().as_ref()); - available_outputs.insert(id, output.clone()); + // Every box already consumed by a committed or selected transaction. + // + // Without this, two conflicting mempool transactions both get selected: + // the UTXO lookup still resolves the contested box (it is unspent on + // chain), and `validate_single_transaction` evaluates one transaction at a + // time and has no idea the box was already taken by an earlier one. The + // candidate would carry a double-spend and the assembled block would be + // invalid. The JVM runs the same check — `doublespend(current, tx)`, + // deliberately before script validation "to save time" + // (`CandidateGenerator.scala:875-880`). + let mut spent_ids: HashSet<[u8; 32]> = HashSet::new(); + + for committed_tx in committed { + accumulated_cost = accumulated_cost.saturating_add(committed_tx.cost); + accumulated_tx_bytes = accumulated_tx_bytes.saturating_add(committed_tx.size); + record(&committed_tx.tx, &mut available_outputs, &mut spent_ids); } for (tx, tx_size) in candidates { - // Check size limit - if accumulated_size + tx_size > max_block_size { + // Cheap rejections first, in the JVM's order: script evaluation is by + // far the most expensive step here and candidate assembly now runs + // every 15 s (TTL regeneration) rather than once a block. + + // Size limit, measured over the section this candidate would produce: + // its framing bytes, every transaction already accounted for, and this + // one. The framing is recomputed per candidate because its VLQ + // transaction count is a function of the selection so far. + let section_size = + block_transactions_overhead(block_version, committed.len() + selected.len() + 1) + .saturating_add(accumulated_tx_bytes) + .saturating_add(*tx_size); + if section_size > max_block_size { continue; // Skip, might fit smaller txs later } + // Conflict with what is already in the block. + if tx + .inputs + .iter() + .any(|input| spent_ids.contains(&id_bytes(input.box_id.as_ref()))) + { + // A mempool conflict is not evidence that this transaction is + // invalid — the mempool owns conflict resolution, and the loser + // here is perfectly valid in a block where the winner is absent. + // The JVM evicts it (`invalidTxs :+ tx.id`); we skip, matching the + // choice already made for unresolvable inputs below. + continue; + } + // Resolve input boxes - let mut input_boxes = Vec::new(); + let mut input_boxes = Vec::with_capacity(tx.inputs.len()); let mut inputs_found = true; for input in tx.inputs.iter() { - let mut id = [0u8; 32]; - id.copy_from_slice(input.box_id.as_ref()); - if let Some(b) = available_outputs.get(&id).cloned().or_else(|| utxo_lookup(&id)) { + let id = id_bytes(input.box_id.as_ref()); + if let Some(b) = available_outputs + .get(&id) + .cloned() + .or_else(|| utxo_lookup(&id)) + { input_boxes.push(b); } else { inputs_found = false; @@ -56,43 +207,139 @@ pub fn select_transactions( continue; // Inputs not available, skip (not necessarily invalid — might appear later) } - // Resolve data input boxes - let data_boxes: Vec = tx - .data_inputs - .as_ref() - .map(|dis| { - dis.iter() - .filter_map(|di| { - let mut id = [0u8; 32]; - id.copy_from_slice(di.box_id.as_ref()); - available_outputs.get(&id).cloned().or_else(|| utxo_lookup(&id)) - }) - .collect() - }) - .unwrap_or_default(); + // Resolve data input boxes, under the same rule as the inputs above: + // a box that will not resolve is not evidence of anything about the + // transaction. + // + // Filtering the misses away instead yields a `data_boxes` shorter than + // `tx.data_inputs`, which `TransactionContext::new` rejects + // deterministically as `DataInputBoxNotFound` — so the transaction + // comes back *invalid* rather than skipped, lands in `invalid_ids`, + // and the caller evicts it for the mempool's invalidation TTL. A + // transiently unresolvable data input would have selected fine on the + // next rebuild 15 seconds later. + let mut data_boxes: Vec = Vec::new(); + let mut data_inputs_found = true; + for di in tx.data_inputs.iter().flat_map(|dis| dis.iter()) { + let id = id_bytes(di.box_id.as_ref()); + if let Some(b) = available_outputs + .get(&id) + .cloned() + .or_else(|| utxo_lookup(&id)) + { + data_boxes.push(b); + } else { + data_inputs_found = false; + break; + } + } + if !data_inputs_found { + continue; // Data inputs not available, skip (same as inputs — might appear later) + } // Validate against upcoming state match validate_single_transaction(tx, input_boxes, data_boxes, state_context) { - Ok(_) => { - accumulated_size += tx_size; + Ok(tx_cost) => { + // The cost check cannot precede validation — validation is what + // produces the number. `checked_add` reads an overflowing sum as + // "does not fit" instead of wrapping into a spurious accept. + let Some(total_cost) = accumulated_cost + .checked_add(tx_cost) + .filter(|total| *total <= max_block_cost) + else { + // Over budget, but still a valid transaction: leave it in + // the mempool and keep scanning — a cheaper one may fit. + continue; + }; + + accumulated_cost = total_cost; + accumulated_tx_bytes += tx_size; - // Track outputs for later txs (intra-block spending) - for output in tx.outputs.iter() { - let mut id = [0u8; 32]; - id.copy_from_slice(output.box_id().as_ref()); - available_outputs.insert(id, output.clone()); - } + record(tx, &mut available_outputs, &mut spent_ids); - selected.push(tx.clone()); + selected.push(CostedTx { + tx: tx.clone(), + cost: tx_cost, + size: *tx_size, + }); } Err(_) => { - let tx_id = tx.id(); - let mut id = [0u8; 32]; - id.copy_from_slice(tx_id.as_ref()); - invalid_ids.push(id); + invalid_ids.push(id_bytes(tx.id().as_ref())); } } } (selected, invalid_ids) } + +/// Fold a transaction into the accumulated block state: its inputs become +/// unavailable, its outputs become spendable by later transactions. +fn record( + tx: &Transaction, + available_outputs: &mut HashMap<[u8; 32], ErgoBox>, + spent_ids: &mut HashSet<[u8; 32]>, +) { + for input in tx.inputs.iter() { + spent_ids.insert(id_bytes(input.box_id.as_ref())); + } + for output in tx.outputs.iter() { + available_outputs.insert(id_bytes(output.box_id().as_ref()), output.clone()); + } +} + +/// Box/transaction ids are Blake2b256 digests — always 32 bytes. +pub(crate) fn id_bytes(bytes: &[u8]) -> [u8; 32] { + let mut id = [0u8; 32]; + id.copy_from_slice(bytes); + id +} + +#[cfg(test)] +mod tests { + use super::*; + use sigma_ser::vlq_encode::WriteSigmaVlqExt; + + /// `vlq_len` counts what the encoder writes. Computing the length instead + /// of encoding keeps an error path out of the size accounting, which is + /// only safe while the two agree — so pin them together, at every byte + /// boundary and either side of it. + #[test] + fn vlq_len_matches_the_encoder() { + let mut cases: Vec = vec![0, 1, 127, 128, 16_383, 16_384, 10_000_002, u64::MAX]; + // Every 7-bit boundary and its neighbours. + for shift in 1..=9 { + let boundary = 1u64 << (7 * shift); + cases.extend([boundary - 1, boundary, boundary + 1]); + } + + for v in cases { + let mut encoded: Vec = Vec::new(); + encoded.put_u64(v).expect("writing to a Vec cannot fail"); + assert_eq!( + vlq_len(v), + encoded.len(), + "vlq_len({v}) disagrees with the encoder" + ); + } + } + + /// The 32 + 4 + 1 that the sum of transaction sizes misses on a + /// current-version block: header id, the sentinel-offset version, and the + /// transaction count. + #[test] + fn section_overhead_is_37_bytes_for_a_normal_block() { + assert_eq!(block_transactions_overhead(3, 1), 37); + assert_eq!(block_transactions_overhead(3, 127), 37); + // 128 transactions push the count VLQ to two bytes. + assert_eq!(block_transactions_overhead(3, 128), 38); + assert_eq!(block_transactions_overhead(3, 16_384), 39); + // Every plausible block version sits inside the same 24-bit VLQ band + // as the sentinel, so the version field is 4 bytes throughout. + for version in 2..=u8::MAX { + assert_eq!(block_transactions_overhead(version, 1), 37); + } + // A version-1 section has no version field at all: the first VLQ is + // the transaction count. + assert_eq!(block_transactions_overhead(1, 1), 33); + } +} diff --git a/mining/src/solution.rs b/mining/src/solution.rs index 3960b8d..f4e78e1 100644 --- a/mining/src/solution.rs +++ b/mining/src/solution.rs @@ -64,9 +64,15 @@ pub fn validate_solution( }; header.id = BlockId(Digest32::from(id_hash)); - // Verify PoW using Header::check_pow() which computes: - // target = order / decode_compact_bits(n_bits) - // valid = pow_hit(header) < target + // Verify PoW using Header::check_pow() (upstream sigma-rust), which computes: + // target = order / decode_compact_bits(n_bits) [== enr_chain::pow_target] + // valid = pow_hit(header) < target + // + // That target must be the same number `build_work_message` serves as + // `WorkMessage.b`. It was not in v0.8.0 — the serve path sent the raw + // difficulty — so miners hunted a bound ~10^58 tighter than the one checked + // here and this branch never ran for anyone. `tests/work_message_wiring.rs` + // pins the two together; check_pow itself stays upstream's. let valid = header .check_pow() .map_err(|e| MiningError::InvalidSolution(format!("check_pow: {e}")))?; diff --git a/mining/src/types.rs b/mining/src/types.rs index 1e04429..bf1adf7 100644 --- a/mining/src/types.rs +++ b/mining/src/types.rs @@ -39,7 +39,10 @@ pub struct CandidateBlock { pub parent: Header, /// Block version. pub version: u8, - /// Encoded difficulty target (compact bits). + /// Compact-encoded **difficulty** for this block — not the mining target, + /// which is `enr_chain::pow_target(n_bits)` and is what `WorkMessage.b` + /// carries. Serialized into the header as 4 raw big-endian bytes, the one + /// non-VLQ integer there. pub n_bits: u32, /// New state root after applying selected transactions. pub state_root: ADDigest, @@ -62,10 +65,19 @@ pub struct CandidateBlock { pub struct WorkMessage { /// Blake2b256(serialized HeaderWithoutPow) — hex-encoded. pub msg: String, - /// Target value from nBits. Held as a decimal string internally, but - /// serialized as a BARE JSON number to match JVM `ExternalCandidateBlock` - /// (jsmn-based miner/pool parsers reject a quoted target). See - /// `serialize_b_as_number`. + /// The Autolykos target: `enr_chain::pow_target(n_bits)`, i.e. + /// `q / decode_compact_bits(n_bits)` for the secp256k1 group order `q`. + /// A solution is valid when `pow_hit < b`. + /// + /// ⚠ **Not `decode_compact_bits(n_bits)` — that is the difficulty**, and + /// serving it here is the v0.8.0 defect that made the node unmineable. + /// The two are never close: at testnet height 485,897 the difficulty was + /// 10 digits and the target 68. Anything under ~20 digits landing in this + /// field is a bug, not a low-difficulty epoch. + /// + /// Held as a decimal string internally, but serialized as a BARE JSON + /// number to match JVM `ExternalCandidateBlock` (jsmn-based miner/pool + /// parsers reject a quoted target). See `serialize_b_as_number`. #[serde(serialize_with = "serialize_b_as_number")] pub b: String, /// Block height. @@ -100,10 +112,19 @@ pub struct WorkMessage { /// serializer, which is the only serializer WorkMessage ever sees (axum's /// `Json` in production, `serde_json::to_string` in tests). /// -/// `b` is always the decimal string from `decode_compact_bits(..).to_string()` +/// `b` is always the decimal string from `enr_chain::pow_target(..).to_string()` /// (a non-negative `BigInt`: valid JSON, no leading zeros, no sign), so the /// RawValue construction never fails in practice — but we surface it as a /// serialization error rather than panic if that invariant is ever violated. +/// +/// This doc comment said `decode_compact_bits(..)` until the v0.8.0 fix, which +/// is the serve bug written down as an invariant in the very function that +/// emits the field. The escape hatch itself was never in question and is now +/// **more** load-bearing, not less: a real target is ~68 digits and genuinely +/// cannot survive serde's numeric model, whereas the difficulty we were +/// accidentally serving fit in a `u64` and would have encoded fine naively. +/// The b-as-number fix was solving a real problem — it was just guarding the +/// wrong number. fn serialize_b_as_number(b: &str, serializer: S) -> Result where S: serde::Serializer, @@ -135,6 +156,17 @@ pub struct CachedCandidate { mod tests { use super::*; + /// A plausible `b` for the shape tests below: the target a Scala node + /// served at testnet height 485,897. + /// + /// These tests are about JSON *shape*, not semantics, so any decimal + /// string would exercise them — but this was `12237864960` until v0.8.0, + /// a difficulty-magnitude value that now reads as an example of the serve + /// bug rather than as a target. A real target is ~68 digits. The + /// arbitrary-precision test below keeps its own (larger) literal. + const OBSERVED_TARGET: &str = + "29598898778389163379010897437604384363675568080188445020547283242588"; + /// A basic candidate WorkMessage (proof `None` — the only shape the node /// produces today), parameterized by the target `b`. fn work_with_b(b: &str) -> WorkMessage { @@ -149,13 +181,13 @@ mod tests { #[test] fn b_serializes_as_bare_number() { - let json = serde_json::to_string(&work_with_b("12237864960")).unwrap(); + let json = serde_json::to_string(&work_with_b(OBSERVED_TARGET)).unwrap(); assert!( - json.contains(r#""b":12237864960"#), + json.contains(&format!(r#""b":{OBSERVED_TARGET}"#)), "b must be a bare JSON number, got: {json}" ); assert!( - !json.contains(r#""b":"12237864960""#), + !json.contains(&format!(r#""b":"{OBSERVED_TARGET}""#)), "b must NOT be a quoted string, got: {json}" ); } @@ -181,10 +213,19 @@ mod tests { fn surviving_fields_keep_their_shape() { // msg/pk stay quoted hex strings, h stays a bare number. (proof // omission is asserted separately in basic_candidate_omits_proof.) - let json = serde_json::to_string(&work_with_b("12237864960")).unwrap(); - assert!(json.contains(r#""msg":"0000"#), "msg must stay a hex string: {json}"); - assert!(json.contains(r#""pk":"0211"#), "pk must stay a hex string: {json}"); - assert!(json.contains(r#""h":271235"#), "h must stay a bare number: {json}"); + let json = serde_json::to_string(&work_with_b(OBSERVED_TARGET)).unwrap(); + assert!( + json.contains(r#""msg":"0000"#), + "msg must stay a hex string: {json}" + ); + assert!( + json.contains(r#""pk":"0211"#), + "pk must stay a hex string: {json}" + ); + assert!( + json.contains(r#""h":271235"#), + "h must stay a bare number: {json}" + ); } #[test] @@ -194,7 +235,7 @@ mod tests { // parses with a fixed jsmn REQ_LEN=11 token buffer. A nested proof // object overflows it ("Jsmn failed to parse latest block"), so the // `proof` key must be ABSENT, not `null`. - let json = serde_json::to_string(&work_with_b("12237864960")).unwrap(); + let json = serde_json::to_string(&work_with_b(OBSERVED_TARGET)).unwrap(); assert!( !json.contains("proof"), @@ -211,7 +252,10 @@ mod tests { // The four expected keys are present, b still bare. assert!(json.contains(r#""msg":"#), "msg present: {json}"); - assert!(json.contains(r#""b":12237864960"#), "b present and bare: {json}"); + assert!( + json.contains(&format!(r#""b":{OBSERVED_TARGET}"#)), + "b present and bare: {json}" + ); assert!(json.contains(r#""h":271235"#), "h present: {json}"); assert!(json.contains(r#""pk":"#), "pk present: {json}"); @@ -220,8 +264,14 @@ mod tests { // object + one per key + one per scalar value = 1 + 4 + 4 = 9 <= 11. // Assert exactly four scalar fields and no nesting. let value: serde_json::Value = serde_json::from_str(&json).unwrap(); - let obj = value.as_object().expect("WorkMessage serializes to a JSON object"); - assert_eq!(obj.len(), 4, "basic candidate must have exactly 4 keys: {json}"); + let obj = value + .as_object() + .expect("WorkMessage serializes to a JSON object"); + assert_eq!( + obj.len(), + 4, + "basic candidate must have exactly 4 keys: {json}" + ); assert!( obj.values().all(|v| !v.is_object() && !v.is_array()), "no nested objects/arrays in the basic candidate: {json}" diff --git a/mining/tests/candidate_assembly.rs b/mining/tests/candidate_assembly.rs new file mode 100644 index 0000000..ed26388 --- /dev/null +++ b/mining/tests/candidate_assembly.rs @@ -0,0 +1,990 @@ +//! Contract steps 3–4 wired into `generate_candidate`: mempool selection, +//! fee collection, and the block limits counted over the ASSEMBLED set. +//! +//! The candidate transactions here spend boxes guarded by `sigmaProp(true)`, +//! so they validate with an empty proof and every cost in the block is +//! deterministic and cheap. Costs and sizes are measured at runtime rather +//! than hardcoded — the numbers move whenever sigma-rust's costing does, and +//! these tests are about the bound, not the tariff. + +use std::collections::HashMap; +use std::time::Duration; + +use ergo_chain_types::{ + ADDigest, AutolykosSolution, BlockId, Digest, Digest32, EcPoint, Header, Votes, +}; +use ergo_lib::chain::emission::MonetarySettings; +use ergo_lib::chain::ergo_tree_predef; +use ergo_lib::chain::genesis; +use ergo_lib::chain::transaction::input::prover_result::ProverResult; +use ergo_lib::chain::transaction::input::Input; +use ergo_lib::chain::transaction::{DataInput, Transaction}; +use ergo_lib::ergotree_interpreter::sigma_protocol::prover::ProofBytes; +use ergo_lib::ergotree_ir::chain::context_extension::ContextExtension; +use ergo_lib::ergotree_ir::chain::ergo_box::box_value::BoxValue; +use ergo_lib::ergotree_ir::chain::ergo_box::{ + BoxTokens, ErgoBox, ErgoBoxCandidate, NonMandatoryRegisters, +}; +use ergo_lib::ergotree_ir::chain::token::{Token, TokenId}; +use ergo_lib::ergotree_ir::chain::tx_id::TxId; +use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; +use ergo_lib::ergotree_ir::mir::expr::Expr; +use ergo_lib::ergotree_ir::serialization::SigmaSerializable; +use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::{ProveDlog, SigmaBoolean, SigmaProp}; +use ergo_mining::emission::ReemissionRules; +use ergo_mining::types::*; +use ergo_mining::{GeneratedCandidate, ValidatorProofsResult}; +use ergo_validation::{ + build_state_context, validate_single_transaction, ErgoStateContext, Parameter, Parameters, +}; +use sigma_ser::vlq_encode::WriteSigmaVlqExt; + +/// Trivial difficulty — the candidate's nBits, irrelevant to selection. +const INITIAL_N_BITS: u32 = 16842752; +const REWARD_DELAY: i32 = 720; +/// Genesis is height 1 in Ergo, so every candidate here is height 2. +const PARENT_HEIGHT: u32 = 1; +const CANDIDATE_HEIGHT: u32 = PARENT_HEIGHT + 1; + +const PROOFS: &[&str] = &[ + "test-proof-1", + "test-proof-2", + "test-proof-3", + "test-proof-4", + "test-proof-5", +]; +const FOUNDER_PKS: &[&str] = &[ + "039bb5fe52359a64c99a60fd944fc5e388cbdc4d37ff091cc841c3ee79060b8647", + "031fb52cf6e805f80d97cde289f4f757d49accf0c83fb864b27d2cf982c37f9a8b", + "0352ac2a471339b0d23b3d2c5ce0db0e81c969f77891b9edf0bda7fd39a78184e7", +]; + +fn founder_pks() -> Vec { + FOUNDER_PKS + .iter() + .map(|h| ProveDlog::new(EcPoint::sigma_parse_bytes(&hex::decode(h).unwrap()).unwrap())) + .collect() +} + +fn miner_pk() -> ProveDlog { + ProveDlog::new(EcPoint::sigma_parse_bytes(&hex::decode(FOUNDER_PKS[0]).unwrap()).unwrap()) +} + +fn config() -> MinerConfig { + MinerConfig { + miner_pk: miner_pk(), + reward_delay: REWARD_DELAY, + votes: [0, 0, 0], + candidate_ttl: Duration::from_secs(15), + reemission_rules: ReemissionRules::mainnet(), + } +} + +fn emission_box() -> ErgoBox { + let (b, _, _) = + genesis::genesis_boxes(&MonetarySettings::default(), &founder_pks(), 2, PROOFS).unwrap(); + b +} + +fn parent_header() -> Header { + Header { + version: 2, + id: BlockId(Digest::from([1u8; 32])), + parent_id: BlockId(Digest::from([0u8; 32])), + ad_proofs_root: Digest32::from([0u8; 32]), + state_root: ADDigest::from([0u8; 33]), + transaction_root: Digest32::from([0u8; 32]), + timestamp: 1000, + n_bits: INITIAL_N_BITS, + height: PARENT_HEIGHT, + extension_root: Digest32::from([0u8; 32]), + autolykos_solution: AutolykosSolution { + miner_pk: Box::new(*miner_pk().h), + pow_onetime_pk: None, + nonce: vec![0u8; 8], + pow_distance: None, + }, + votes: Votes([0, 0, 0]), + unparsed_bytes: Box::new([]), + } +} + +fn mock_proofs(_txs: &[Transaction]) -> ValidatorProofsResult { + Some(Ok((vec![0u8; 64], ADDigest::from([0u8; 33])))) +} + +// --------------------------------------------------------------------------- +// Fixture transactions +// --------------------------------------------------------------------------- + +/// `sigmaProp(true)` — reduces to true on its own, so an empty spending proof +/// suffices and the candidate transactions cost next to nothing to validate. +fn always_true() -> ErgoTree { + let prop = SigmaProp::new(SigmaBoolean::TrivialProp(true)); + ErgoTree::try_from(Expr::Const(prop.into())).unwrap() +} + +fn fee_tree() -> ErgoTree { + ergo_tree_predef::fee_proposition(REWARD_DELAY).unwrap() +} + +/// A box on the "already on chain" side, spendable by anyone. +fn source_box(seed: u8, value: u64) -> ErgoBox { + source_box_with_tokens(seed, value, None) +} + +fn source_box_with_tokens(seed: u8, value: u64, tokens: Option) -> ErgoBox { + ErgoBox::new( + BoxValue::try_from(value).unwrap(), + always_true(), + tokens, + NonMandatoryRegisters::empty(), + PARENT_HEIGHT, + TxId::from(Digest32::from([seed; 32])), + 0, + ) + .unwrap() +} + +/// `count` distinct token ids, deterministic and namespaced by `seed` so two +/// source boxes never overlap. Amount 1, so each token costs the minimal 33 +/// serialized bytes and the box-size arithmetic in these tests is legible. +fn distinct_tokens(seed: u8, count: usize) -> Vec { + (0..count) + .map(|i| { + let mut id = [0u8; 32]; + id[0] = seed; + id[1] = i as u8; + id[2] = (i >> 8) as u8; + Token { + token_id: TokenId::from(Digest32::from(id)), + amount: 1u64.try_into().unwrap(), + } + }) + .collect() +} + +fn token_source_box(seed: u8, value: u64, tokens: &[Token]) -> ErgoBox { + source_box_with_tokens( + seed, + value, + Some(BoxTokens::from_vec(tokens.to_vec()).unwrap()), + ) +} + +fn token_ids(b: &ErgoBox) -> Vec { + let tokens: &[Token] = match b.tokens.as_ref() { + Some(t) => t.as_ref(), + None => &[], + }; + tokens.iter().map(|t| t.token_id).collect() +} + +/// The token ids `build_fee_tx` should keep: every group in order, truncated +/// at `cap`. +fn expected_ids(groups: &[&Vec], cap: usize) -> Vec { + groups + .iter() + .flat_map(|g| g.iter()) + .take(cap) + .map(|t| t.token_id) + .collect() +} + +/// Every token the fee boxes offer, in the traversal order the reward box must +/// take them in. +fn ordered_tokens(groups: &[Vec]) -> Vec { + groups.iter().flat_map(|g| g.iter().cloned()).collect() +} + +/// A box's serialized length as a validator counts it — `ErgoBox` bytes, +/// transaction id and output index included, which is what `txBoxSize` is +/// measured against. The candidate body is 33 bytes shorter and would let a +/// box over the window look like it fits. +fn box_bytes(b: &ErgoBox) -> usize { + b.sigma_serialize_bytes().unwrap().len() +} + +/// The same box re-measured with one more token on it. +/// +/// Built here from the shipped box rather than from anything the crate +/// returns, so "the cut is tight" is checked against the serializer directly +/// and not against the arithmetic under test. +fn box_bytes_with(b: &ErgoBox, extra: &Token) -> usize { + let mut tokens: Vec = match b.tokens.as_ref() { + Some(t) => >::as_ref(t).to_vec(), + None => Vec::new(), + }; + tokens.push(*extra); + let candidate = ErgoBoxCandidate { + value: b.value, + ergo_tree: b.ergo_tree.clone(), + tokens: Some(BoxTokens::from_vec(tokens).unwrap()), + additional_registers: NonMandatoryRegisters::empty(), + creation_height: b.creation_height, + }; + box_bytes(&ErgoBox::from_box_candidate(&candidate, b.transaction_id, b.index).unwrap()) +} + +fn spend_into(src: &ErgoBox, out_tree: ErgoTree) -> Transaction { + spend_into_with_data_inputs(src, &[], out_tree) +} + +/// `spend_into` with read-only data inputs attached. The fixture script is +/// `sigmaProp(true)` and never reads them, so all they change for selection is +/// whether the boxes they name have to resolve. +fn spend_into_with_data_inputs( + src: &ErgoBox, + data_boxes: &[&ErgoBox], + out_tree: ErgoTree, +) -> Transaction { + // Tokens carry through unchanged: the whole box goes to the one output, so + // preservation holds without a change box. + let output = ErgoBoxCandidate { + value: src.value, + ergo_tree: out_tree, + tokens: src.tokens.clone(), + additional_registers: NonMandatoryRegisters::empty(), + creation_height: CANDIDATE_HEIGHT, + }; + let input = Input::new( + src.box_id(), + ProverResult { + proof: ProofBytes::Empty, + extension: ContextExtension::empty(), + }, + ); + let data_inputs: Vec = data_boxes.iter().map(|b| b.box_id().into()).collect(); + Transaction::new_from_vec(vec![input], data_inputs, vec![output]).unwrap() +} + +/// Spends `src` entirely into a fee-proposition box — the whole value becomes +/// a miner fee, so this transaction contributes a fee box to the block. +fn fee_paying_tx(src: &ErgoBox) -> Transaction { + spend_into(src, fee_tree()) +} + +/// Spends `src` into another anyone-can-spend box: valid, but pays no fee. +fn feeless_tx(src: &ErgoBox) -> Transaction { + spend_into(src, always_true()) +} + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +fn lookup_over(boxes: &[ErgoBox]) -> impl Fn(&[u8; 32]) -> Option + '_ { + let map: HashMap<[u8; 32], ErgoBox> = boxes.iter().map(|b| (id_of(b), b.clone())).collect(); + move |id: &[u8; 32]| map.get(id).cloned() +} + +fn id_of(b: &ErgoBox) -> [u8; 32] { + let mut id = [0u8; 32]; + id.copy_from_slice(b.box_id().as_ref()); + id +} + +fn size_of(tx: &Transaction) -> usize { + tx.sigma_serialize_bytes().unwrap().len() +} + +fn candidates(txs: &[Transaction]) -> Vec<(Transaction, usize)> { + txs.iter().map(|t| (t.clone(), size_of(t))).collect() +} + +fn params_with(max_cost: u64, max_size: usize) -> Parameters { + let mut p = Parameters::default(); + p.parameters_table + .insert(Parameter::MaxBlockCost, i32::try_from(max_cost).unwrap()); + p.parameters_table + .insert(Parameter::MaxBlockSize, i32::try_from(max_size).unwrap()); + p +} + +fn generate( + candidate_txs: &[(Transaction, usize)], + parameters: &Parameters, + utxos: &[ErgoBox], +) -> GeneratedCandidate { + let lookup = lookup_over(utxos); + ergo_mining::generate_candidate( + &config(), + &parent_header(), + INITIAL_N_BITS, + &[], + &emission_box(), + None, + &[], + candidate_txs, + parameters, + &[], + &lookup, + &mock_proofs, + ) + .expect("candidate generation must succeed") +} + +/// The state context the assembled block's transactions execute under. +/// +/// Rebuilt here rather than reached for: `generate_candidate` derives its own +/// from the parent, and the only field that differs is the wall-clock +/// timestamp, which no fixture script reads and no cost depends on. +fn block_context(parameters: &Parameters) -> ErgoStateContext { + let parent = parent_header(); + let upcoming = Header { + height: CANDIDATE_HEIGHT, + parent_id: parent.id, + timestamp: parent.timestamp + 1, + ..parent.clone() + }; + build_state_context(&upcoming, &[parent], parameters) +} + +/// Total block cost and size, counted exactly as a validator counts them: +/// cost is the sum over EVERY transaction in the block (`enforce_block_cost` +/// in `validation/src/tx_validation.rs`), fee and emission transactions +/// included; size is the serialized BlockTransactions **section**, framing and +/// all (`ErgoStateContext.scala:308-310`), which is 37 bytes more than that +/// sum on a current-version block. +fn measure_block( + block: &CandidateBlock, + utxos: &[ErgoBox], + ctx: &ErgoStateContext, +) -> (u64, usize) { + let mut boxes: HashMap<[u8; 32], ErgoBox> = + utxos.iter().map(|b| (id_of(b), b.clone())).collect(); + boxes.insert(id_of(&emission_box()), emission_box()); + for tx in &block.transactions { + for out in tx.outputs.iter() { + boxes.entry(id_of(out)).or_insert_with(|| out.clone()); + } + } + + let mut total_cost = 0u64; + for (i, tx) in block.transactions.iter().enumerate() { + let inputs: Vec = + tx.inputs + .iter() + .map(|input| { + let mut id = [0u8; 32]; + id.copy_from_slice(input.box_id.as_ref()); + boxes.get(&id).cloned().unwrap_or_else(|| { + panic!("tx {i}: input box {} unresolved", hex::encode(id)) + }) + }) + .collect(); + total_cost += validate_single_transaction(tx, inputs, vec![], ctx) + .unwrap_or_else(|e| panic!("assembled tx {i} must validate: {e}")); + } + (total_cost, serialized_section(block).len()) +} + +fn is_fee_tx(tx: &Transaction) -> bool { + tx.outputs.len() == 1 + && tx.outputs.get(0).unwrap().ergo_tree + == ergo_tree_predef::reward_output_script(REWARD_DELAY, miner_pk()).unwrap() +} + +/// The BlockTransactions section as a validator measures it, built here from +/// the JVM serializer's own layout rather than from the crate's accounting: +/// +/// ```text +/// [header_id: 32B][10_000_000 + version: VLQ][tx_count: VLQ][txs…] +/// ``` +/// +/// (`BlockTransactionsSerializer.serialize`; the sentinel offset is how a +/// reader tells a versioned section from a pre-v2 one.) Independent of +/// `block_transactions_overhead`, so it is free to disagree with it — which is +/// the only reason it is worth writing out. +fn serialized_section(block: &CandidateBlock) -> Vec { + // The header id is not known until a solution lands; its 32 bytes are. + let mut out: Vec = vec![0u8; 32]; + out.put_u64(10_000_000 + u64::from(block.version)).unwrap(); + out.put_u64(block.transactions.len() as u64).unwrap(); + for tx in &block.transactions { + out.extend_from_slice(&tx.sigma_serialize_bytes().unwrap()); + } + out +} + +// --------------------------------------------------------------------------- +// 1. Empty mempool — must not regress +// --------------------------------------------------------------------------- + +#[test] +fn empty_mempool_still_produces_emission_only() { + let generated = generate(&[], &Parameters::default(), &[]); + + assert_eq!( + generated.block.transactions.len(), + 1, + "an empty mempool must still yield the emission transaction alone" + ); + assert!( + generated.invalid_txs.is_empty(), + "nothing was offered, so nothing can be invalid" + ); + // Emission tx shape: spends the emission box, pays out the new emission + // box plus the miner reward. + let emission = &generated.block.transactions[0]; + assert_eq!(emission.inputs.len(), 1); + assert_eq!(emission.outputs.len(), 2); +} + +// --------------------------------------------------------------------------- +// 2. Selection appears, fee transaction goes last +// --------------------------------------------------------------------------- + +#[test] +fn selected_transactions_appear_with_the_fee_transaction_last() { + let srcs = vec![source_box(0xA1, 2_000_000), source_box(0xA2, 3_000_000)]; + let txs = vec![fee_paying_tx(&srcs[0]), fee_paying_tx(&srcs[1])]; + + let generated = generate(&candidates(&txs), &Parameters::default(), &srcs); + let assembled = &generated.block.transactions; + + assert_eq!( + assembled.len(), + 4, + "expected [emission, tx, tx, fee], got {} transactions", + assembled.len() + ); + assert_eq!( + assembled[1].id(), + txs[0].id(), + "selected transactions keep priority order, after the emission tx" + ); + assert_eq!(assembled[2].id(), txs[1].id()); + + let fee_tx = assembled.last().unwrap(); + assert!( + is_fee_tx(fee_tx), + "the LAST transaction must be the fee transaction — it spends fee \ + boxes that are outputs of the selected transactions, so it cannot \ + precede them" + ); + assert_eq!( + fee_tx.inputs.len(), + 2, + "both fee boxes must be collected into the one fee transaction" + ); + assert_eq!( + fee_tx.outputs.get(0).unwrap().value.as_i64(), + 5_000_000, + "the miner box must carry the summed fees" + ); + assert!(generated.invalid_txs.is_empty()); +} + +// --------------------------------------------------------------------------- +// 3. Zero total fee — no fee transaction, and not an error +// --------------------------------------------------------------------------- + +#[test] +fn zero_total_fee_yields_no_fee_transaction() { + let srcs = vec![source_box(0xB1, 2_000_000)]; + let txs = vec![feeless_tx(&srcs[0])]; + + let generated = generate(&candidates(&txs), &Parameters::default(), &srcs); + let assembled = &generated.block.transactions; + + assert_eq!( + assembled.len(), + 2, + "a selected transaction paying no fee yields [emission, tx] — no fee tx" + ); + assert_eq!(assembled[1].id(), txs[0].id()); + assert!( + !is_fee_tx(assembled.last().unwrap()), + "nothing to collect must not synthesise a fee transaction" + ); + assert!(generated.invalid_txs.is_empty()); +} + +// --------------------------------------------------------------------------- +// 4. The assembled block, fee transaction included, stays within both limits +// --------------------------------------------------------------------------- + +/// The failure this whole step exists to avoid: `select_transactions` counts +/// mempool transactions only, so selecting right up to `max_block_cost` and +/// then appending a fee transaction pushes the assembled block over the limit. +/// +/// Measured against the exact boundary. One unit of budget short of +/// `emission + selected + fee`, the selected transaction must drop out — +/// dropping it also drops the fee box it created, so the block falls back to +/// emission-only. An arrangement that forgets to count the fee transaction +/// keeps it, and the assembled block exceeds the budget it was built for. +#[test] +fn assembled_block_including_the_fee_transaction_stays_within_the_cost_limit() { + let srcs = vec![source_box(0xC1, 4_000_000)]; + let txs = candidates(&[fee_paying_tx(&srcs[0])]); + + // Measure the full block first, with budgets nothing can reach. + let generous = params_with(1_000_000, 524_288); + let full = generate(&txs, &generous, &srcs); + assert_eq!(full.block.transactions.len(), 3, "[emission, tx, fee]"); + let (full_cost, _) = measure_block(&full.block, &srcs, &block_context(&generous)); + + // Exactly enough: everything fits. + let exact = params_with(full_cost, 524_288); + let at_limit = generate(&txs, &exact, &srcs); + let (cost, _) = measure_block(&at_limit.block, &srcs, &block_context(&exact)); + assert_eq!( + at_limit.block.transactions.len(), + 3, + "a block summing to exactly max_block_cost must be accepted whole" + ); + assert!(cost <= full_cost, "{cost} must fit {full_cost}"); + + // One unit short: the fee transaction is what no longer fits, and the + // transaction that created its fee box goes with it. + let tight_cost = full_cost - 1; + let tight = params_with(tight_cost, 524_288); + let dropped = generate(&txs, &tight, &srcs); + let (cost, _) = measure_block(&dropped.block, &srcs, &block_context(&tight)); + assert!( + cost <= tight_cost, + "assembled block cost {cost} exceeds the {tight_cost} limit it was \ + built for — the fee transaction was not counted against the budget" + ); + assert_eq!( + dropped.block.transactions.len(), + 1, + "one unit short of the whole set, the selected transaction and its \ + fee transaction must both drop, leaving emission only" + ); + assert!( + dropped.invalid_txs.is_empty(), + "a transaction dropped for budget is not invalid — it stays in the mempool" + ); +} + +/// Same bound, counted in bytes. +#[test] +fn assembled_block_including_the_fee_transaction_stays_within_the_size_limit() { + let srcs = vec![source_box(0xD1, 4_000_000)]; + let txs = candidates(&[fee_paying_tx(&srcs[0])]); + + let generous = params_with(1_000_000, 524_288); + let full = generate(&txs, &generous, &srcs); + assert_eq!(full.block.transactions.len(), 3, "[emission, tx, fee]"); + let (_, full_size) = measure_block(&full.block, &srcs, &block_context(&generous)); + + let exact = params_with(1_000_000, full_size); + let at_limit = generate(&txs, &exact, &srcs); + assert_eq!( + at_limit.block.transactions.len(), + 3, + "a block summing to exactly max_block_size must be accepted whole" + ); + + let tight_size = full_size - 1; + let tight = params_with(1_000_000, tight_size); + let dropped = generate(&txs, &tight, &srcs); + let (_, size) = measure_block(&dropped.block, &srcs, &block_context(&tight)); + assert!( + size <= tight_size, + "assembled block size {size} exceeds the {tight_size} limit it was \ + built for — the fee transaction's bytes were not counted" + ); + assert_eq!( + dropped.block.transactions.len(), + 1, + "one byte short of the whole set, the selected transaction and its \ + fee transaction must both drop" + ); +} + +// --------------------------------------------------------------------------- +// 5. Conflicting mempool transactions never both land in the candidate +// --------------------------------------------------------------------------- + +/// Two transactions spending the same box both validate in isolation — the +/// contested box is unspent on chain and `validate_single_transaction` sees +/// one transaction at a time. Without a conflict check against the +/// accumulated block, both get selected and the assembled block carries a +/// double-spend. +#[test] +fn conflicting_transactions_are_not_both_selected() { + let src = source_box(0xE1, 4_000_000); + let first = fee_paying_tx(&src); + // Same input, different output script → a different transaction id. + let second = feeless_tx(&src); + assert_ne!( + first.id(), + second.id(), + "fixture: two distinct transactions" + ); + + let generated = generate( + &candidates(&[first.clone(), second.clone()]), + &Parameters::default(), + &[src], + ); + let assembled = &generated.block.transactions; + + let selected: Vec<_> = assembled + .iter() + .filter(|tx| tx.id() == first.id() || tx.id() == second.id()) + .collect(); + assert_eq!( + selected.len(), + 1, + "exactly one of two conflicting transactions may be selected" + ); + assert_eq!(selected[0].id(), first.id(), "priority order decides"); + assert!( + generated.invalid_txs.is_empty(), + "losing a mempool conflict is not evidence of invalidity — the \ + mempool owns conflict resolution" + ); +} + +// --------------------------------------------------------------------------- +// 6. The size limit is over the serialized SECTION, not the sum of transactions +// --------------------------------------------------------------------------- + +/// Validators enforce `max_block_size` against the BlockTransactions section — +/// `fb.blockTransactions.size <= currentParameters.maxBlockSize` +/// (`ErgoStateContext.scala:308-310`, rule `bsBlockTransactionsSize`) — and +/// that section carries framing no sum of transaction sizes contains: 32 bytes +/// of header id, the sentinel-offset block version, the transaction count. +/// +/// So a candidate whose transactions sum to exactly the limit is **over** it by +/// the rule that decides validity, and every peer rejects the block. This test +/// sets the limit to exactly that sum: the accounting under test must shed +/// transactions, and an accounting that sums transaction sizes accepts the +/// whole block and ships it 37 bytes over. +#[test] +fn the_size_bound_is_over_the_section_not_the_sum_of_transactions() { + let srcs = vec![source_box(0xF1, 4_000_000)]; + let txs = candidates(&[fee_paying_tx(&srcs[0])]); + + let generous = params_with(1_000_000, 524_288); + let full = generate(&txs, &generous, &srcs); + assert_eq!(full.block.transactions.len(), 3, "[emission, tx, fee]"); + + let tx_bytes: usize = full.block.transactions.iter().map(size_of).sum(); + let section_bytes = serialized_section(&full.block).len(); + assert_eq!( + section_bytes - tx_bytes, + 37, + "a v{} section framing {} transactions costs 32 header-id bytes, a \ + 4-byte version and a 1-byte count", + full.block.version, + full.block.transactions.len() + ); + + // The whole section on the limit: `<=`, so nothing sheds. + let exact = params_with(1_000_000, section_bytes); + let at_limit = generate(&txs, &exact, &srcs); + assert_eq!( + at_limit.block.transactions.len(), + 3, + "a section landing exactly on max_block_size is accepted whole — the \ + framing is counted, not padded against" + ); + assert_eq!(serialized_section(&at_limit.block).len(), section_bytes); + + // The limit is the sum of the transaction bytes — 37 short of the section. + let undercount = params_with(1_000_000, tx_bytes); + let bounded = generate(&txs, &undercount, &srcs); + let shipped = serialized_section(&bounded.block).len(); + assert!( + shipped <= tx_bytes, + "the shipped section is {shipped} bytes against a {tx_bytes} limit — \ + the section framing was not counted, and this block is rejected by \ + every peer that checks it" + ); + assert_eq!( + bounded.block.transactions.len(), + 1, + "37 bytes short of the whole set, the selected transaction and the fee \ + transaction it fed must both drop, leaving emission only" + ); + assert!( + bounded.invalid_txs.is_empty(), + "a transaction dropped for size is not invalid — it stays in the mempool" + ); +} + +// --------------------------------------------------------------------------- +// 7. Fee token aggregation, and the cap on it +// --------------------------------------------------------------------------- + +/// A token-carrying fee box is collected like any other: the miner box takes +/// the ERG and the tokens with it, and the assembled block validates. +/// +/// The regression this guards is the aggregation rewrite that the cap needed. +/// Tokens are summed in first-seen traversal order — fee boxes in block order, +/// tokens in box order — rather than in `HashMap` order, because that order +/// decides the miner box's serialized bytes, hence its id, the fee +/// transaction's id, and the transactions root the miner is handed to hash. +#[test] +fn fee_box_tokens_are_collected_onto_the_miner_box() { + const PER_BOX: usize = 50; + + let tokens_a = distinct_tokens(0x10, PER_BOX); + let tokens_b = distinct_tokens(0x20, PER_BOX); + let srcs = vec![ + token_source_box(0xA7, 2_000_000, &tokens_a), + token_source_box(0xA8, 3_000_000, &tokens_b), + ]; + let txs = vec![fee_paying_tx(&srcs[0]), fee_paying_tx(&srcs[1])]; + + // Token-heavy boxes are large and cost more to evaluate; neither limit is + // what this test is about. + let params = params_with(50_000_000, 524_288); + let generated = generate(&candidates(&txs), ¶ms, &srcs); + let assembled = &generated.block.transactions; + + assert_eq!(assembled.len(), 4, "[emission, tx, tx, fee]"); + let fee_tx = assembled.last().unwrap(); + assert!( + is_fee_tx(fee_tx), + "the last transaction must be the fee transaction" + ); + + let miner_box = fee_tx.outputs.get(0).unwrap(); + assert_eq!( + miner_box.value.as_i64(), + 5_000_000, + "every ERG of fee collected" + ); + assert_eq!( + token_ids(miner_box), + expected_ids(&[&tokens_a, &tokens_b], 2 * PER_BOX), + "every token collected, in traversal order" + ); + + // Re-validates every assembled transaction — a token-carrying fee + // collection that does not validate fails here. + measure_block(&generated.block, &srcs, &block_context(¶ms)); + assert!(generated.invalid_txs.is_empty()); +} + +/// The cap on aggregated fee tokens is the reward box's MEASURED serialized +/// size — `out.bytes.length <= MaxBoxSize` (4096), JVM rule `txBoxSize`, +/// `ErgoTransaction.scala:175`, ergo-lib `BoxSizeExceeded` — and never a token +/// count. A 255-token reward box is roughly 8.5 KB, so `MAX_TOKENS_COUNT` can +/// only move the failure from "box will not build" to "box will not validate"; +/// see `fee_tokens_past_the_box_window_are_capped_and_the_fees_collected`. +/// +/// The survivors are however many fit, in first-seen traversal order — a plain +/// take, no reordering by value, no hash-map iteration. Asserted on the +/// serialized length and on the tightness of the cut; the count is the measure +/// this change established is the wrong one. +#[test] +fn fee_tokens_are_capped_at_one_box_worth_in_traversal_order() { + const PER_BOX: usize = 100; + + let groups = [ + distinct_tokens(0x10, PER_BOX), + distinct_tokens(0x20, PER_BOX), + distinct_tokens(0x30, PER_BOX), + ]; + let block_txs: Vec = groups + .iter() + .enumerate() + .map(|(i, tokens)| fee_paying_tx(&token_source_box(0xB0 + i as u8, 2_000_000, tokens))) + .collect(); + + let fee_tx = + ergo_mining::fee::build_fee_tx(&block_txs, CANDIDATE_HEIGHT, REWARD_DELAY, &miner_pk()) + .expect("the cap is what keeps this buildable") + .expect("three fee boxes must produce a fee transaction"); + + let miner_box = fee_tx.outputs.get(0).unwrap(); + let ordered = ordered_tokens(&groups); + let kept = token_ids(miner_box).len(); + + let size = box_bytes(miner_box); + assert!( + size <= ErgoBox::MAX_BOX_SIZE, + "the reward box is {size} bytes against a {}-byte window: it cannot \ + validate, and a fee box that cannot validate collects nothing", + ErgoBox::MAX_BOX_SIZE + ); + assert!( + kept < ordered.len(), + "fixture must overflow the box window: all {} tokens fit", + ordered.len() + ); + assert!( + kept < ErgoBox::MAX_TOKENS_COUNT, + "{kept} tokens in a {}-byte box — MAX_TOKENS_COUNT cannot be what bound, \ + so the cap has regressed to a count", + ErgoBox::MAX_BOX_SIZE + ); + assert!( + box_bytes_with(miner_box, &ordered[kept]) > ErgoBox::MAX_BOX_SIZE, + "the cut is loose: token {kept} still fits in {} bytes, so the cap \ + burned a token it did not have to", + ErgoBox::MAX_BOX_SIZE + ); + assert_eq!( + token_ids(miner_box), + expected_ids(&groups.iter().collect::>(), kept), + "the survivors are the first {kept} in traversal order — a plain take, \ + no reordering by value, no hash-map iteration" + ); + assert_eq!( + miner_box.value.as_i64(), + 3 * 2_000_000, + "capping tokens must not cost the miner any ERG" + ); + assert_eq!( + fee_tx.inputs.len(), + 3, + "all three fee boxes are still spent — the dropped tokens are burned, \ + which Ergo permits and the JVM's own take does too" + ); +} + +/// ⚠ **The whole point, end to end.** 140 distinct tokens is *under* +/// `MAX_TOKENS_COUNT` and *over* the box window, so a count cap is never even +/// reached: the reward box built, failed `txBoxSize` at validation, and the +/// block shipped collecting nothing. That is what the JVM does — it caps by +/// the same wrong constant (`.take(MaxAssetsPerBox)`, +/// `CandidateGenerator.scala:811`) and loses the same fees. +/// +/// **We diverge deliberately.** Which fee boxes a miner collects is miner +/// policy, not consensus: the rule constrains the box a miner produces, not +/// the choice of what to put in it, and uncollected fee boxes stay spendable +/// later. So the fee transaction is now built, valid, and collecting — asserted +/// through `generate_candidate` with every assembled transaction re-validated, +/// because "the box serializes small enough" and "the block a peer receives is +/// accepted" are not the same claim. +#[test] +fn fee_tokens_past_the_box_window_are_capped_and_the_fees_collected() { + const PER_BOX: usize = 70; + const { + assert!( + 2 * PER_BOX < ErgoBox::MAX_TOKENS_COUNT, + "fixture must stay UNDER the token cap, so the box window is what bites" + ) + }; + + let groups = [ + distinct_tokens(0x10, PER_BOX), + distinct_tokens(0x20, PER_BOX), + ]; + let srcs = vec![ + token_source_box(0xC1, 2_000_000, &groups[0]), + token_source_box(0xC2, 3_000_000, &groups[1]), + ]; + let txs = vec![fee_paying_tx(&srcs[0]), fee_paying_tx(&srcs[1])]; + + let params = params_with(50_000_000, 524_288); + let generated = generate(&candidates(&txs), ¶ms, &srcs); + let assembled = &generated.block.transactions; + + assert_eq!(assembled.len(), 4, "[emission, tx, tx, fee]"); + let fee_tx = assembled.last().unwrap(); + assert!( + is_fee_tx(fee_tx), + "the fee transaction must ship: {} distinct tokens is a reason to burn \ + the excess, not to hand the block's whole revenue back", + 2 * PER_BOX + ); + + let miner_box = fee_tx.outputs.get(0).unwrap(); + assert_eq!( + miner_box.value.as_i64(), + 5_000_000, + "every nanoERG of fee collected — the tokens are what got capped" + ); + + let ordered = ordered_tokens(&groups); + let kept = token_ids(miner_box).len(); + let size = box_bytes(miner_box); + assert!( + size <= ErgoBox::MAX_BOX_SIZE, + "the reward box is {size} bytes against a {}-byte window", + ErgoBox::MAX_BOX_SIZE + ); + assert!( + kept < ordered.len(), + "fixture must overflow the box window: all {} tokens fit", + ordered.len() + ); + assert!( + box_bytes_with(miner_box, &ordered[kept]) > ErgoBox::MAX_BOX_SIZE, + "the cut is loose: token {kept} still fits, so the cap burned a token \ + it did not have to" + ); + assert_eq!( + token_ids(miner_box), + expected_ids(&groups.iter().collect::>(), kept), + "the survivors are the first {kept} in traversal order" + ); + + // Re-validates every assembled transaction. The reward box that used to + // fail `txBoxSize` right here is the reason this test exists. + measure_block(&generated.block, &srcs, &block_context(¶ms)); + assert!( + generated.invalid_txs.is_empty(), + "the fee boxes' creators are perfectly valid transactions" + ); +} + +// --------------------------------------------------------------------------- +// 8. An unresolvable DATA input is skipped, not evicted +// --------------------------------------------------------------------------- + +/// The same rule § 5 applies to conflicts, and the one selection already +/// applied to regular inputs: a box that will not resolve is not evidence the +/// transaction is invalid. It may be a reorg race, a fastsync gap, or the +/// at-tip storage swap window, and it may resolve on the next 15-second +/// rebuild. +/// +/// Resolving data inputs with a filter that drops the misses yields a +/// `data_boxes` shorter than `tx.data_inputs`, which `TransactionContext::new` +/// rejects deterministically as `DataInputBoxNotFound`. So the transaction +/// comes back *invalid* rather than skipped, lands in `invalid_txs`, and the +/// caller routes it to `pool.invalidate` — evicting a valid transaction for +/// the mempool's invalidation TTL. +/// +/// ⚠ The exclusion assertion passes either way: the truncated context is +/// caught up front, so a filtered transaction never reaches the block. Nothing +/// is validated against a mismatched context. The whole bug is **which of the +/// two return values** it lands in, so the `invalid_txs` assertion is the one +/// under test — restore the filter and it is the one that fails. +#[test] +fn an_unresolvable_data_input_is_skipped_not_evicted() { + let src = source_box(0xDA, 4_000_000); + // Referenced as a data input, and deliberately withheld from the lookup in + // the first pass: a box the node cannot see right now. + let referenced = source_box(0xDB, 1_000_000); + let tx = spend_into_with_data_inputs(&src, &[&referenced], fee_tree()); + let mempool = candidates(std::slice::from_ref(&tx)); + + let generated = generate(&mempool, &Parameters::default(), std::slice::from_ref(&src)); + assert!( + !generated + .block + .transactions + .iter() + .any(|t| t.id() == tx.id()), + "a transaction whose data input will not resolve cannot be selected" + ); + assert!( + generated.invalid_txs.is_empty(), + "an unresolvable data input is not evidence of invalidity — reporting \ + it evicts a valid transaction from the mempool for the invalidation \ + TTL, when it would have selected fine on the next rebuild" + ); + + // Control: hand the lookup the same box and the transaction selects. The + // skip above is about resolution, not about carrying a data input at all. + let resolved = generate(&mempool, &Parameters::default(), &[src, referenced]); + assert!( + resolved + .block + .transactions + .iter() + .any(|t| t.id() == tx.id()), + "fixture invariant: the transaction is otherwise selectable" + ); + assert!(resolved.invalid_txs.is_empty()); +} diff --git a/mining/tests/candidate_generator.rs b/mining/tests/candidate_generator.rs index 9f4ed6b..ba40222 100644 --- a/mining/tests/candidate_generator.rs +++ b/mining/tests/candidate_generator.rs @@ -10,12 +10,14 @@ use ergo_chain_types::{ }; use ergo_lib::chain::emission::MonetarySettings; use ergo_lib::chain::genesis; +use ergo_lib::chain::parameters::Parameters; +use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; use ergo_lib::ergotree_ir::serialization::SigmaSerializable; use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; use ergo_mining::emission::ReemissionRules; use ergo_mining::solution::validate_solution; use ergo_mining::types::*; -use ergo_mining::{CandidateGenerator, ValidatorProofsResult}; +use ergo_mining::{CandidateGenerator, GeneratedCandidate, ValidatorProofsResult}; const INITIAL_N_BITS: u32 = 16842752; @@ -25,16 +27,22 @@ const FOUNDER_PKS: &[&str] = &[ "0352ac2a471339b0d23b3d2c5ce0db0e81c969f77891b9edf0bda7fd39a78184e7", ]; const PROOFS: &[&str] = &[ - "test-proof-1", "test-proof-2", "test-proof-3", - "test-proof-4", "test-proof-5", + "test-proof-1", + "test-proof-2", + "test-proof-3", + "test-proof-4", + "test-proof-5", ]; fn founder_pks() -> Vec { - FOUNDER_PKS.iter().map(|hex_str| { - let bytes = hex::decode(hex_str).unwrap(); - let point = EcPoint::sigma_parse_bytes(&bytes).unwrap(); - ProveDlog::new(point) - }).collect() + FOUNDER_PKS + .iter() + .map(|hex_str| { + let bytes = hex::decode(hex_str).unwrap(); + let point = EcPoint::sigma_parse_bytes(&bytes).unwrap(); + ProveDlog::new(point) + }) + .collect() } fn test_miner_pk() -> ProveDlog { @@ -76,20 +84,43 @@ fn test_config() -> MinerConfig { } } -fn mock_proofs( - _txs: &[ergo_lib::chain::transaction::Transaction], -) -> ValidatorProofsResult { +fn mock_proofs(_txs: &[ergo_lib::chain::transaction::Transaction]) -> ValidatorProofsResult { Some(Ok((vec![0u8; 64], ADDigest::from([0u8; 33])))) } +/// `generate_candidate` with an empty mempool — the shape every test in this +/// file needs. With no candidate transactions, selection and fee collection +/// short-circuit, so the parameters, ancestor headers and UTXO lookup are +/// never consulted. +fn gen_empty_mempool( + config: &MinerConfig, + parent: &Header, + interlinks: &[BlockId], + emission_box: &ErgoBox, +) -> Result { + ergo_mining::generate_candidate( + config, + parent, + INITIAL_N_BITS, + interlinks, + emission_box, + None, + &[], + &[], + &Parameters::default(), + &[], + &|_| None, + &mock_proofs, + ) +} + /// Generate a candidate at the given parent. fn gen_candidate(config: &MinerConfig, parent: &Header) -> (CandidateBlock, WorkMessage) { let settings = MonetarySettings::default(); let pks = founder_pks(); let (emission_box, _, _) = genesis::genesis_boxes(&settings, &pks, 2, PROOFS).unwrap(); - ergo_mining::generate_candidate( - config, parent, INITIAL_N_BITS, &[], &emission_box, None, &[], &mock_proofs, - ).unwrap() + let generated = gen_empty_mempool(config, parent, &[], &emission_box).unwrap(); + (generated.block, generated.work) } /// CPU mine with trivial difficulty. @@ -123,7 +154,8 @@ fn cached_work_returns_same_result_within_ttl() { // Multiple polls at the same tip height should return the cached work for _ in 0..20 { - let w = gen.cached_work(parent.height) + let w = gen + .cached_work(parent.height) .expect("should return cached work"); assert_eq!(w.h, work.h); assert_eq!(w.msg, work.msg); @@ -157,7 +189,10 @@ fn cached_work_returns_none_after_ttl_expires() { gen.cache_candidate(block, work, parent.height); std::thread::sleep(Duration::from_millis(5)); - assert!(gen.cached_work(parent.height).is_none(), "should expire after TTL"); + assert!( + gen.cached_work(parent.height).is_none(), + "should expire after TTL" + ); } // --------------------------------------------------------------------------- @@ -193,7 +228,9 @@ fn stale_solution_accepted_after_regeneration() { gen.cache_candidate(block2, work2, parent.height); // The previous candidate should still be accessible - let prev = gen.previous_block().expect("previous candidate should exist"); + let prev = gen + .previous_block() + .expect("previous candidate should exist"); assert_eq!(prev.timestamp, block1.timestamp); // Solution for the OLD candidate should still validate @@ -304,9 +341,9 @@ fn mined_block_advances_emission_box() { // Generate and mine block 2 let interlinks_1: Vec = vec![]; - let (candidate_2, _) = ergo_mining::generate_candidate( - &config, &parent, INITIAL_N_BITS, &interlinks_1, &emission_box, None, &[], &mock_proofs, - ).unwrap(); + let candidate_2 = gen_empty_mempool(&config, &parent, &interlinks_1, &emission_box) + .unwrap() + .block; let header_2 = cpu_mine(&candidate_2); assert_eq!(header_2.height, 2); @@ -318,9 +355,9 @@ fn mined_block_advances_emission_box() { .expect("interlinks update"); // Generate block 3 using the updated emission box - let (candidate_3, _) = ergo_mining::generate_candidate( - &config, &header_2, INITIAL_N_BITS, &interlinks_2, &new_emission_box, None, &[], &mock_proofs, - ).unwrap(); + let candidate_3 = gen_empty_mempool(&config, &header_2, &interlinks_2, &new_emission_box) + .unwrap() + .block; let header_3 = cpu_mine(&candidate_3); assert_eq!(header_3.height, 3); @@ -329,10 +366,7 @@ fn mined_block_advances_emission_box() { let reward_2 = emission_rules.miners_reward_at_height(2); let reward_3 = emission_rules.miners_reward_at_height(3); let original_value = emission_box.value.as_i64(); - assert_eq!( - new_emission_box.value.as_i64(), - original_value - reward_2, - ); + assert_eq!(new_emission_box.value.as_i64(), original_value - reward_2,); let final_emission_box = candidate_3.transactions[0].outputs.get(0).unwrap(); assert_eq!( final_emission_box.value.as_i64(), @@ -369,7 +403,10 @@ fn on_block_applied_own_block_drops_slots_and_clears_latch() { gen.on_block_applied(&solved.id, solved.height); assert!(gen.cached_block().is_none(), "current must be dropped"); assert!(gen.previous_block().is_none(), "previous must be dropped"); - assert!(!gen.solved_pending(), "latch must clear on own block applied"); + assert!( + !gen.solved_pending(), + "latch must clear on own block applied" + ); } #[test] @@ -415,18 +452,9 @@ fn on_block_applied_keeps_candidate_building_on_applied_block() { let interlinks_2 = ergo_nipopow::NipopowAlgos::update_interlinks(parent, vec![]) .expect("interlinks update for header_2"); let emission_box_2 = block_a.transactions[0].outputs.get(0).unwrap().clone(); - let (block_c2, work_c2) = ergo_mining::generate_candidate( - &config, - &header_2, - INITIAL_N_BITS, - &interlinks_2, - &emission_box_2, - None, - &[], - &mock_proofs, - ) - .expect("candidate on header_2"); - gen.cache_candidate(block_c2, work_c2, header_2.height); + let generated = gen_empty_mempool(&config, &header_2, &interlinks_2, &emission_box_2) + .expect("candidate on header_2"); + gen.cache_candidate(generated.block, generated.work, header_2.height); // header_2 applies: current still builds on the tip → survives; // previous (genesis parent) does not → dropped. Per-slot independence. @@ -474,7 +502,10 @@ fn solved_latch_survives_application_below_latch_height() { // Reorg re-application below the latched height must not clear the // latch — our solved block's height has not been reached yet. gen.on_block_applied(&block_id(0x01), 9); - assert!(gen.solved_pending(), "latch must survive lower-height application"); + assert!( + gen.solved_pending(), + "latch must survive lower-height application" + ); } #[test] diff --git a/mining/tests/mine_blocks.rs b/mining/tests/mine_blocks.rs index 2af7b1d..a28a1dc 100644 --- a/mining/tests/mine_blocks.rs +++ b/mining/tests/mine_blocks.rs @@ -9,15 +9,16 @@ use std::time::Duration; use ergo_chain_types::{ ADDigest, AutolykosSolution, BlockId, Digest, Digest32, EcPoint, Header, Votes, }; -use tracing_test::traced_test; use ergo_lib::chain::emission::{EmissionRules, MonetarySettings}; use ergo_lib::chain::genesis; +use ergo_lib::chain::parameters::Parameters; use ergo_lib::ergotree_ir::serialization::SigmaSerializable; use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; use ergo_mining::emission::{build_emission_tx, ReemissionRules}; use ergo_mining::solution::validate_solution; use ergo_mining::types::*; use ergo_mining::{MiningError, ValidatorProofsResult}; +use tracing_test::traced_test; /// Initial difficulty for testnet/mainnet — decodes to 1. /// Target = order / 1 ≈ 2^256, so any nonce is valid. @@ -113,8 +114,7 @@ fn cpu_mine(candidate: &CandidateBlock, max_attempts: u64) -> Result= 20, + "b must be the target, not the difficulty: got {} digits ({})", + work.b.len(), + work.b + ); assert!(!work.pk.is_empty(), "pk should be non-empty hex"); - assert!(work.proof.is_none(), "basic candidate omits the proof (no nested msgPreimage)"); + assert!( + work.proof.is_none(), + "basic candidate omits the proof (no nested msgPreimage)" + ); // Verify candidate structure - assert_eq!(candidate.transactions.len(), 1, "should have 1 tx (emission only)"); + assert_eq!( + candidate.transactions.len(), + 1, + "should have 1 tx (emission only)" + ); assert_eq!(candidate.version, 2); assert_eq!(candidate.n_bits, INITIAL_N_BITS); assert_eq!(candidate.votes, [0, 0, 0]); @@ -208,8 +236,7 @@ fn generate_candidate_and_mine_block() { fn mine_three_consecutive_blocks() { let settings = MonetarySettings::default(); let pks = founder_pks(); - let (emission_box, _, _) = - genesis::genesis_boxes(&settings, &pks, 2, PROOFS).unwrap(); + let (emission_box, _, _) = genesis::genesis_boxes(&settings, &pks, 2, PROOFS).unwrap(); let miner_pk = test_miner_pk(); let config = MinerConfig { @@ -237,7 +264,7 @@ fn mine_three_consecutive_blocks() { for _ in 0..3u32 { let height = parent.height + 1; - let (candidate, _work) = ergo_mining::generate_candidate( + let candidate = ergo_mining::generate_candidate( &config, &parent, INITIAL_N_BITS, @@ -245,9 +272,14 @@ fn mine_three_consecutive_blocks() { ¤t_emission_box, None, // no boundary params (heights 2-4 are not epoch boundaries) &[], // no proposed-update payload + &[], // empty mempool + &Parameters::default(), + &[], // no ancestors beyond the parent + &|_| None, &mock_proofs, ) - .unwrap_or_else(|e| panic!("candidate at height {height} failed: {e}")); + .unwrap_or_else(|e| panic!("candidate at height {height} failed: {e}")) + .block; let header = cpu_mine(&candidate, 100) .unwrap_or_else(|e| panic!("mining block at height {height} failed: {e}")); @@ -259,11 +291,8 @@ fn mine_three_consecutive_blocks() { ); // Update interlinks for the next block - interlinks = ergo_nipopow::NipopowAlgos::update_interlinks( - parent.clone(), - interlinks, - ) - .unwrap_or_else(|e| panic!("interlinks update at height {height} failed: {e}")); + interlinks = ergo_nipopow::NipopowAlgos::update_interlinks(parent.clone(), interlinks) + .unwrap_or_else(|e| panic!("interlinks update at height {height} failed: {e}")); // The emission tx is the first transaction in the candidate. // Its first output is the new emission box for the next block. @@ -309,8 +338,7 @@ fn mine_three_consecutive_blocks() { fn mining_block_found_emits_contract_marker() { let settings = MonetarySettings::default(); let pks = founder_pks(); - let (emission_box, _, _) = - genesis::genesis_boxes(&settings, &pks, 2, PROOFS).unwrap(); + let (emission_box, _, _) = genesis::genesis_boxes(&settings, &pks, 2, PROOFS).unwrap(); let miner_pk = test_miner_pk(); let config = MinerConfig { @@ -328,7 +356,7 @@ fn mining_block_found_emits_contract_marker() { Some(Ok((vec![0u8; 64], ADDigest::from([0u8; 33])))) }; - let (candidate, _work) = ergo_mining::generate_candidate( + let candidate = ergo_mining::generate_candidate( &config, &parent, INITIAL_N_BITS, @@ -336,17 +364,18 @@ fn mining_block_found_emits_contract_marker() { &emission_box, None, &[], + &[], + &Parameters::default(), + &[], + &|_| None, &mock_proofs, ) - .expect("candidate generation failed"); + .expect("candidate generation failed") + .block; - let header = - cpu_mine(&candidate, 100).expect("mining should succeed with trivial difficulty"); + let header = cpu_mine(&candidate, 100).expect("mining should succeed with trivial difficulty"); - assert!( - logs_contain("mining: block found"), - "marker prefix missing" - ); + assert!(logs_contain("mining: block found"), "marker prefix missing"); assert!( logs_contain(&format!("height={}", header.height)), "height field missing" diff --git a/mining/tests/selection_bounds.rs b/mining/tests/selection_bounds.rs new file mode 100644 index 0000000..fc4aee9 --- /dev/null +++ b/mining/tests/selection_bounds.rs @@ -0,0 +1,383 @@ +//! Cost and size bounding in `select_transactions` (contract Step 3, 4a–4e). +//! +//! Fixtures are the block-342,964 fee-contract spend vectors already proven in +//! `ergo-validation`: fee boxes in, one substituted miner-reward box out, empty +//! proof — the script reduces to true on its own, so every candidate has a +//! cheap, deterministic cost to bound against. Costs are measured at runtime +//! rather than hardcoded; the numbers move whenever sigma-rust's costing does, +//! and these tests are about the bound, not the tariff. + +use std::collections::HashMap; + +use ergo_chain_types::{ADDigest, AutolykosSolution, BlockId, Digest32, EcPoint, Header, Votes}; +use ergo_lib::chain::transaction::input::prover_result::ProverResult; +use ergo_lib::chain::transaction::input::Input; +use ergo_lib::chain::transaction::Transaction; +use ergo_lib::ergotree_interpreter::sigma_protocol::prover::ProofBytes; +use ergo_lib::ergotree_ir::chain::context_extension::ContextExtension; +use ergo_lib::ergotree_ir::chain::ergo_box::box_value::BoxValue; +use ergo_lib::ergotree_ir::chain::ergo_box::{ErgoBox, ErgoBoxCandidate, NonMandatoryRegisters}; +use ergo_lib::ergotree_ir::chain::tx_id::TxId; +use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; +use ergo_lib::ergotree_ir::serialization::SigmaSerializable; +use ergo_mining::selection::{block_transactions_overhead, select_transactions, CostedTx}; +use ergo_validation::{ + build_state_context, validate_single_transaction, ErgoStateContext, Parameter, Parameters, +}; + +const FEE_CONTRACT_HEX: &str = "1005040004000e36100204a00b08cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ea02d192a39a8cc7a701730073011001020402d19683030193a38cc7b2a57300000193c2b2a57301007473027303830108cdeeac93b1a57304"; +const OUTPUT_TREE_HEX: &str = "100204a00b08cd02a27f37ca339c25a8ee65cbdb73fe7a7134dd89cd3e7c43e313a92c128859e4f6ea02d192a39a8cc7a70173007301"; +const MINER_PK_HEX: &str = "02a27f37ca339c25a8ee65cbdb73fe7a7134dd89cd3e7c43e313a92c128859e4f6"; +const BLOCK_HEIGHT: u32 = 342_964; +/// Mainnet block 342,964 is a v1 block, and the fixture header says so. It is +/// also a size input: a v1 BlockTransactions section carries no version field, +/// so its framing is 33 bytes rather than the 37 a current-version block pays. +const BLOCK_VERSION: u8 = 1; + +/// Mainnet default — the state context's own per-tx budget, deliberately kept +/// generous so every fixture validates. The bound under test is the argument +/// passed to `select_transactions`, not this. +const CONTEXT_MAX_COST: i32 = 1_000_000; + +fn ec_point(hex_str: &str) -> EcPoint { + EcPoint::sigma_parse_bytes(&hex::decode(hex_str).unwrap()).unwrap() +} + +fn fee_box(seed: u8, value: u64) -> ErgoBox { + let tree = ErgoTree::sigma_parse_bytes(&hex::decode(FEE_CONTRACT_HEX).unwrap()).unwrap(); + ErgoBox::new( + BoxValue::try_from(value).unwrap(), + tree, + None, + NonMandatoryRegisters::empty(), + 342_900, + TxId::from(Digest32::from([seed; 32])), + 0, + ) + .unwrap() +} + +/// Spend every source box into one miner-reward output. More inputs means more +/// script evaluations, which is how the "expensive" candidate gets its cost. +fn fee_spend_tx(srcs: &[ErgoBox]) -> Transaction { + let output_tree = ErgoTree::sigma_parse_bytes(&hex::decode(OUTPUT_TREE_HEX).unwrap()).unwrap(); + let total: i64 = srcs.iter().map(|b| b.value.as_i64()).sum(); + let output = ErgoBoxCandidate { + value: BoxValue::try_from(u64::try_from(total).unwrap()).unwrap(), + ergo_tree: output_tree, + tokens: None, + additional_registers: NonMandatoryRegisters::empty(), + creation_height: BLOCK_HEIGHT, + }; + let inputs: Vec = srcs + .iter() + .map(|b| { + Input::new( + b.box_id(), + ProverResult { + proof: ProofBytes::Empty, + extension: ContextExtension::empty(), + }, + ) + }) + .collect(); + Transaction::new_from_vec(inputs, vec![], vec![output]).unwrap() +} + +fn block_header() -> Header { + let parent_id_bytes: [u8; 32] = + hex::decode("be5d64122592b6d2a07a3a619d4e68598e8df38e57ccbff732fc797bbdcf86ef") + .unwrap() + .try_into() + .unwrap(); + Header { + version: BLOCK_VERSION, + id: BlockId(Digest32::from([0u8; 32])), + parent_id: BlockId(parent_id_bytes.into()), + ad_proofs_root: Digest32::from([0u8; 32]), + state_root: ADDigest::from([0u8; 33]), + transaction_root: Digest32::from([0u8; 32]), + timestamp: 1603134264292, + n_bits: 118099735, + height: BLOCK_HEIGHT, + extension_root: Digest32::from([0u8; 32]), + autolykos_solution: AutolykosSolution { + miner_pk: Box::new(ec_point(MINER_PK_HEX)), + pow_onetime_pk: None, + nonce: vec![0u8; 8], + pow_distance: None, + }, + votes: Votes([4, 3, 0]), + unparsed_bytes: Box::new([]), + } +} + +fn parent_header() -> Header { + Header { + version: BLOCK_VERSION, + id: BlockId(Digest32::from([1u8; 32])), + parent_id: BlockId(Digest32::from([0u8; 32])), + ad_proofs_root: Digest32::from([0u8; 32]), + state_root: ADDigest::from([0u8; 33]), + transaction_root: Digest32::from([0u8; 32]), + timestamp: 1603134202817, + n_bits: 118099735, + height: BLOCK_HEIGHT - 1, + extension_root: Digest32::from([0u8; 32]), + autolykos_solution: AutolykosSolution { + miner_pk: Box::new(EcPoint::default()), + pow_onetime_pk: None, + nonce: vec![0u8; 8], + pow_distance: None, + }, + votes: Votes([4, 0, 0]), + unparsed_bytes: Box::new([]), + } +} + +fn state_context() -> ErgoStateContext { + let mut params = Parameters::default(); + params + .parameters_table + .insert(Parameter::MaxBlockCost, CONTEXT_MAX_COST); + build_state_context(&block_header(), &[parent_header()], ¶ms) +} + +/// UTXO resolver over a fixed box set — the "already on chain" side. +fn lookup_over(boxes: &[ErgoBox]) -> impl Fn(&[u8; 32]) -> Option + '_ { + let map: HashMap<[u8; 32], ErgoBox> = boxes + .iter() + .map(|b| { + let mut id = [0u8; 32]; + id.copy_from_slice(b.box_id().as_ref()); + (id, b.clone()) + }) + .collect(); + move |id: &[u8; 32]| map.get(id).cloned() +} + +fn cost_of(tx: &Transaction, boxes: &[ErgoBox], ctx: &ErgoStateContext) -> u64 { + validate_single_transaction(tx, boxes.to_vec(), vec![], ctx) + .expect("fixture transaction must validate") +} + +fn serialized_size(tx: &Transaction) -> usize { + tx.sigma_serialize_bytes().unwrap().len() +} + +fn ids(txs: &[Transaction]) -> Vec { + txs.iter().map(|tx| format!("{}", tx.id())).collect() +} + +fn selected_ids(selected: &[CostedTx]) -> Vec { + selected.iter().map(|c| format!("{}", c.tx.id())).collect() +} + +/// The half a `break` would silently fail: an over-budget transaction is +/// skipped, and the scan keeps going so a later cheaper one still gets in. +#[test] +fn over_budget_tx_is_skipped_but_a_later_cheaper_one_fits() { + let ctx = state_context(); + + // Expensive: three fee-box inputs, three script evaluations. + let big_srcs = vec![ + fee_box(0xA1, 2_000_000), + fee_box(0xA2, 1_000_000), + fee_box(0xA3, 1_000_000), + ]; + let big = fee_spend_tx(&big_srcs); + // Cheap: one input. + let small_srcs = vec![fee_box(0xB1, 1_000_000)]; + let small = fee_spend_tx(&small_srcs); + + let big_cost = cost_of(&big, &big_srcs, &ctx); + let small_cost = cost_of(&small, &small_srcs, &ctx); + assert!( + big_cost > small_cost, + "fixture invariant: {big_cost} must exceed {small_cost}" + ); + + let mut utxos = big_srcs.clone(); + utxos.extend(small_srcs.clone()); + let lookup = lookup_over(&utxos); + + // Budget fits the cheap tx exactly, and the expensive one not at all. + let (selected, invalid) = select_transactions( + &[ + (big.clone(), serialized_size(&big)), + (small.clone(), serialized_size(&small)), + ], + &[], + &ctx, + BLOCK_VERSION, + 524_288, + small_cost, + &lookup, + ); + + assert_eq!( + selected_ids(&selected), + ids(&[small]), + "the expensive tx must be skipped and the cheap one still selected" + ); + assert!( + invalid.is_empty(), + "an over-budget tx is valid — it must not be reported for mempool eviction" + ); +} + +/// The bound is `<=`. A candidate set landing on exactly `max_block_cost` is +/// accepted; one block unit less and the last transaction drops out. +#[test] +fn exactly_max_block_cost_is_accepted() { + let ctx = state_context(); + + let src_a = vec![fee_box(0xC1, 2_000_000)]; + let src_b = vec![fee_box(0xC2, 1_000_000)]; + let tx_a = fee_spend_tx(&src_a); + let tx_b = fee_spend_tx(&src_b); + + let cost_a = cost_of(&tx_a, &src_a, &ctx); + let cost_b = cost_of(&tx_b, &src_b, &ctx); + assert!(cost_a > 0 && cost_b > 0, "fixtures must have nonzero cost"); + + let mut utxos = src_a.clone(); + utxos.extend(src_b.clone()); + let lookup = lookup_over(&utxos); + let candidates = [ + (tx_a.clone(), serialized_size(&tx_a)), + (tx_b.clone(), serialized_size(&tx_b)), + ]; + + let (selected, invalid) = select_transactions( + &candidates, + &[], + &ctx, + BLOCK_VERSION, + 524_288, + cost_a + cost_b, + &lookup, + ); + assert_eq!( + selected_ids(&selected), + ids(&[tx_a.clone(), tx_b.clone()]), + "a candidate set summing to exactly max_block_cost must be accepted" + ); + assert!(invalid.is_empty()); + + // One unit under: the second transaction no longer fits. + let (selected, invalid) = select_transactions( + &candidates, + &[], + &ctx, + BLOCK_VERSION, + 524_288, + cost_a + cost_b - 1, + &lookup, + ); + assert_eq!( + selected_ids(&selected), + ids(&[tx_a]), + "one block unit under the sum must drop the second transaction" + ); + assert!(invalid.is_empty()); +} + +/// Size bounding is unchanged by the cost bound: still skip-and-continue, +/// still independent of it. +#[test] +fn size_bound_still_skips_and_keeps_scanning() { + let ctx = state_context(); + + let src_a = vec![fee_box(0xD1, 2_000_000)]; + let src_b = vec![fee_box(0xD2, 1_000_000)]; + let tx_a = fee_spend_tx(&src_a); + let tx_b = fee_spend_tx(&src_b); + + let mut utxos = src_a.clone(); + utxos.extend(src_b.clone()); + let lookup = lookup_over(&utxos); + + // Declared sizes, not real ones: the caller supplies them, and the point + // here is the arithmetic of the bound. + let (selected, invalid) = select_transactions( + &[(tx_a, 400), (tx_b.clone(), 100)], + &[], + &ctx, + BLOCK_VERSION, + 300, + u64::from(u32::MAX), + &lookup, + ); + + assert_eq!( + selected_ids(&selected), + ids(&[tx_b]), + "the oversized tx is skipped and the smaller one still selected" + ); + assert!(invalid.is_empty()); +} + +/// The bound is over the serialized BlockTransactions **section**, so its +/// framing bytes are inside `max_block_size` — a transaction that fits only +/// when they are ignored must be skipped. +/// +/// Measured at the boundary in both directions, because the margin is a few +/// dozen bytes and an accounting that sums transaction sizes gets the +/// comfortable cases right for free. +#[test] +fn size_bound_counts_the_section_framing() { + let ctx = state_context(); + let src = vec![fee_box(0xF1, 1_000_000)]; + let tx = fee_spend_tx(&src); + let lookup = lookup_over(&src); + + // A v1 section frames one transaction in 32 header-id bytes plus the + // one-byte VLQ count. No version field: v1 predates it. + let framing = block_transactions_overhead(BLOCK_VERSION, 1); + assert_eq!( + framing, 33, + "fixture invariant: v1 framing for one transaction" + ); + + // Declared size, not the real one — the caller supplies it and the point + // here is the arithmetic of the bound. + const DECLARED: usize = 500; + let candidate = [(tx, DECLARED)]; + + let select_under = |max_block_size: usize| { + let (selected, invalid) = select_transactions( + &candidate, + &[], + &ctx, + BLOCK_VERSION, + max_block_size, + u64::from(u32::MAX), + &lookup, + ); + assert!( + invalid.is_empty(), + "a transaction skipped for size is valid — it stays in the mempool" + ); + selected.len() + }; + + assert_eq!( + select_under(DECLARED), + 0, + "a limit equal to the transaction's own size leaves no room for the \ + section framing: counting transactions only would accept this and \ + ship a block every peer rejects" + ); + assert_eq!( + select_under(DECLARED + framing - 1), + 0, + "one byte short of the section is still over the limit" + ); + assert_eq!( + select_under(DECLARED + framing), + 1, + "the section landing exactly on the limit is accepted — the bound is \ + `<=`, and the framing is accounted for, not padded against" + ); +} diff --git a/mining/tests/transactions_root.rs b/mining/tests/transactions_root.rs index 8e843a1..dacdfe8 100644 --- a/mining/tests/transactions_root.rs +++ b/mining/tests/transactions_root.rs @@ -102,10 +102,18 @@ fn empty_proof_input_contributes_nothing() { // The emission tx's input spends the emission box with ProofBytes::Empty. let emission_box = mainnet_emission_box(); let miner_pk = test_miner_pk(); - let tx = build_emission_tx(&emission_box, 1, &miner_pk, 720, &ReemissionRules::mainnet()) - .unwrap(); + let tx = build_emission_tx( + &emission_box, + 1, + &miner_pk, + 720, + &ReemissionRules::mainnet(), + ) + .unwrap(); assert!( - tx.inputs.iter().all(|i| i.spending_proof.proof.as_ref().is_empty()), + tx.inputs + .iter() + .all(|i| i.spending_proof.proof.as_ref().is_empty()), "test premise: emission tx inputs carry empty proofs" ); @@ -131,10 +139,8 @@ fn empty_tx_list_is_an_error() { fn test_miner_pk() -> ProveDlog { let bytes = - hex::decode("039bb5fe52359a64c99a60fd944fc5e388cbdc4d37ff091cc841c3ee79060b8647") - .unwrap(); - let point = - ergo_chain_types::EcPoint::sigma_parse_bytes(&bytes).unwrap(); + hex::decode("039bb5fe52359a64c99a60fd944fc5e388cbdc4d37ff091cc841c3ee79060b8647").unwrap(); + let point = ergo_chain_types::EcPoint::sigma_parse_bytes(&bytes).unwrap(); ProveDlog::new(point) } diff --git a/mining/tests/work_message_wiring.rs b/mining/tests/work_message_wiring.rs index 53d9bec..2a536f7 100644 --- a/mining/tests/work_message_wiring.rs +++ b/mining/tests/work_message_wiring.rs @@ -27,7 +27,11 @@ //! canonical hex in our own test suite. It catches drift on future //! sigma-rust bumps without us having to also run sigma-rust's tests. +use std::str::FromStr; + use blake2::Digest as Blake2Digest; +use enr_chain::BigInt; +use ergo_chain_types::autolykos_pow_scheme::{decode_compact_bits, AutolykosPowScheme}; use ergo_chain_types::{ ADDigest, AutolykosSolution, BlockId, Digest, Digest32, EcPoint, Header, Votes, }; @@ -38,7 +42,11 @@ use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; use ergo_mining::candidate::{build_work_message, transactions_root}; use ergo_mining::emission::{build_emission_tx, ReemissionRules}; use ergo_mining::extension::extension_digest; +use ergo_mining::solution::validate_solution; use ergo_mining::types::*; +use ergo_mining::MiningError; +use num_bigint::ToBigInt; +use sigma_ser::ScorexSerializable; type Blake2b256 = blake2::Blake2b; @@ -113,8 +121,7 @@ fn build_work_message_wiring_matches_independent_serialization() { let parent = parent_for_wiring_test(); let settings = MonetarySettings::default(); let pks = founder_pks(); - let (emission_box, _, _) = - genesis::genesis_boxes(&settings, &pks, 2, TEST_PROOFS).unwrap(); + let (emission_box, _, _) = genesis::genesis_boxes(&settings, &pks, 2, TEST_PROOFS).unwrap(); let miner_pk = test_miner_pk(); let reemission_rules = ReemissionRules::mainnet(); @@ -274,8 +281,7 @@ fn sigma_rust_serialize_without_pow_matches_jvm_canonical_height_614400() { let msg_hex = hex::encode(blake2b256_bytes(&bytes)); assert_eq!( - msg_hex, - "548c3e602a8f36f8f2738f5f643b02425038044d98543a51cabaa9785e7e864f", + msg_hex, "548c3e602a8f36f8f2738f5f643b02425038044d98543a51cabaa9785e7e864f", "sigma-rust serialize_without_pow drifted from JVM-canonical msg \ for height 614400. Either sigma-rust changed its serialization \ (which would be a hard fork on the wire) or our blake2b256 \ @@ -283,6 +289,235 @@ fn sigma_rust_serialize_without_pow_matches_jvm_canonical_height_614400() { ); } +// --------------------------------------------------------------------------- +// WorkMessage.b — the target served to external miners +// +// v0.8.0 served `decode_compact_bits(n_bits)` here: the DIFFICULTY, not the +// target. A GPU miner ran nine minutes at 143 MH/s against the released node +// and submitted zero shares, because the bound it was handed was tens of +// orders of magnitude tighter than the one `check_pow` on the solution path +// actually enforces. Three tests over `b` (non-empty, stable across polls, +// bare JSON number) passed continuously throughout — present, consistent, +// well-typed, and the wrong quantity. These two assert the value. +// --------------------------------------------------------------------------- + +/// Compact-bits encoding of the difficulty at testnet height 485,897. +/// **DERIVED**, not observed — see `served_b_matches_scala_node_vector`. +const SCALA_N_BITS: u32 = 83_945_773; // 0x0500e92d + +/// Difficulty reported by a live Scala node at testnet height 485,897. +/// **Observed.** +const SCALA_DIFFICULTY: u64 = 3_912_040_448; + +/// The `b` that same Scala node served for that height. **Observed**, and +/// cross-checked digit for digit against `floor(q / 3912040448)` (remainder +/// 1309294913). +const SCALA_TARGET: &str = "29598898778389163379010897437604384363675568080188445020547283242588"; + +/// Compact-bits encoding of a small integer difficulty. For `size == 3` the +/// mantissa *is* the value, so this is just `0x03000000 | d`. +fn n_bits_for_difficulty(d: u32) -> u32 { + assert!(d <= 0x007f_ffff, "difficulty must fit the 23-bit mantissa"); + 0x0300_0000 | d +} + +/// The wiring-test fixture at a chosen difficulty encoding. Only `n_bits` +/// varies, so the header bytes — and therefore the PoW hit — are a +/// deterministic function of `n_bits` and the nonce. +fn candidate_at_n_bits(n_bits: u32) -> CandidateBlock { + let parent = parent_for_wiring_test(); + let settings = MonetarySettings::default(); + let pks = founder_pks(); + let (emission_box, _, _) = genesis::genesis_boxes(&settings, &pks, 2, TEST_PROOFS).unwrap(); + let height = parent.height + 1; + let emission_tx = build_emission_tx( + &emission_box, + height, + &test_miner_pk(), + 720, + &ReemissionRules::mainnet(), + ) + .unwrap(); + + CandidateBlock { + parent, + version: 2, + n_bits, + state_root: ADDigest::from([0xAA; 33]), + ad_proof_bytes: vec![0xCC; 16], + transactions: vec![emission_tx], + timestamp: 1_700_000_000_500, + extension: ExtensionCandidate { + fields: vec![([0x01, 0x00], vec![0xEE; 32])], + }, + votes: [0, 0, 0], + header_bytes: vec![], + } +} + +/// The header `validate_solution` assembles for `candidate` + `nonce`, rebuilt +/// here because `pow_hit` needs a `Header` and the solution path only hands one +/// back when it accepts. Every accepted solution below cross-checks the two +/// serialize identically, so the hit read out of this really is the hit the +/// solution path compared. +fn header_for_nonce(candidate: &CandidateBlock, nonce: u64) -> Header { + Header { + version: candidate.version, + id: BlockId(Digest::from([0u8; 32])), + parent_id: candidate.parent.id, + ad_proofs_root: Digest32::from(blake2b256_bytes(&candidate.ad_proof_bytes)), + state_root: candidate.state_root, + transaction_root: transactions_root(&candidate.transactions, candidate.version).unwrap(), + timestamp: candidate.timestamp, + n_bits: candidate.n_bits, + height: candidate.parent.height + 1, + extension_root: Digest32::from(extension_digest(&candidate.extension).unwrap()), + autolykos_solution: solution_with_nonce(nonce), + votes: Votes(candidate.votes), + unparsed_bytes: Box::new([]), + } +} + +fn solution_with_nonce(nonce: u64) -> AutolykosSolution { + AutolykosSolution { + miner_pk: Box::new(*test_miner_pk().h), + pow_onetime_pk: None, + nonce: nonce.to_be_bytes().to_vec(), + pow_distance: None, + } +} + +/// (a) The served `b` against a fixed pair captured from a Scala node. +/// +/// Provenance, because the three numbers are not the same kind of thing: +/// `SCALA_DIFFICULTY` and `SCALA_TARGET` were **observed** — read off a live +/// Scala node at testnet height 485,897 and compared digit for digit. +/// `SCALA_N_BITS` is **derived**, computed by canonical compact-bits encoding +/// of the observed difficulty rather than read off the wire. So the round-trip +/// is asserted first: a bad derivation then fails loudly here instead of +/// letting the target assertion quietly pass against some other difficulty. +/// +/// Asserted against the literal on purpose. `assert_eq!(work.b, +/// pow_target(n).to_string())` would restate the implementation and pass on +/// any self-consistent definition — including the one that shipped. +/// +/// `chain/` carries the same vector against `pow_target` directly +/// (`chain/src/tests.rs::pow_target_matches_scala_node`). That is deliberate, +/// not duplication to consolidate: this one covers the whole serve path out to +/// the string a miner receives, that one covers the function. +#[test] +fn served_b_matches_scala_node_vector() { + assert_eq!( + decode_compact_bits(SCALA_N_BITS), + BigInt::from(SCALA_DIFFICULTY), + "n_bits derivation is wrong — the target assertion below would be \ + testing a difficulty the Scala node never reported" + ); + + let candidate = candidate_at_n_bits(SCALA_N_BITS); + let (_, work) = build_work_message(&candidate, &test_miner_pk().h).unwrap(); + + assert_eq!( + work.b, SCALA_TARGET, + "served b diverged from the value a Scala node published for the same \ + nBits. Miners hash against this number; a wrong one costs them every \ + share they find." + ); + + // The specific wrong answer, named. 10 digits against 68. + assert_ne!( + work.b, + SCALA_DIFFICULTY.to_string(), + "b is the difficulty again — that is the v0.8.0 defect exactly" + ); +} + +/// (b) Serve/verify agreement: the number handed to the miner is the number the +/// solution path checks a hit against. +/// +/// That is the property that was actually violated — two paths, two numbers, +/// nothing tying them together — and a test pinning only the literal above +/// would still pass if the solution side later drifted. +/// +/// `check_pow` is upstream's and opaque (it returns a bool, not its target), so +/// the target is observed at its boundary instead: for each nonce, the node's +/// own accept/reject verdict must agree with `hit < b_served`. At difficulty 2 +/// roughly half the nonces clear the bar, so the scan sees both verdicts and +/// brackets `b` from both sides. Under the v0.8.0 bug `b` was 2, every hit is a +/// ~77-digit number, so every `hit < b` would read false while the node +/// accepted half of them — the first accepted nonce fails this. +#[test] +fn served_b_is_the_bound_the_solution_path_enforces() { + const NONCES: u64 = 32; + + let n_bits = n_bits_for_difficulty(2); + assert_eq!( + decode_compact_bits(n_bits), + BigInt::from(2u32), + "compact-bits encoding helper is wrong" + ); + + let candidate = candidate_at_n_bits(n_bits); + let (_, work) = build_work_message(&candidate, &test_miner_pk().h).unwrap(); + let b = BigInt::from_str(&work.b).expect("served b must be a decimal integer"); + + let pow = AutolykosPowScheme::default(); + let (mut accepted, mut rejected) = (0u32, 0u32); + + for nonce in 0..NONCES { + let header = header_for_nonce(&candidate, nonce); + let hit = pow + .pow_hit(&header) + .expect("pow_hit on a well-formed v2 header") + .to_bigint() + .expect("unsigned -> signed conversion never fails"); + + // The real entry point — the same call the /mining/solution handler makes. + let node_accepts = match validate_solution(&candidate, solution_with_nonce(nonce)) { + Ok(validated) => { + // Prove the header whose hit we just read is the header the + // solution path validated. `id` is not serialized, so equal + // bytes means equal header. + assert_eq!( + header.scorex_serialize_bytes().unwrap(), + validated.scorex_serialize_bytes().unwrap(), + "reconstructed header diverged from the one validate_solution \ + built, so the hit below is for a different header" + ); + true + } + Err(MiningError::InvalidSolution(_)) => false, + Err(e) => panic!("unexpected mining error at nonce {nonce}: {e}"), + }; + + assert_eq!( + node_accepts, + hit < b, + "serve/verify disagreement at nonce {nonce}: the node {} this \ + solution, but against the served b={b} the hit {hit} says the \ + opposite. The number miners are given and the number the node \ + checks must be the same number.", + if node_accepts { "ACCEPTED" } else { "REJECTED" } + ); + + if node_accepts { + accepted += 1; + } else { + rejected += 1; + } + } + + // Both verdicts must actually occur or the loop above asserted nothing + // about where the boundary is. The fixture is fixed, so this is a + // deterministic property of it — it fails only if someone changes the + // fixture or the difficulty and makes the scan one-sided. + assert!( + accepted > 0 && rejected > 0, + "scan went one-sided ({accepted} accepted, {rejected} rejected) — it no \ + longer brackets the target and proves nothing" + ); +} + fn hex_to_array_32(s: &str) -> [u8; 32] { let bytes = hex::decode(s).unwrap(); bytes.try_into().unwrap() diff --git a/p2p/Cargo.toml b/p2p/Cargo.toml index 557c143..2317b30 100644 --- a/p2p/Cargo.toml +++ b/p2p/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "enr-p2p" -version = "0.1.0" +version.workspace = true edition = "2021" [dependencies] diff --git a/p2p/src/config.rs b/p2p/src/config.rs index 58c0b24..d1be576 100644 --- a/p2p/src/config.rs +++ b/p2p/src/config.rs @@ -153,15 +153,23 @@ pub struct IdentityConfig { } impl Config { - /// Load config from a TOML file. + /// Parse a config from TOML text **already in memory**. + /// + /// The unit of configuration is a document, not a file. A layered + /// `/etc/ergo-node/conf.d/` merges several files into one effective + /// config that exists nowhere on disk; this is the entry point that can + /// express it. Do not serialise a merged document to a temp file to reach + /// [`Config::load`] — that is the adapter the Interface Integrity rule + /// forbids. /// /// # Contract - /// - **Precondition**: `path` points to a readable TOML file. - /// - **Postcondition**: Returns a valid `Config` with at least one listener - /// and at least one seed peer, or an error. - pub fn load(path: &str) -> Result> { - let content = std::fs::read_to_string(path)?; - let config: Config = toml::from_str(&content)?; + /// - **Precondition**: none. The caller owns where the text came from. + /// - **Postcondition**: identical to what [`Config::load`] produces for a + /// file with the same contents — there is exactly one parse, and this is + /// it. Returns a valid `Config` with at least one listener and at least + /// one seed peer, or an error. + pub fn from_toml_str(toml: &str) -> Result> { + let config: Config = toml::from_str(toml)?; if config.listen.ipv4.is_none() && config.listen.ipv6.is_none() { return Err("At least one listener (ipv4 or ipv6) must be configured".into()); @@ -178,6 +186,20 @@ impl Config { Ok(config) } + /// Load config from a TOML file. + /// + /// Defined as [`Config::from_toml_str`] over the file's contents, so the + /// two entry points cannot drift. + /// + /// # Contract + /// - **Precondition**: `path` points to a readable TOML file. + /// - **Postcondition**: Returns a valid `Config` with at least one listener + /// and at least one seed peer, or an error. + pub fn load(path: &str) -> Result> { + let content = std::fs::read_to_string(path)?; + Self::from_toml_str(&content) + } + /// Returns network settings, using defaults if the `[network]` section is absent. pub fn network_settings(&self) -> NetworkConfig { self.network.clone().unwrap_or_default() @@ -247,4 +269,127 @@ protocol_version = "5.0.25" assert!(config.upnp.enabled); assert_eq!(config.upnp.discover_timeout_secs, 5); } + + /// Exercises every section, so the equivalence check below compares more + /// than the handful of fields validation happens to touch. + const FULL_TOML: &str = r#" +[proxy] +network = "mainnet" + +[listen.ipv4] +address = "0.0.0.0:9030" +mode = "full" +max_inbound = 30 + +[listen.ipv6] +address = "[::]:9030" +mode = "full" +max_inbound = 30 + +[outbound] +min_peers = 4 +max_peers = 20 +seed_peers = ["213.239.193.208:9030", "[2a01:4f8:1c17:6bf::2]:9030"] + +[identity] +agent_name = "ergo-node-rust" +peer_name = "equivalence-fixture" +protocol_version = "5.0.25" + +[network] +get_peers_interval_secs = 90 +desired_inv_objects = 200 +filter_bogus_addresses = false + +[upnp] +enabled = true +discover_timeout_secs = 7 +"#; + + #[test] + fn from_toml_str_parses_minimal_config() { + let config = Config::from_toml_str(MINIMAL_TOML).expect("minimal config is valid"); + + assert_eq!(config.proxy.network, Network::Testnet); + assert_eq!(config.outbound.min_peers, 1); + assert_eq!(config.outbound.max_peers, 5); + assert_eq!(config.outbound.seed_peers.len(), 1); + assert_eq!(config.identity.agent_name, "ergo-test"); + assert_eq!(config.identity.protocol_version, "5.0.25"); + assert!(config.listen.ipv4.is_some()); + assert!(config.listen.ipv6.is_none()); + // No [network] section: absent, with `network_settings()` filling defaults. + assert!(config.network.is_none()); + assert!(!config.upnp.enabled); + } + + /// The property this whole split exists to protect: identical text must + /// produce an identical `Config` whether it arrived via a path or in + /// memory. `Config` derives `Debug` but not `PartialEq`; the derived + /// `Debug` rendering covers every field of every nested struct, so + /// comparing it is a full structural comparison — not merely the three + /// fields validation happens to read — without adding a trait the crate + /// does not otherwise need. + #[test] + fn from_toml_str_matches_load_for_identical_text() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ergo.toml"); + std::fs::write(&path, FULL_TOML).unwrap(); + + let from_file = Config::load(path.to_str().unwrap()).expect("fixture loads from disk"); + let from_memory = Config::from_toml_str(FULL_TOML).expect("fixture parses in memory"); + + assert_eq!(format!("{:?}", from_file), format!("{:?}", from_memory)); + } + + /// Equivalence on the error side too: a `from_toml_str` that accepted a + /// document `load` rejects would hand the caller a config the node was + /// never meant to run with. + #[test] + fn from_toml_str_enforces_the_same_validation_as_load() { + let no_listener = MINIMAL_TOML.replace("[listen.ipv4]", "[listen.unused]"); + let no_seeds = MINIMAL_TOML.replace( + r#"seed_peers = ["213.239.193.208:9030"]"#, + "seed_peers = []", + ); + let inverted_peers = MINIMAL_TOML.replace("min_peers = 1", "min_peers = 9"); + + // The expected message is asserted too — without it the test would + // still pass if both entry points failed in the *parser* instead, + // which is not the equivalence being claimed. + let cases = [ + (&no_listener, "At least one listener"), + (&no_seeds, "At least one seed peer"), + (&inverted_peers, "min_peers must be <= max_peers"), + ]; + + for (invalid, expected) in cases { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ergo.toml"); + std::fs::write(&path, invalid).unwrap(); + + let file_err = Config::load(path.to_str().unwrap()) + .expect_err("load must reject this document") + .to_string(); + let memory_err = Config::from_toml_str(invalid) + .expect_err("from_toml_str must reject this document") + .to_string(); + + assert!( + memory_err.contains(expected), + "expected validation error containing {expected:?}, got {memory_err:?}" + ); + assert_eq!(file_err, memory_err); + } + } + + #[test] + fn from_toml_str_rejects_malformed_toml() { + // Unterminated table header — never a valid document. + assert!(Config::from_toml_str("[proxy\nnetwork = \"mainnet\"").is_err()); + // Well-formed TOML, but not a Config. + assert!(Config::from_toml_str("hello = \"world\"").is_err()); + // Empty input. + assert!(Config::from_toml_str("").is_err()); + } } diff --git a/p2p/src/node.rs b/p2p/src/node.rs index e7ebc54..97a38cd 100644 --- a/p2p/src/node.rs +++ b/p2p/src/node.rs @@ -1048,8 +1048,13 @@ mod tests { fn test_node() -> TestHarness { let blacklist = Arc::new(Blacklist::new()); let peer_db = shared_peer_db(blacklist.clone()); - let router_inner = - Router::with_peer_db(peer_db.clone(), blacklist.clone(), 64, Network::Mainnet, true); + let router_inner = Router::with_peer_db( + peer_db.clone(), + blacklist.clone(), + 64, + Network::Mainnet, + true, + ); let counters = router_inner.counters(); let router = Arc::new(Mutex::new(router_inner)); let peer_senders = Arc::new(Mutex::new(HashMap::new())); @@ -1235,8 +1240,13 @@ mod tests { async fn subscriber_receives_events() { let blacklist = Arc::new(Blacklist::new()); let peer_db = shared_peer_db(blacklist.clone()); - let router_inner = - Router::with_peer_db(peer_db.clone(), blacklist.clone(), 64, Network::Mainnet, true); + let router_inner = Router::with_peer_db( + peer_db.clone(), + blacklist.clone(), + 64, + Network::Mainnet, + true, + ); let counters = router_inner.counters(); let router = Arc::new(Mutex::new(router_inner)); let peer_senders: Arc>> = @@ -1324,8 +1334,13 @@ mod tests { // event loop with a synthetic Message event. let blacklist = Arc::new(Blacklist::new()); let peer_db = shared_peer_db(blacklist.clone()); - let router_inner = - Router::with_peer_db(peer_db.clone(), blacklist.clone(), 64, Network::Mainnet, true); + let router_inner = Router::with_peer_db( + peer_db.clone(), + blacklist.clone(), + 64, + Network::Mainnet, + true, + ); let counters = router_inner.counters(); let router = Arc::new(Mutex::new(router_inner)); let peer_senders: Arc>> = diff --git a/p2p/src/routing/router.rs b/p2p/src/routing/router.rs index b8640a3..dcde73c 100644 --- a/p2p/src/routing/router.rs +++ b/p2p/src/routing/router.rs @@ -192,7 +192,13 @@ impl Router { HashSet::new(), ) .expect("MemoryPeerStorage::load_all is infallible"); - Self::with_peer_db(Arc::new(StdMutex::new(peer_db)), blacklist, 64, network, true) + Self::with_peer_db( + Arc::new(StdMutex::new(peer_db)), + blacklist, + 64, + network, + true, + ) } /// Construct a router with an externally-owned PeerDb + Blacklist. @@ -586,9 +592,7 @@ impl Router { // PeerSynchronizer.addNewPeers → AddPeerIfEmpty // never penalizes here. Non-bogus entries from the // same body are recorded regardless. - if self.filter_bogus_addresses - && is_bogus_address(addr, self.network) - { + if self.filter_bogus_addresses && is_bogus_address(addr, self.network) { continue; } if self.blacklist.contains(addr) { @@ -1390,7 +1394,10 @@ mod tests { } => { assert_eq!(*target, source, "response goes straight to the requester"); assert_eq!(*modifier_type, 101); - assert_eq!(modifiers.as_slice(), &[(mid(0xAA), b"header-bytes".to_vec())]); + assert_eq!( + modifiers.as_slice(), + &[(mid(0xAA), b"header-bytes".to_vec())] + ); } other => panic!("expected direct ModifierResponse, got {other:?}"), } @@ -1648,7 +1655,9 @@ mod tests { ); let actions = router.handle_event(ProtocolEvent::Message { peer_id: source, - message: ProtocolMessage::SyncInfo { body: vec![1, 2, 3] }, + message: ProtocolMessage::SyncInfo { + body: vec![1, 2, 3], + }, }); assert!(actions.is_empty(), "SyncInfo from Light source dropped"); } @@ -1680,9 +1689,7 @@ mod tests { Action::Send { target, message: ProtocolMessage::ModifierResponse { modifiers, .. }, - } if *target == source => { - Some(modifiers.iter().map(|(id, _)| *id).collect()) - } + } if *target == source => Some(modifiers.iter().map(|(id, _)| *id).collect()), _ => None, }) .collect(); diff --git a/p2p/src/transport/frame.rs b/p2p/src/transport/frame.rs index 53fd8ff..44e2465 100644 --- a/p2p/src/transport/frame.rs +++ b/p2p/src/transport/frame.rs @@ -26,6 +26,7 @@ const HEADER_SIZE: usize = 13; /// - `Modifiers` messages: up to `maxMsgSizeWithReserve` (~8.4 MB payload) /// - UTXO snapshot manifest / chunk messages: up to ~4 MB /// - Inv / Request / Sync / Peers: kilobytes +/// /// Also bounds per-peer buffering: 30 peers × 16 MB ≈ 480 MB worst-case. const MAX_BODY_SIZE: u32 = 16_388_608; diff --git a/src/bin/inspect-modifier.rs b/src/bin/inspect-modifier.rs index beb3b24..ac72774 100644 --- a/src/bin/inspect-modifier.rs +++ b/src/bin/inspect-modifier.rs @@ -21,12 +21,9 @@ use std::path::PathBuf; use anyhow::{bail, Context, Result}; use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; -const PRIMARY: TableDefinition<(u8, [u8; 32]), &[u8]> = - TableDefinition::new("primary"); -const BEST_CHAIN: TableDefinition = - TableDefinition::new("best_chain"); -const HEADER_FORKS: TableDefinition<(u32, u32), [u8; 32]> = - TableDefinition::new("header_forks"); +const PRIMARY: TableDefinition<(u8, [u8; 32]), &[u8]> = TableDefinition::new("primary"); +const BEST_CHAIN: TableDefinition = TableDefinition::new("best_chain"); +const HEADER_FORKS: TableDefinition<(u32, u32), [u8; 32]> = TableDefinition::new("header_forks"); fn main() -> Result<()> { let args: Vec = std::env::args().skip(1).collect(); @@ -51,7 +48,10 @@ fn main() -> Result<()> { println!(" id: {}", hex::encode(id)); println!(); match &in_primary { - Some(g) => println!("PRIMARY[(type_id, id)] -> PRESENT, {} bytes", g.value().len()), + Some(g) => println!( + "PRIMARY[(type_id, id)] -> PRESENT, {} bytes", + g.value().len() + ), None => println!("PRIMARY[(type_id, id)] -> ABSENT"), } diff --git a/src/bin/inspect-state.rs b/src/bin/inspect-state.rs index 9f9229b..75ce5d2 100644 --- a/src/bin/inspect-state.rs +++ b/src/bin/inspect-state.rs @@ -21,7 +21,9 @@ const META_BLOCK_HEIGHT: &str = "block_height"; fn main() -> Result<()> { let args: Vec = std::env::args().skip(1).collect(); if args.len() < 2 { - bail!("usage: inspect-state >"); + bail!( + "usage: inspect-state >" + ); } let path = PathBuf::from(&args[0]); let cmd = &args[1]; @@ -30,7 +32,9 @@ fn main() -> Result<()> { return scan_tree(&path); } if cmd == "check-digest" { - if args.len() < 3 { bail!("check-digest needs a 32-byte hex digest arg"); } + if args.len() < 3 { + bail!("check-digest needs a 32-byte hex digest arg"); + } return check_digest(&path, &args[2]); } @@ -82,14 +86,20 @@ fn main() -> Result<()> { let packed_guard = match nodes.get(current_label.as_slice())? { Some(g) => g, None => { - println!(" depth={depth} label={} -- MISSING FROM NODES_TABLE", hex::encode(¤t_label)); + println!( + " depth={depth} label={} -- MISSING FROM NODES_TABLE", + hex::encode(¤t_label) + ); println!(" >>> this is the missing-node corruption signature <<<"); return Ok(()); } }; let packed = packed_guard.value(); if packed.is_empty() { - println!(" depth={depth} label={} -- EMPTY PACKED BYTES", hex::encode(¤t_label)); + println!( + " depth={depth} label={} -- EMPTY PACKED BYTES", + hex::encode(¤t_label) + ); return Ok(()); } let node_type = packed[0]; @@ -98,12 +108,18 @@ fn main() -> Result<()> { // Leaf: type | key (32) | vlen u32 BE | value let key_end = 1 + 32; if packed.len() < key_end { - println!(" depth={depth} label={} -- TRUNCATED LEAF", hex::encode(¤t_label)); + println!( + " depth={depth} label={} -- TRUNCATED LEAF", + hex::encode(¤t_label) + ); return Ok(()); } let leaf_key = &packed[1..key_end]; - println!(" depth={depth} type=Leaf label={}.. key={}", - hex::encode(¤t_label[..8]), hex::encode(leaf_key)); + println!( + " depth={depth} type=Leaf label={}.. key={}", + hex::encode(¤t_label[..8]), + hex::encode(leaf_key) + ); if leaf_key == target.as_slice() { println!(" >>> target found at this leaf <<<"); } else { @@ -118,7 +134,10 @@ fn main() -> Result<()> { let right_off = left_off + 32; let end = right_off + 32; if packed.len() < end { - println!(" depth={depth} label={} -- TRUNCATED INTERNAL", hex::encode(¤t_label)); + println!( + " depth={depth} label={} -- TRUNCATED INTERNAL", + hex::encode(¤t_label) + ); return Ok(()); } let balance = packed[1] as i8; @@ -126,16 +145,29 @@ fn main() -> Result<()> { let left = &packed[left_off..right_off]; let right = &packed[right_off..end]; - let direction = if target.as_slice() < node_key { "left" } else { "right" }; - println!(" depth={depth} type=Internal label={}.. balance={} key={}.. -> {} ({}..)", + let direction = if target.as_slice() < node_key { + "left" + } else { + "right" + }; + println!( + " depth={depth} type=Internal label={}.. balance={} key={}.. -> {} ({}..)", hex::encode(¤t_label[..8]), balance, hex::encode(&node_key[..8]), direction, - hex::encode(if direction == "left" { &left[..8] } else { &right[..8] }), + hex::encode(if direction == "left" { + &left[..8] + } else { + &right[..8] + }), ); - current_label = if direction == "left" { left.to_vec() } else { right.to_vec() }; + current_label = if direction == "left" { + left.to_vec() + } else { + right.to_vec() + }; depth += 1; } } @@ -199,16 +231,24 @@ fn scan_tree(path: &std::path::Path) -> Result<()> { None => { missing_count += 1; if missing_count <= max_missing_to_print { - println!("MISSING ref={} from parent={} side={}", + println!( + "MISSING ref={} from parent={} side={}", hex::encode(&label), - if parent.is_empty() { "META_TOP_NODE_HASH".to_string() } else { hex::encode(&parent) }, - side); + if parent.is_empty() { + "META_TOP_NODE_HASH".to_string() + } else { + hex::encode(&parent) + }, + side + ); } continue; } }; let packed = packed_guard.value(); - if packed.is_empty() { continue; } + if packed.is_empty() { + continue; + } visited_count += 1; let node_type = packed[0]; @@ -222,7 +262,9 @@ fn scan_tree(path: &std::path::Path) -> Result<()> { let left_off = key_off + 32; let right_off = left_off + 32; let end = right_off + 32; - if packed.len() < end { continue; } + if packed.len() < end { + continue; + } let left = packed[left_off..right_off].to_vec(); let right = packed[right_off..end].to_vec(); let parent_label = label.clone(); @@ -230,7 +272,9 @@ fn scan_tree(path: &std::path::Path) -> Result<()> { stack.push((left, parent_label, "left")); if visited_count.is_multiple_of(500_000) { - println!(" ... visited {visited_count} nodes (leaves={leaves}, missing={missing_count})"); + println!( + " ... visited {visited_count} nodes (leaves={leaves}, missing={missing_count})" + ); } } @@ -240,7 +284,10 @@ fn scan_tree(path: &std::path::Path) -> Result<()> { println!(" Leaves: {leaves}"); println!(" Internals: {}", visited_count - leaves); println!(" Missing references: {missing_count}"); - println!(" Orphan nodes (storage but unreachable): {}", total_in_table as i64 - visited_count as i64); + println!( + " Orphan nodes (storage but unreachable): {}", + total_in_table as i64 - visited_count as i64 + ); Ok(()) } diff --git a/src/bin/sharpen.rs b/src/bin/sharpen.rs index 3b39f52..6d54490 100644 --- a/src/bin/sharpen.rs +++ b/src/bin/sharpen.rs @@ -16,25 +16,18 @@ use std::path::PathBuf; use anyhow::{bail, Context, Result}; use bytes::Bytes; use clap::Parser; -use enr_chain::{ - AD_PROOFS_TYPE_ID, BLOCK_TRANSACTIONS_TYPE_ID, EXTENSION_TYPE_ID, HEADER_TYPE_ID, -}; -use ergo_avltree_rust::versioned_avl_storage::VersionedAVLStorage; +use enr_chain::{AD_PROOFS_TYPE_ID, BLOCK_TRANSACTIONS_TYPE_ID, EXTENSION_TYPE_ID, HEADER_TYPE_ID}; use enr_state::{AVLTreeParams, CacheSize, RedbAVLStorage}; +use ergo_avltree_rust::versioned_avl_storage::VersionedAVLStorage; use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; use sigma_ser::ScorexSerializable; // Must match store/src/redb.rs — sharpen opens the DB directly. -const PRIMARY: TableDefinition<(u8, [u8; 32]), &[u8]> = - TableDefinition::new("primary"); -const HEIGHT_INDEX: TableDefinition<(u8, u32), [u8; 32]> = - TableDefinition::new("height_index"); -const HEADER_FORKS: TableDefinition<(u32, u32), [u8; 32]> = - TableDefinition::new("header_forks"); -const HEADER_SCORES: TableDefinition<[u8; 32], &[u8]> = - TableDefinition::new("header_scores"); -const BEST_CHAIN: TableDefinition = - TableDefinition::new("best_chain"); +const PRIMARY: TableDefinition<(u8, [u8; 32]), &[u8]> = TableDefinition::new("primary"); +const HEIGHT_INDEX: TableDefinition<(u8, u32), [u8; 32]> = TableDefinition::new("height_index"); +const HEADER_FORKS: TableDefinition<(u32, u32), [u8; 32]> = TableDefinition::new("header_forks"); +const HEADER_SCORES: TableDefinition<[u8; 32], &[u8]> = TableDefinition::new("header_scores"); +const BEST_CHAIN: TableDefinition = TableDefinition::new("best_chain"); /// Cut the node's chain tip off above a given height. /// @@ -137,11 +130,8 @@ fn main() -> Result<()> { // and best-chain entries above target. print!("truncating modifiers.redb ... "); let modifiers = Database::open(&modifiers_path)?; - let (headers_deleted, sections_deleted) = - truncate_modifiers(&modifiers, target, current_tip)?; - println!( - "done ({headers_deleted} headers + forks, {sections_deleted} block sections)" - ); + let (headers_deleted, sections_deleted) = truncate_modifiers(&modifiers, target, current_tip)?; + println!("done ({headers_deleted} headers + forks, {sections_deleted} block sections)"); // 3. Optional: truncate indexer SQLite DB above target. Uses the same // cascade as the indexer's internal rollback_to(). @@ -200,10 +190,7 @@ fn load_best_tip_height(db: &Database) -> Result> { } /// Read and parse the header at a given height (from the best chain). -fn read_header( - db: &Database, - height: u32, -) -> Result> { +fn read_header(db: &Database, height: u32) -> Result> { let read_txn = db.begin_read()?; let best = read_txn.open_table(BEST_CHAIN)?; let id = match best.get(height)? { @@ -216,18 +203,14 @@ fn read_header( .context("header ID in BEST_CHAIN but not in PRIMARY")? .value() .to_vec(); - let header = ergo_chain_types::Header::scorex_parse_bytes(&raw) - .context("header parse failed")?; + let header = + ergo_chain_types::Header::scorex_parse_bytes(&raw).context("header parse failed")?; Ok(Some(header)) } /// Delete all data at heights > target. Returns (header-like entries /// removed, block-section entries removed). -fn truncate_modifiers( - db: &Database, - target: u32, - current_tip: u32, -) -> Result<(usize, usize)> { +fn truncate_modifiers(db: &Database, target: u32, current_tip: u32) -> Result<(usize, usize)> { // First pass: collect everything we need to delete. Can't iterate // and delete in the same pass because redb tables are borrowed // mutably for deletion. @@ -254,12 +237,8 @@ fn truncate_modifiers( // Derive the section IDs the header announces so we can // delete PRIMARY[(type_id, derived_id)] entries too. if let Some(raw) = primary.get((HEADER_TYPE_ID, id))? { - if let Ok(header) = - ergo_chain_types::Header::scorex_parse_bytes(raw.value()) - { - for (type_id, derived_id) in - required_section_ids(&header) - { + if let Ok(header) = ergo_chain_types::Header::scorex_parse_bytes(raw.value()) { + for (type_id, derived_id) in required_section_ids(&header) { section_keys_to_delete.push((type_id, derived_id)); } } @@ -328,9 +307,7 @@ fn truncate_modifiers( /// Compute the (type_id, id) keys for block sections of a given header. /// Mirrors chain/src/section.rs::section_ids. -fn required_section_ids( - header: &ergo_chain_types::Header, -) -> [(u8, [u8; 32]); 3] { +fn required_section_ids(header: &ergo_chain_types::Header) -> [(u8, [u8; 32]); 3] { [ ( BLOCK_TRANSACTIONS_TYPE_ID, @@ -342,29 +319,17 @@ fn required_section_ids( ), ( AD_PROOFS_TYPE_ID, - prefixed_hash( - AD_PROOFS_TYPE_ID, - &header.id.0 .0, - &header.ad_proofs_root.0, - ), + prefixed_hash(AD_PROOFS_TYPE_ID, &header.id.0 .0, &header.ad_proofs_root.0), ), ( EXTENSION_TYPE_ID, - prefixed_hash( - EXTENSION_TYPE_ID, - &header.id.0 .0, - &header.extension_root.0, - ), + prefixed_hash(EXTENSION_TYPE_ID, &header.id.0 .0, &header.extension_root.0), ), ] } /// `Blake2b256(prefix || data1 || data2)` — Scorex `Algos.hash.prefixedHash`. -fn prefixed_hash( - prefix: u8, - data1: &[u8; 32], - data2: &[u8; 32], -) -> [u8; 32] { +fn prefixed_hash(prefix: u8, data1: &[u8; 32], data2: &[u8; 32]) -> [u8; 32] { let mut buf = Vec::with_capacity(1 + 32 + 32); buf.push(prefix); buf.extend_from_slice(data1); diff --git a/src/bridge.rs b/src/bridge.rs index 5827d30..19b2315 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -142,7 +142,11 @@ impl SyncChain for SharedChain { } async fn active_proposed_update_bytes(&self) -> Vec { - self.chain.lock().await.active_proposed_update_bytes().to_vec() + self.chain + .lock() + .await + .active_proposed_update_bytes() + .to_vec() } async fn verify_nipopow_envelope( @@ -189,10 +193,11 @@ impl SyncChain for SharedChain { // install_from_nipopow_proof returns InstalledHeader entries in // the same order as `all_headers`. for (header, ih) in all_headers.iter().zip(installed.iter()) { - let raw = header.scorex_serialize_bytes() + let raw = header + .scorex_serialize_bytes() .map_err(|e| ChainError::Nipopow(format!("re-serialize installed header: {e}")))?; self.store - .put_header(&ih.id.0.0, ih.height, 0, &ih.score_be, &raw) + .put_header(&ih.id.0 .0, ih.height, 0, &ih.score_be, &raw) .map_err(|e| ChainError::Nipopow(format!("persist installed header: {e}")))?; } Ok(()) @@ -214,14 +219,10 @@ impl SharedStore { } } -/// Reserved type_id for sync metadata (not a real modifier type). -const SYNC_META_TYPE_ID: u8 = 255; -/// Fixed key for script_verified_height metadata. -const SCRIPT_VERIFIED_HEIGHT_KEY: [u8; 32] = { - let mut k = [0u8; 32]; - k[0] = b's'; k[1] = b'v'; k[2] = b'h'; // "svh" prefix - k -}; +// SYNC_META_TYPE_ID (255) and SCRIPT_VERIFIED_HEIGHT_KEY ("svh") lived here to +// persist the script-verification watermark. Both went with deferred evaluation +// in v0.8.0 and had no other users. Nodes upgraded from an earlier version keep +// the stale key in their store; nothing reads it, and there is no migration. impl SyncStore for SharedStore { async fn has_modifier(&self, type_id: u8, id: &[u8; 32]) -> bool { @@ -264,44 +265,6 @@ impl SyncStore for SharedStore { } } - async fn script_verified_height(&self) -> Option { - let store = self.store.clone(); - match tokio::task::spawn_blocking(move || { - match store.get(SYNC_META_TYPE_ID, &SCRIPT_VERIFIED_HEIGHT_KEY) { - Ok(Some(bytes)) if bytes.len() == 4 => { - Some(u32::from_le_bytes(bytes[..4].try_into().unwrap())) - } - _ => None, - } - }) - .await - { - Ok(v) => v, - Err(e) => { - tracing::error!(?e, "spawn_blocking panicked: script_verified_height"); - None - } - } - } - - async fn set_script_verified_height(&self, height: u32) { - let store = self.store.clone(); - if let Err(e) = tokio::task::spawn_blocking(move || { - if let Err(e) = store.put( - SYNC_META_TYPE_ID, - &SCRIPT_VERIFIED_HEIGHT_KEY, - 0, // metadata, no meaningful height - &height.to_le_bytes(), - ) { - tracing::warn!(height, "failed to persist script_verified_height: {e}"); - } - }) - .await - { - tracing::error!(?e, height, "spawn_blocking panicked: set_script_verified_height"); - } - } - async fn flush(&self) { let store = self.store.clone(); if let Err(e) = tokio::task::spawn_blocking(move || { @@ -317,13 +280,11 @@ impl SyncStore for SharedStore { async fn validated_height(&self) -> Option { let store = self.store.clone(); - match tokio::task::spawn_blocking(move || { - match store.chain_meta_get(b"validated_height") { - Ok(Some(bytes)) if bytes.len() == 4 => { - Some(u32::from_be_bytes(bytes[..4].try_into().unwrap())) - } - _ => None, + match tokio::task::spawn_blocking(move || match store.chain_meta_get(b"validated_height") { + Ok(Some(bytes)) if bytes.len() == 4 => { + Some(u32::from_be_bytes(bytes[..4].try_into().unwrap())) } + _ => None, }) .await { @@ -338,9 +299,7 @@ impl SyncStore for SharedStore { async fn set_validated_height(&self, height: u32) { let store = self.store.clone(); if let Err(e) = tokio::task::spawn_blocking(move || { - if let Err(e) = - store.chain_meta_put(b"validated_height", &height.to_be_bytes()) - { + if let Err(e) = store.chain_meta_put(b"validated_height", &height.to_be_bytes()) { tracing::warn!(height, "failed to persist validated_height: {e}"); return; } @@ -357,11 +316,7 @@ impl SyncStore for SharedStore { } } - async fn prune_below_height( - &self, - horizon: u32, - type_ids: &[u8], - ) -> Result { + async fn prune_below_height(&self, horizon: u32, type_ids: &[u8]) -> Result { let store = self.store.clone(); let type_ids = type_ids.to_vec(); match tokio::task::spawn_blocking(move || { @@ -382,9 +337,7 @@ impl SyncStore for SharedStore { async fn min_height_present(&self, type_id: u8) -> Result, String> { let store = self.store.clone(); match tokio::task::spawn_blocking(move || { - store - .min_height_present(type_id) - .map_err(|e| e.to_string()) + store.min_height_present(type_id).map_err(|e| e.to_string()) }) .await { diff --git a/src/main.rs b/src/main.rs index abed0ca..ded89b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,21 +11,26 @@ use std::sync::Arc; use bytes::Bytes; use enr_chain::{ChainConfig, HeaderChain, StateType, HEADER_TYPE_ID}; use enr_state::{AVLTreeParams, CacheSize, RedbAVLStorage, SnapshotReader}; +use enr_store::{ModifierStore, RedbModifierStore}; use ergo_avltree_rust::authenticated_tree_ops::AuthenticatedTreeOps; use ergo_avltree_rust::batch_avl_prover::BatchAVLProver; use ergo_avltree_rust::batch_node::AVLTree; use ergo_avltree_rust::operation::{KeyValue, Operation}; use ergo_avltree_rust::versioned_avl_storage::VersionedAVLStorage; use ergo_chain_types::ADDigest; +use ergo_chain_types::EcPoint; use ergo_lib::chain::emission::MonetarySettings; use ergo_lib::chain::genesis; use ergo_lib::ergotree_ir::serialization::SigmaSerializable; use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; -use ergo_chain_types::EcPoint; -use enr_store::{ModifierStore, RedbModifierStore}; -use ergo_node_rust::{P2pTransport, PeerStorageAdapter, SharedChain, SharedStore, ValidationPipeline}; +use ergo_node_rust::{ + P2pTransport, PeerStorageAdapter, SharedChain, SharedStore, ValidationPipeline, +}; use ergo_sync::{HeaderSync, SyncConfig, SyncStore}; -use ergo_validation::{ApplyStateOutcome, BlockValidator, DigestValidator, UtxoValidator, ValidationError}; +use ergo_validation::{ + ApplyStateOutcome, BlockValidator, DigestValidator, MiningState, StatePersistence, + UtxoValidator, ValidationError, +}; use serde::Deserialize; use tokio::sync::Mutex; @@ -100,14 +105,17 @@ fn build_genesis_boxes(network: enr_p2p::types::Network) -> Vec<([u8; 32], Vec, - state_root: ADDigest, - emission_tx: ergo_validation::Transaction, + emission_box: ergo_validation::ErgoBox, tip_height: u32, } @@ -141,7 +156,9 @@ type MiningProofCache = Arc>>; /// Context for mining proof pre-computation inside the validator callback. struct MiningCtx { - config: ergo_mining::MinerConfig, + // No `config` here: the hook stopped building the emission transaction when + // assembly moved into `generate_candidate`, and the mining task reads the + // config off the generator it already holds. proof_cache: MiningProofCache, snapshot_reader: Arc, /// Candidate lifecycle handle — the post-apply hook calls @@ -181,8 +198,39 @@ struct Validator { height_watch_tx: tokio::sync::watch::Sender, /// Mining proof pre-computation (None if mining not configured or digest mode). mining: Option, + /// AVL prover memory gauges, published every + /// [`PROVER_GAUGE_INTERVAL_BLOCKS`] applied blocks and read by + /// `/debug/memory`. Digest mode never writes them, so they stay unset and + /// the endpoint omits the fields — correct, digest mode has no prover. + prover_modified_nodes_bytes: Arc, + prover_resident_nodes_bytes: Arc, + /// Applied blocks since the gauges were last written. + blocks_since_prover_gauge: u32, + /// When the gauges were last written, for the at-tip fallback. + last_prover_gauge: std::time::Instant, } +/// How often the prover memory gauges are recomputed, in applied blocks. +/// +/// `prover_memory_estimate` walks the resident tree — O(resident nodes), the +/// same order as applying a block at mainnet scale — so this must never run per +/// block (`facts/validation.md`). The structure it measures grows monotonically +/// over hundreds of thousands of blocks, so a coarse gauge loses nothing. +const PROVER_GAUGE_INTERVAL_BLOCKS: u32 = 512; + +/// Wall-clock ceiling between publishes, whichever trigger fires first. +/// +/// The block interval alone is a sync-shaped rule: 512 blocks is seconds during +/// catch-up and roughly seventeen HOURS at tip, where blocks arrive every two +/// minutes. A gauge that updates twice a day cannot show a growth curve on a +/// synced node, which is the case an operator actually watches. +/// +/// The cost that motivated the block interval does not exist at tip — one walk +/// per two-minute block is free — so the two triggers do not conflict: during +/// sync the block count is reached long before this elapses, and at tip this +/// one carries it. +const PROVER_GAUGE_MAX_INTERVAL: std::time::Duration = std::time::Duration::from_secs(600); + // Size difference between variants is fundamental: UTXO mode carries a // persistent AVL+ prover (~384 bytes); digest mode doesn't (~44 bytes). // There's exactly one validator instance per process, so boxing @@ -202,6 +250,8 @@ impl Validator { block_applied_tx: tokio::sync::mpsc::Sender>, height_watch_tx: tokio::sync::watch::Sender, mining: Option, + prover_modified_nodes_bytes: Arc, + prover_resident_nodes_bytes: Arc, ) -> Self { let h = match &inner { ValidatorInner::Digest(v) => v.validated_height(), @@ -209,7 +259,36 @@ impl Validator { }; shared_height.store(h, std::sync::atomic::Ordering::Relaxed); let _ = height_watch_tx.send(h); - Self { inner, shared_height, shared_state_context, block_applied_tx, height_watch_tx, mining } + Self { + inner, + shared_height, + shared_state_context, + block_applied_tx, + height_watch_tx, + mining, + prover_modified_nodes_bytes, + prover_resident_nodes_bytes, + // Publish on the first applied block rather than after 512, so a + // node that is restarted often still reports something. + blocks_since_prover_gauge: PROVER_GAUGE_INTERVAL_BLOCKS, + last_prover_gauge: std::time::Instant::now(), + } + } + + /// The active variant's mining support. `None` in digest mode, where + /// candidate assembly has no UTXO set to work from. + /// + /// Inherent rather than a `BlockValidator` method because `main` is its + /// only consumer and does name this type. Putting it on the trait would + /// force six `sync/` test stubs to declare a capability nothing asks them + /// for. See facts/validation.md § "How callers reach the split traits" — + /// the asymmetry with `state_persistence` follows the consumers and is + /// deliberate. + fn mining_state(&self) -> Option<&dyn MiningState> { + match &self.inner { + ValidatorInner::Digest(_) => None, + ValidatorInner::Utxo(v) => Some(v), + } } /// Pre-compute mining proofs after a successful block validation. @@ -219,12 +298,20 @@ impl Validator { None => return, }; - let next_height = header.height + 1; - let emission_id = match self.emission_box_id() { - Some(id) => id, + // Digest mode implements no MiningState — candidate assembly needs a + // live UTXO set. `self.mining` is already None there, so this arm is + // belt-and-braces; the point is that "wrong mode" and "all ERG + // emitted" are now two distinct returns instead of one shared None. + let mining_state = match self.mining_state() { + Some(s) => s, None => return, }; + let emission_id = match mining_state.emission_box_id() { + Some(id) => id, + None => return, // all ERG emitted — and now that is all it means + }; + let box_bytes = match mining.snapshot_reader.lookup_key(&emission_id) { Some(b) => b, None => { @@ -241,38 +328,55 @@ impl Validator { } }; - let emission_tx = match ergo_mining::emission::build_emission_tx( - &emission_box, - next_height, - &mining.config.miner_pk, - mining.config.reward_delay, - &mining.config.reemission_rules, - ) { - Ok(tx) => tx, - Err(e) => { - tracing::warn!("mining: failed to build emission tx: {e}"); - return; - } - }; - - let (ad_proof_bytes, state_root) = match self.proofs_for_transactions(std::slice::from_ref(&emission_tx)) { - Some(Ok(result)) => result, - Some(Err(e)) => { - tracing::warn!("mining: proof computation failed: {e}"); - return; - } - None => return, // digest mode - }; - + // Deliberately stops here. Building the emission tx and its proofs + // belongs to `generate_candidate`, which knows the full transaction + // list; doing it here would compute proofs for a block that is not the + // one we are about to mine. let mut guard = mining.proof_cache.lock().unwrap_or_else(|e| e.into_inner()); *guard = Some(MiningProofData { parent: header.clone(), - ad_proof_bytes, - state_root, - emission_tx, + emission_box, tip_height: header.height, }); } + + /// Recompute and publish the prover memory gauges, at most once every + /// [`PROVER_GAUGE_INTERVAL_BLOCKS`] applied blocks. + /// + /// The walk is O(resident nodes), so the interval is the point — see + /// `facts/validation.md`. Digest mode returns `None` from + /// `state_persistence()` and nothing is ever written, which is why + /// `/debug/memory` omits the fields there instead of reporting zero. + fn publish_prover_gauges(&mut self) { + self.blocks_since_prover_gauge = self.blocks_since_prover_gauge.saturating_add(1); + let due_by_blocks = self.blocks_since_prover_gauge >= PROVER_GAUGE_INTERVAL_BLOCKS; + let due_by_time = self.last_prover_gauge.elapsed() >= PROVER_GAUGE_MAX_INTERVAL; + if !due_by_blocks && !due_by_time { + return; + } + let Some(estimate) = self + .state_persistence() + .and_then(|p| p.prover_memory_estimate()) + else { + return; + }; + self.blocks_since_prover_gauge = 0; + self.last_prover_gauge = std::time::Instant::now(); + self.prover_modified_nodes_bytes.store( + estimate.modified_nodes_bytes, + std::sync::atomic::Ordering::Relaxed, + ); + self.prover_resident_nodes_bytes.store( + estimate.resident_nodes_bytes, + std::sync::atomic::Ordering::Relaxed, + ); + tracing::debug!( + modified_nodes_bytes = estimate.modified_nodes_bytes, + resident_nodes_bytes = estimate.resident_nodes_bytes, + node_count = estimate.node_count, + "prover memory gauges published" + ); + } } impl BlockValidator for Validator { @@ -289,23 +393,49 @@ impl BlockValidator for Validator { ) -> Result { let result = match &mut self.inner { ValidatorInner::Digest(v) => v.apply_state( - header, block_txs, ad_proofs, extension, preceding_headers, - active_params, expected_boundary_params, expected_proposed_update, + header, + block_txs, + ad_proofs, + extension, + preceding_headers, + active_params, + expected_boundary_params, + expected_proposed_update, ), ValidatorInner::Utxo(v) => v.apply_state( - header, block_txs, ad_proofs, extension, preceding_headers, - active_params, expected_boundary_params, expected_proposed_update, + header, + block_txs, + ad_proofs, + extension, + preceding_headers, + active_params, + expected_boundary_params, + expected_proposed_update, ), }; if result.is_ok() { let h = self.validated_height(); - self.shared_height.store(h, std::sync::atomic::Ordering::Relaxed); + self.shared_height + .store(h, std::sync::atomic::Ordering::Relaxed); let _ = self.height_watch_tx.send(h); // Publish state context for mempool/API transaction validation. - // Only when we have preceding headers (height > 0). + // + // The UPCOMING context, not `build_state_context`: its consumers + // validate transactions that are not in a block yet, and those + // target the block after this one. `header` is the block just + // applied, so it is the *last* header here, not the preheader — + // and `preceding_headers` is unchanged, since the builder prepends + // `header` itself (facts/validation.md § Free Functions: state + // context). Publishing the current-tip context instead rejects + // every well-formed transaction on the network with + // "Creation height H+1 > preheader height". + // + // The guard no longer protects the builder — `header` alone + // satisfies `Headers` — but a context published at height 0 has no + // meaningful UTXO root for its consumers, so it stays. if !preceding_headers.is_empty() { - let ctx = ergo_validation::build_state_context( + let ctx = ergo_validation::build_upcoming_state_context( header, preceding_headers, active_params, @@ -333,6 +463,8 @@ impl BlockValidator for Validator { // Pre-compute mining proofs for the next block. self.update_mining_proofs(header); + + self.publish_prover_gauges(); } result } @@ -359,32 +491,36 @@ impl BlockValidator for Validator { // Republish the validator's ACTUAL height: the new one on Ok, the // unchanged one on Err (reset_to Err = validator unmoved). let h = self.validated_height(); - self.shared_height.store(h, std::sync::atomic::Ordering::Relaxed); + self.shared_height + .store(h, std::sync::atomic::Ordering::Relaxed); let _ = self.height_watch_tx.send(h); result } - fn flush(&self) -> Result<(), ValidationError> { - match &self.inner { - ValidatorInner::Digest(v) => v.flush(), - ValidatorInner::Utxo(v) => v.flush(), - } - } - - fn proofs_for_transactions( - &self, - txs: &[ergo_validation::Transaction], - ) -> Option, ADDigest), ValidationError>> { - match &self.inner { - ValidatorInner::Utxo(v) => v.proofs_for_transactions(txs), - ValidatorInner::Digest(_) => None, - } - } - - fn emission_box_id(&self) -> Option<[u8; 32]> { + // The four forwards that used to live here — flush, resize_cache, + // proofs_for_transactions, emission_box_id — are gone. One of them was + // silently missing for the life of the feature; the narrative is kept in + // facts/validation.md § "Traits", where it documents why the split + // happened rather than sitting next to code that no longer exists. + // + // Storage and mining are reached through the two accessors below. There + // is no per-method forwarding left to forget. + + /// The active variant's storage lifecycle. `None` in digest mode, which + /// owns no redb. + /// + /// `None` means "nothing to persist" — never "the flush failed". Callers + /// must keep those apart; collapsing them is the shape that produced the + /// `resize_cache` bug documented above. See facts/sync.md § "Flushing a + /// validator that owns no state". + /// + /// This one is a trait method rather than an inherent accessor because + /// `sync/` is generic over `V: BlockValidator` and never names this type, + /// so an inherent method here would be out of reach of its only caller. + fn state_persistence(&self) -> Option<&dyn StatePersistence> { match &self.inner { - ValidatorInner::Utxo(v) => v.emission_box_id(), ValidatorInner::Digest(_) => None, + ValidatorInner::Utxo(v) => Some(v), } } } @@ -410,9 +546,7 @@ async fn penalize( Some(addr) => addr.ip().to_string(), None => "unknown".to_string(), }; - tracing::warn!( - "PENALTY peer_ip={ip} type={penalty_type} reason=\"{reason}\"" - ); + tracing::warn!("PENALTY peer_ip={ip} type={penalty_type} reason=\"{reason}\""); if disconnect { p2p.disconnect_peer(peer_id).await; } @@ -441,7 +575,14 @@ async fn handle_nipopow_event( let req = match nipopow_serve::parse_get_nipopow_proof(body) { Ok(r) => r, Err(e) => { - penalize(p2p, peer_id, "misbehavior", &format!("GetNipopowProof parse failed: {e}"), false).await; + penalize( + p2p, + peer_id, + "misbehavior", + &format!("GetNipopowProof parse failed: {e}"), + false, + ) + .await; return; } }; @@ -462,8 +603,8 @@ async fn handle_nipopow_event( let anchor = match req.header_id { Some(id) => Some(id), None => { - let validated_h = shared_validated_height - .load(std::sync::atomic::Ordering::Relaxed); + let validated_h = + shared_validated_height.load(std::sync::atomic::Ordering::Relaxed); if validated_h == 0 { tracing::warn!( peer = %peer_id, @@ -521,7 +662,14 @@ async fn handle_nipopow_event( let proof_bytes = match nipopow_serve::parse_nipopow_proof(body) { Ok(b) => b, Err(e) => { - penalize(p2p, peer_id, "misbehavior", &format!("NipopowProof parse failed: {e}"), false).await; + penalize( + p2p, + peer_id, + "misbehavior", + &format!("NipopowProof parse failed: {e}"), + false, + ) + .await; return; } }; @@ -538,14 +686,24 @@ async fn handle_nipopow_event( ); } Err(e) => { - penalize(p2p, peer_id, "permanent", &format!("NiPoPoW proof verification failed: {e}"), true).await; + penalize( + p2p, + peer_id, + "permanent", + &format!("NiPoPoW proof verification failed: {e}"), + true, + ) + .await; } } } _ => { // is_nipopow_message guarantees code is 90 or 91; this branch is unreachable. - debug_assert!(false, "handle_nipopow_event called with non-nipopow code {code}"); + debug_assert!( + false, + "handle_nipopow_event called with non-nipopow code {code}" + ); } } } @@ -560,7 +718,10 @@ struct MempoolUtxoReader { } impl ergo_mempool::types::UtxoReader for MempoolUtxoReader { - fn box_by_id(&self, box_id: &[u8; 32]) -> Option { + fn box_by_id( + &self, + box_id: &[u8; 32], + ) -> Option { let reader = self.reader.as_ref()?; let value_bytes = reader.lookup_key(box_id)?; ergo_validation::deserialize_box(&value_bytes).ok() @@ -605,21 +766,36 @@ impl ergo_api::ChainAccess for HeaderChainAdapter { fn tip(&self) -> Option { self.with_chain(|c| { let h = c.height(); - if h == 0 { None } else { c.header_at(h) } + if h == 0 { + None + } else { + c.header_at(h) + } }) } + /// Maps `chain/`'s estimate onto the api-local mirror type. `api/` + /// deliberately does not depend on `enr-chain`, so this adapter is the + /// single seam between the two — and it stays a pure field mapping. Any + /// arithmetic here would recreate the split that let `AVG_HEADER_BYTES` + /// go stale against a structure `chain/` had already retired. + fn memory_estimate(&self) -> ergo_api::ChainMemory { + let e = self.with_chain(|c| c.memory_estimate()); + ergo_api::ChainMemory { + index_bytes: e.index_bytes, + header_cache_bytes: e.header_cache_bytes, + score_cache_bytes: e.score_cache_bytes, + } + } fn build_nipopow_proof( &self, m: u32, k: u32, header_id: Option<[u8; 32]>, ) -> Result, String> { - let block_id = header_id.map(|id| { - ergo_chain_types::BlockId(ergo_chain_types::Digest32::from(id)) - }); + let block_id = + header_id.map(|id| ergo_chain_types::BlockId(ergo_chain_types::Digest32::from(id))); self.with_chain(|c| { - enr_chain::build_nipopow_proof(c, m, k, block_id) - .map_err(|e| e.to_string()) + enr_chain::build_nipopow_proof(c, m, k, block_id).map_err(|e| e.to_string()) }) } fn header_ids(&self, offset: u32, limit: u32) -> Vec<[u8; 32]> { @@ -637,7 +813,9 @@ impl ergo_api::ChainAccess for HeaderChainAdapter { id.copy_from_slice(header.id.0.as_ref()); out.push(id); } - if h == 0 { break; } + if h == 0 { + break; + } h -= 1; } out @@ -645,9 +823,7 @@ impl ergo_api::ChainAccess for HeaderChainAdapter { } fn popow_header_by_id(&self, id: &[u8; 32]) -> Result>, String> { let block_id = ergo_chain_types::BlockId(ergo_chain_types::Digest32::from(*id)); - self.with_chain(|c| { - enr_chain::popow_header_by_id(c, &block_id).map_err(|e| e.to_string()) - }) + self.with_chain(|c| enr_chain::popow_header_by_id(c, &block_id).map_err(|e| e.to_string())) } } @@ -665,6 +841,17 @@ impl ergo_api::StoreAccess for StoreAdapter { let modifier_id = self.store.get_id_at(type_id, height).ok().flatten()?; self.store.get(type_id, &modifier_id).ok().flatten() } + + /// Always available — the store handle outlives the adapter. + fn cache_bytes_used(&self) -> Option { + Some(self.store.cache_bytes_used()) + } + + /// Evictions, not occupancy, are what respond to the configured cache + /// size. See facts/store.md. + fn cache_evictions(&self) -> Option { + Some(self.store.cache_evictions()) + } } /// Adapter: SwappableReader → UtxoAccess for the API crate. @@ -682,6 +869,13 @@ impl ergo_api::UtxoAccess for ApiUtxoReader { let value_bytes = reader.lookup_key(box_id)?; ergo_validation::deserialize_box(&value_bytes).ok() } + + /// `None` during the reopen window, when no reader exists — the same + /// condition under which every other lookup here returns `None`. Omitting + /// the field is correct then; reporting 0 would claim an empty cache. + fn cache_bytes_used(&self) -> Option { + Some(self.swap_reader.current()?.cache_bytes_used()) + } } /// BlockSubmitter implementation for the mining solution endpoint. @@ -723,9 +917,27 @@ impl ergo_api::BlockSubmitter for MinedBlockSubmitter { // Pre-store all sections in the modifier store so the sync task can // find them when the chain advances. let entries = vec![ - (enr_chain::BLOCK_TRANSACTIONS_TYPE_ID, block_txs_id, header.height, block_txs_bytes, None), - (enr_chain::AD_PROOFS_TYPE_ID, ad_proofs_id, header.height, ad_proofs_bytes, None), - (enr_chain::EXTENSION_TYPE_ID, extension_id, header.height, extension_bytes, None), + ( + enr_chain::BLOCK_TRANSACTIONS_TYPE_ID, + block_txs_id, + header.height, + block_txs_bytes, + None, + ), + ( + enr_chain::AD_PROOFS_TYPE_ID, + ad_proofs_id, + header.height, + ad_proofs_bytes, + None, + ), + ( + enr_chain::EXTENSION_TYPE_ID, + extension_id, + header.height, + extension_bytes, + None, + ), ]; self.store .put_batch(&entries) @@ -751,6 +963,7 @@ impl ergo_api::BlockSubmitter for MinedBlockSubmitter { /// Mining config parsed from `[node.mining]` in ergo.toml. #[derive(Debug, Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] struct MiningConfig { /// Miner public key (hex-encoded compressed EC point, 33 bytes). /// Empty = mining disabled. @@ -767,12 +980,19 @@ struct MiningConfig { candidate_ttl_secs: u64, } -fn default_votes() -> String { "000000".to_string() } -fn default_reward_delay() -> i32 { 720 } -fn default_candidate_ttl() -> u64 { 15 } +fn default_votes() -> String { + "000000".to_string() +} +fn default_reward_delay() -> i32 { + 720 +} +fn default_candidate_ttl() -> u64 { + 15 +} /// Node-level config parsed from the `[node]` section of ergo.toml. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct NodeConfig { #[serde(default = "default_data_dir")] data_dir: String, @@ -832,23 +1052,48 @@ struct NodeConfig { /// If no peer reports a tip within this window, fastsync is skipped. #[serde(default = "default_fastsync_peer_wait_timeout_sec")] fastsync_peer_wait_timeout_sec: u64, - /// redb cache size in megabytes (default: 256). - #[serde(default = "default_cache_mb")] - cache_mb: u64, + /// TOTAL redb page cache across BOTH databases, in megabytes (default: 512). + /// + /// Breaking change: this previously sized `state.redb` alone while + /// `modifiers.redb` silently took redb's built-in 1 GiB default, so a + /// config saying 1024 actually used 2048 MB. + /// Absent = derived from the memory budget (facts/memory.md). + cache_mb: Option, + /// Total memory the node may use, MB. Absent = read the cgroup limit, + /// else a conservative share of MemTotal. + memory_budget_mb: Option, + /// Downgrade the startup memory floor from a refusal to a warning. + /// + /// A UTXO node refuses to start under a 3 GiB ceiling (facts/memory.md + /// § "Startup floor"). That floor is a rule of thumb, and a competent + /// operator may have a real reason to cross it — a heavily pruned node, a + /// mode we have not measured, or a box we are simply wrong about. A check + /// with no override turns our estimate into somebody else's outage. + /// + /// Deliberately a config key rather than a CLI flag: it survives a + /// restart, it is greppable, and startup states when it is set. + #[serde(default)] + ignore_memory_floor: bool, + /// Percentage of `cache_mb` given to `modifiers.redb`; the remainder goes + /// to `state.redb`. Valid 1-99, validated at startup. + cache_store_pct: Option, /// Live-heap threshold (MB) above which the validation sweep commits /// the redb write transaction mid-sweep. 0 disables the memory trigger /// and flushes degenerate to every `flush_max_blocks`. Default tuned to /// 4 GB, empirically the point where the redb write-tx dirty-page /// cache starts dominating live heap during initial sync. - #[serde(default = "default_flush_heap_threshold_mb")] - flush_heap_threshold_mb: u64, + flush_heap_threshold_mb: Option, /// Upper bound on blocks between flushes. Bounds crash-recovery work. - #[serde(default = "default_flush_max_blocks")] - flush_max_blocks: u32, + flush_max_blocks: Option, /// Lower bound on blocks between flushes. Prevents storm-flushing when /// heap growth is driven by something other than the redb write tx. - #[serde(default = "default_flush_min_blocks")] - flush_min_blocks: u32, + flush_min_blocks: Option, + + // ── Deferred-eval backpressure ─────────────────────────────────────── + // Bounds the queue of script evaluations dispatched to rayon but not + // yet drained. Governs a pool of memory DISJOINT from the redb dirty + // pages that flush_heap_threshold_mb controls — flushing redb does not + // free a queued eval, so the two must not share a budget. // ── At-tip memory mirrors ──────────────────────────────────────────── // These take effect once chain sync reaches tip. Until then the cold- @@ -856,7 +1101,6 @@ struct NodeConfig { // and reopens the AVL state DB once with the synced_cache_mb value. // Default: each mirror equals its cold-sync parent (= no-op until // configured), so existing configs keep their current behavior. - /// redb cache size (MB) used at tip. Smaller = lower steady-state RSS; /// the tradeoff is more disk reads when cold-restarting at tip (cache /// has to re-warm from the working set). @@ -912,10 +1156,13 @@ impl Default for NodeConfig { fastsync_peer: None, fastsync_threshold_blocks: default_fastsync_threshold_blocks(), fastsync_peer_wait_timeout_sec: default_fastsync_peer_wait_timeout_sec(), - cache_mb: default_cache_mb(), - flush_heap_threshold_mb: default_flush_heap_threshold_mb(), - flush_max_blocks: default_flush_max_blocks(), - flush_min_blocks: default_flush_min_blocks(), + cache_mb: None, + memory_budget_mb: None, + ignore_memory_floor: false, + cache_store_pct: None, + flush_heap_threshold_mb: None, + flush_max_blocks: None, + flush_min_blocks: None, synced_cache_mb: None, synced_flush_heap_threshold_mb: None, synced_flush_max_blocks: None, @@ -932,6 +1179,9 @@ fn default_data_dir() -> String { fn default_state_type() -> String { "utxo".to_string() } +/// Deferred until a slow box tells us what inline actually costs. Changing +/// this default is a durability decision, not a tuning one — see +/// facts/validation.md § "Script evaluation modes". fn default_verify_transactions() -> bool { true } @@ -959,12 +1209,366 @@ fn default_fastsync_threshold_blocks() -> u32 { fn default_fastsync_peer_wait_timeout_sec() -> u64 { 30 } -fn default_cache_mb() -> u64 { - 256 + +fn default_cache_store_pct() -> u32 { + 50 +} + +/// One-shot maintenance subcommands open the store to touch a handful of keys +/// and exit. They have no working set worth caching, so they take a small +/// fixed budget rather than the operator's configured total — 256 MB to delete +/// one chain-meta key would be absurd. +const MAINTENANCE_CACHE_BYTES: usize = 16 * 1024 * 1024; + +/// Reject a split that starves either database. A zero share means a database +/// with no page cache at all, which is a configuration mistake rather than a +/// tuning choice, and should fail at startup rather than produce a node that +/// technically runs. +fn validate_cache_split(cfg: &NodeConfig) -> Result<(), String> { + // Only an operator-supplied value can be out of range; derivation never + // produces one. Absent is valid and means "derive". + if let Some(pct) = cfg.cache_store_pct { + if !(1..=99).contains(&pct) { + return Err(format!("cache_store_pct must be 1-99, got {pct}")); + } + } + Ok(()) +} + +/// `(modifiers_bytes, state_bytes)`. Integer split with the remainder given to +/// state, so the two always sum to exactly `cache_mb` regardless of rounding. +/// +/// Note that redb splits whatever it receives a further 90% read / 10% write +/// (`patches/redb/src/db.rs:1177`), and an at-tip in-place resize moves only +/// the read half — see `facts/state.md`. +fn cache_split_bytes(plan: &MemoryPlan) -> (usize, usize) { + split_cache_mb(plan.cache_mb, plan.cache_store_pct) +} + +/// Split an arbitrary MB budget by the store percentage. Used for both the +/// cold-sync `cache_mb` and the at-tip `synced_cache_mb`, so the ratio is one +/// concept rather than two. +fn split_cache_mb(total_mb: u64, store_pct: u32) -> (usize, usize) { + let total = total_mb as usize * 1024 * 1024; + let store = total * store_pct as usize / 100; + (store, total - store) +} +// ── Memory budget derivation ───────────────────────────────────────────── +// +// See facts/memory.md. The node reads its own ceiling rather than being told +// it: a systemd unit with MemoryMax, or a container with --memory, already +// states how much this node may use, and until v0.8.0 the node never looked. + +/// Where the ceiling came from. Decides how much of it we are willing to +/// spend — an explicit budget or a cgroup limit is somebody stating this node +/// may have that much; `MemTotal` states only that the machine has it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BudgetSource { + Explicit, + Cgroup, + MemTotal, +} + +impl BudgetSource { + fn as_str(self) -> &'static str { + match self { + BudgetSource::Explicit => "config", + BudgetSource::Cgroup => "cgroup", + BudgetSource::MemTotal => "meminfo", + } + } + + fn usable_fraction(self) -> f64 { + match self { + BudgetSource::Explicit => 1.00, + BudgetSource::Cgroup => 0.90, + BudgetSource::MemTotal => 0.50, + } + } +} + +/// This process's cgroup memory limit, or None when unconfined. +/// +/// Walks the v2 hierarchy upward and takes the MINIMUM: our own cgroup may say +/// `max` while a parent slice carries the real limit, and the effective +/// ceiling is the tightest one on the path. +fn cgroup_memory_limit() -> Option { + let self_cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?; + + // cgroup v2 — one line, "0::/path". + if let Some(path) = self_cgroup.lines().find_map(|l| l.strip_prefix("0::")) { + let mut best: Option = None; + let root = std::path::Path::new("/sys/fs/cgroup"); + let mut cur = root.join(path.trim_start_matches('/')); + loop { + if let Ok(txt) = std::fs::read_to_string(cur.join("memory.max")) { + let t = txt.trim(); + // The literal "max" means no limit at this level, not zero. + if t != "max" { + if let Ok(v) = t.parse::() { + best = Some(best.map_or(v, |b: u64| b.min(v))); + } + } + } + if cur == root || !cur.pop() { + break; + } + } + return best; + } + + // cgroup v1 — "hierarchy:controllers:path", memory controller. + for line in self_cgroup.lines() { + let mut parts = line.splitn(3, ':'); + let (_, ctrl, path) = (parts.next()?, parts.next()?, parts.next()?); + if ctrl.split(',').any(|c| c == "memory") { + let f = format!("/sys/fs/cgroup/memory{path}/memory.limit_in_bytes"); + if let Ok(txt) = std::fs::read_to_string(f) { + if let Ok(v) = txt.trim().parse::() { + // v1 signals unlimited with a page-count sentinel near u64::MAX + // rather than a word, so treat implausibly large as absent. + if v < (1u64 << 62) { + return Some(v); + } + } + } + } + } + None +} + +fn meminfo_total_bytes() -> Option { + let txt = std::fs::read_to_string("/proc/meminfo").ok()?; + txt.lines().find_map(|line| { + let rest = line.strip_prefix("MemTotal:")?; + let kb: u64 = rest.trim().trim_end_matches("kB").trim().parse().ok()?; + Some(kb * 1024) + }) +} + +/// Anonymous heap the node holds that no cache knob governs. Measured at tip +/// on mainnet 1.85M: jemalloc `allocated` 703 MB against 402 MB of tracked +/// components. Excludes jemalloc's own overhead (a further ~260 MB between +/// `allocated` and `resident`), which is not ours to allocate against. +const BASELINE_ANON_BYTES: u64 = 300 * 1024 * 1024; + +/// Shares of the available budget. ⚠ Calibrated on a 32-core box; the +/// constrained-box run that settles them is still in flight (facts/memory.md). +const CACHE_SHARE: f64 = 0.40; +const WRITE_SHARE: f64 = 0.40; +const SYNCED_RATIO: f64 = 0.25; + +#[derive(Debug, Clone, Copy)] +struct MemoryBudget { + source: BudgetSource, + ceiling_bytes: u64, + usable_bytes: u64, +} + +/// Resolve the ceiling, in the priority order facts/memory.md specifies. +fn detect_memory_budget(explicit_mb: Option) -> MemoryBudget { + let mem_total = meminfo_total_bytes(); + + let (ceiling, source) = if let Some(mb) = explicit_mb { + (mb.saturating_mul(1024 * 1024), BudgetSource::Explicit) + } else if let Some(cg) = cgroup_memory_limit() { + // A cgroup limit above physical RAM is not a licence to use more than + // exists, so the ceiling is the tighter of the two. + (cg.min(mem_total.unwrap_or(u64::MAX)), BudgetSource::Cgroup) + } else if let Some(mt) = mem_total { + (mt, BudgetSource::MemTotal) + } else { + // No cgroup, no /proc — assume a small box rather than a large one. + (1024 * 1024 * 1024, BudgetSource::MemTotal) + }; + + let usable = (ceiling as f64 * source.usable_fraction()) as u64; + MemoryBudget { + source, + ceiling_bytes: ceiling, + usable_bytes: usable, + } +} + +// ── Startup memory floor (facts/memory.md § "Startup floor") ───────────── + +/// Ceiling below which a UTXO node refuses to start. +const MEMORY_FLOOR_BYTES: u64 = 3 * 1024 * 1024 * 1024; + +/// Ceiling below which any node warns. +const MEMORY_RECOMMENDED_BYTES: u64 = 4 * 1024 * 1024 * 1024; + +/// Measured cold-sync RSS peak: mainnet genesis→595k, 32-core box, 1 GB cache +/// budget, inline evaluation. A *usable* budget under this is the number that +/// predicts trouble, and it is not the same test as the ceiling. +const COLD_SYNC_PEAK_BYTES: u64 = 3_020_000_000; + +/// Refuse, or warn, before any work begins. +/// +/// ⚠ Keys on the **ceiling**, not `MemAvailable`. `MemAvailable` moves with +/// page cache, so keying on it means a node that came up at boot refuses after +/// a busy hour — nondeterministic, for a reason the operator cannot see and did +/// not cause. +/// +/// ⚠ The refusal is **UTXO-only**. 4 GiB is a UTXO figure — that is where the +/// AVL prover tree lives. `digest` runs the verifier rather than the persistent +/// prover and `light` holds no tree at all, so refusing them would block the +/// one mode a small box should be running. +fn check_memory_floor( + budget: &MemoryBudget, + state_type: StateType, + ignore_floor: bool, +) -> Result<(), Box> { + let ceiling_mb = budget.ceiling_bytes / MIB; + let usable_mb = budget.usable_bytes / MIB; + let under_floor = budget.ceiling_bytes < MEMORY_FLOOR_BYTES; + + if under_floor && state_type == StateType::Utxo { + if ignore_floor { + tracing::warn!( + ceiling_mb, + floor_mb = MEMORY_FLOOR_BYTES / MIB, + "memory ceiling is below the floor for a UTXO node, but \ + ignore_memory_floor is set — starting anyway. If this node is \ + OOM-killed during cold sync, this is why." + ); + } else { + return Err(format!( + "memory ceiling {ceiling_mb} MB is below the {} MB floor for a UTXO node \ + (source: {}). A UTXO node holds the AVL prover tree in RAM and cold sync \ + peaked at {} MB on a well-provisioned box; 4 GB or more is recommended. \ + Options: give it more memory; set MemoryMax on the systemd unit or \ + memory_budget_mb in the config if this box has more than the node can see; \ + run state_type = \"light\" or \"digest\", which do not hold the tree; or set \ + ignore_memory_floor = true in [node] to start anyway.", + MEMORY_FLOOR_BYTES / MIB, + budget.source.as_str(), + COLD_SYNC_PEAK_BYTES / MIB, + ) + .into()); + } + } else if under_floor { + tracing::warn!( + ceiling_mb, + floor_mb = MEMORY_FLOOR_BYTES / MIB, + state_type = ?state_type, + "memory ceiling is below the UTXO floor; this mode does not hold the \ + AVL prover tree, so it is allowed — but it is unmeasured territory" + ); + } else if budget.ceiling_bytes < MEMORY_RECOMMENDED_BYTES { + tracing::warn!( + ceiling_mb, + recommended_mb = MEMORY_RECOMMENDED_BYTES / MIB, + "memory ceiling is below the recommended minimum — the node will run, \ + but cold sync is the tightest phase and more memory is better" + ); + } + + // A separate test, and the one that catches the case the ceiling check + // waves through: 4 GiB of RAM with no cgroup resolves to MemTotal at 50%, + // so 2 GiB usable — under the cold-sync peak, while passing above. + if budget.usable_bytes < COLD_SYNC_PEAK_BYTES { + let advice = match budget.source { + BudgetSource::MemTotal => { + "nothing states how much this node may use, so it is taking a conservative \ + 50% of MemTotal. Setting MemoryMax on the systemd unit, or memory_budget_mb \ + in [node], raises that to 90% or 100% — the same RAM, declared honestly, is \ + a substantially larger budget" + } + BudgetSource::Cgroup => { + "raise MemoryMax on the systemd unit if this box has more to give" + } + BudgetSource::Explicit => { + "raise memory_budget_mb in [node] if this box has more to give" + } + }; + tracing::warn!( + usable_mb, + cold_sync_peak_mb = COLD_SYNC_PEAK_BYTES / MIB, + source = budget.source.as_str(), + "derived budget is below the measured cold-sync peak — {advice}" + ); + } + + Ok(()) +} + +/// Budget left for caches and write buffers after the parts nothing governs. +/// `chain_index_bytes` is 0 in phase 1, when the store has not been opened and +/// the index is not yet knowable. +fn available_bytes(budget: &MemoryBudget, chain_index_bytes: u64) -> u64 { + let floor = BASELINE_ANON_BYTES.saturating_add(chain_index_bytes); + budget.usable_bytes.saturating_sub(floor) +} + +/// Every memory setting, resolved: derived from the budget unless the operator +/// stated it. An absent config key derives; a present one is obeyed exactly. +#[derive(Debug, Clone, Copy)] +struct MemoryPlan { + cache_mb: u64, + cache_store_pct: u32, + flush_heap_threshold_mb: u64, + flush_max_blocks: u32, + flush_min_blocks: u32, + synced_cache_mb: Option, + synced_flush_heap_threshold_mb: Option, + synced_flush_max_blocks: Option, + synced_flush_min_blocks: Option, + /// True when any field came from derivation rather than the config — + /// decides whether the startup record is worth emitting. + derived_any: bool, } -fn default_flush_heap_threshold_mb() -> u64 { - 4096 + +const MIB: u64 = 1024 * 1024; + +/// Resolve every memory setting. `chain_index_bytes` is 0 in phase 1, before +/// the store is open and the index is knowable — see facts/memory.md. +fn derive_memory_plan( + cfg: &NodeConfig, + budget: &MemoryBudget, + chain_index_bytes: u64, +) -> MemoryPlan { + let avail = available_bytes(budget, chain_index_bytes); + + let derived_cache_mb = ((avail as f64 * CACHE_SHARE) as u64 / MIB).max(64); + + // An ABSOLUTE heap level, not a share: the trigger compares against total + // jemalloc `allocated`, which already contains the caches. A threshold + // below the cache size would fire on every check forever. + let derived_flush_mb = (BASELINE_ANON_BYTES + + chain_index_bytes + + (avail as f64 * CACHE_SHARE) as u64 + + (avail as f64 * WRITE_SHARE) as u64) + / MIB; + + let cache_mb = cfg.cache_mb.unwrap_or(derived_cache_mb); + let derived_any = cfg.cache_mb.is_none() + || cfg.flush_heap_threshold_mb.is_none() + || cfg.synced_cache_mb.is_none(); + + MemoryPlan { + cache_mb, + cache_store_pct: cfg.cache_store_pct.unwrap_or_else(default_cache_store_pct), + flush_heap_threshold_mb: cfg.flush_heap_threshold_mb.unwrap_or(derived_flush_mb), + // Not derived: block counts bound crash-recovery work, and nothing + // measured relates a block count to a memory budget. + flush_max_blocks: cfg + .flush_max_blocks + .unwrap_or_else(default_flush_max_blocks), + flush_min_blocks: cfg + .flush_min_blocks + .unwrap_or_else(default_flush_min_blocks), + synced_cache_mb: Some( + cfg.synced_cache_mb + .unwrap_or(((cache_mb as f64 * SYNCED_RATIO) as u64).max(32)), + ), + synced_flush_heap_threshold_mb: cfg.synced_flush_heap_threshold_mb, + synced_flush_max_blocks: cfg.synced_flush_max_blocks, + synced_flush_min_blocks: cfg.synced_flush_min_blocks, + derived_any, + } } + fn default_flush_max_blocks() -> u32 { 100 } @@ -974,8 +1578,12 @@ fn default_flush_min_blocks() -> u32 { fn default_reconciliation_trust_threshold() -> u32 { 100 } - /// Top-level config wrapper. +// ⚠ NO `deny_unknown_fields` here, deliberately. This same file is parsed a +// second time into `enr_p2p`'s own `Config` for [proxy], [listen.*], +// [outbound] and [identity] — sections RootConfig does not declare. Denying +// unknown fields at THIS level would reject every real config at startup. +// The nested sections below are wholly owned by this crate and do deny. #[derive(Debug, Deserialize)] struct RootConfig { #[serde(default)] @@ -988,6 +1596,7 @@ struct RootConfig { /// `[debug]` toml section — opt-in container for diagnostic subsystems. #[derive(Debug, Deserialize, Clone, Default)] +#[serde(deny_unknown_fields)] struct DebugConfig { #[serde(default)] p2p_capture: Option, @@ -995,6 +1604,7 @@ struct DebugConfig { /// `[stats]` toml section — opt-in. See `facts/stats.md`. #[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] struct StatsConfig { #[serde(default = "default_stats_bind")] bind_address: std::net::SocketAddr, @@ -1079,8 +1689,6 @@ fn conv_snapshot_map( out } -/// At-tip storage reopen handler. Awaits a one-shot from sync's `synced()` - fn locate_config(args: &[String]) -> Option { if let Some(p) = args.iter().skip(1).find(|a| !a.starts_with("--")).cloned() { return Some(p); @@ -1110,6 +1718,215 @@ fn locate_config(args: &[String]) -> Option { None } +// ── Layered configuration (facts/config.md) ────────────────────────────── +// +// A base `ergo.toml` and a sibling `conf.d/` are merged into ONE document. +// Either half alone is valid: a base file with no `conf.d` (tarball installs) +// and a `conf.d` with no base file (the .deb end state after migration). + +/// Fields where a `_add` sibling appends instead of replacing. +/// +/// ⚠ Three names, by name — **not** a general mechanism applied to every +/// array. `_add` names the operation rather than the provenance (provenance is +/// already implied by which file the line is in) and leaves room for a future +/// `_remove`. See facts/config.md § "Merge semantics". +const ADDITIVE_FIELDS: [&str; 3] = ["seed_peers", "include_ips", "exclude_ips"]; + +/// The merged configuration document, and the files it came from. +#[derive(Debug)] +struct ConfigDocument { + /// Merged TOML, re-serialized. **Both** parsers consume exactly this + /// string — `RootConfig` here and `enr_p2p::config::Config` via + /// `from_toml_str`. Handing p2p a path instead would make it read one file + /// and silently ignore every `conf.d` layer. + text: String, + /// The base file, when one was found. `None` is valid: `conf.d` alone. + base: Option, + /// `conf.d` files layered on, in the order applied. + layers: Vec, +} + +/// Layer `overlay` onto `acc`. +/// +/// Scalars: later wins, **per key, not per table** — setting `max_peers` must +/// not drop `seed_peers` from the same table. Bare arrays replace wholesale, +/// which deliberately discards any `_add` contributions accumulated so far: +/// an operator writing a bare array is stating the complete list. +fn merge_config_table(acc: &mut toml::Table, overlay: toml::Table) { + for (key, val) in overlay { + if let Some(field) = key.strip_suffix("_add") { + if ADDITIVE_FIELDS.contains(&field) { + append_additive(acc, field, &key, val); + continue; + } + // Not one of the three. Almost certainly a typo or a wrong + // assumption about how general `_add` is, and silently accepting + // it would look like it worked — the key would land in the merged + // document and be dropped by serde without a word. + tracing::warn!( + key = %key, + additive_fields = ?ADDITIVE_FIELDS, + "`_add` is a convention for three fields only — this key is being \ + taken literally and will be ignored by the config parser" + ); + } + match (acc.get_mut(&key), val) { + // Recurse so sibling keys survive. + (Some(toml::Value::Table(existing)), toml::Value::Table(incoming)) => { + merge_config_table(existing, incoming); + } + // A table we have not seen yet still has to be *merged*, not + // inserted wholesale: `_add` is handled per level, so inserting the + // incoming table verbatim would carry a nested `seed_peers_add` + // straight through to the parser, which drops it silently. The + // first file to mention a table is the easy case to get wrong, + // because every later one takes the branch above. + (_, toml::Value::Table(incoming)) => { + let mut fresh = toml::Table::new(); + merge_config_table(&mut fresh, incoming); + acc.insert(key, toml::Value::Table(fresh)); + } + (_, incoming) => { + acc.insert(key, incoming); + } + } + } +} + +fn append_additive(acc: &mut toml::Table, field: &str, key: &str, val: toml::Value) { + let toml::Value::Array(items) = val else { + tracing::warn!(key = %key, "`_add` value is not an array — ignoring"); + return; + }; + match acc.get_mut(field) { + Some(toml::Value::Array(existing)) => existing.extend(items), + // Nothing to append to yet: the `_add` becomes the field. A later bare + // array still replaces it, which is the documented precedence. + _ => { + acc.insert(field.to_string(), toml::Value::Array(items)); + } + } +} + +/// The `conf.d` directory that applies, or None. +/// +/// With a base file it is that file's sibling and nothing else — the search +/// does not continue past the tier that won. Without one, the same three tiers +/// are searched for a bare `conf.d`. +fn locate_conf_d(base: Option<&std::path::Path>) -> Option { + if let Some(b) = base { + let d = b + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("conf.d"); + return d.is_dir().then_some(d); + } + + let pwd = std::path::PathBuf::from("./conf.d"); + if pwd.is_dir() { + return Some(pwd); + } + let xdg_dir = std::env::var("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .ok() + .or_else(|| { + std::env::var("HOME") + .map(|h| std::path::PathBuf::from(h).join(".config")) + .ok() + }); + if let Some(d) = xdg_dir { + let candidate = d.join("ergo-node/conf.d"); + if candidate.is_dir() { + return Some(candidate); + } + } + let etc = std::path::PathBuf::from("/etc/ergo-node/conf.d"); + etc.is_dir().then_some(etc) +} + +/// `*.toml` in a `conf.d`, in lexical filename order. +/// +/// Lexical order IS the mechanism — a file that wants to win names itself +/// later. Sorting on the full path would be equivalent here but breaks the +/// moment a caller passes a directory whose name sorts differently, so sort on +/// the file name. +fn conf_d_files(dir: &std::path::Path) -> Result, std::io::Error> { + let mut files: Vec = std::fs::read_dir(dir)? + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.is_file() && p.extension().is_some_and(|e| e == "toml")) + .collect(); + files.sort_by(|a, b| a.file_name().cmp(&b.file_name())); + Ok(files) +} + +/// Every search location, for the error a maintenance subcommand prints when +/// it finds nothing. The daemon does not use this — it writes a bootstrap +/// config instead of failing. +const NO_CONFIG_FOUND: &str = "no config found (pass an explicit path, or place one at \ + ./ergo.toml, ~/.config/ergo-node/ergo.toml, or /etc/ergo-node/ergo.toml, \ + or a conf.d/ directory beside any of them)"; + +/// Merge the base file and any `conf.d` into one document. +/// +/// `Ok(None)` means neither half exists — the caller writes a bootstrap config. +/// A `conf.d` that exists but holds no `*.toml` counts as present: the operator +/// made the directory, and silently falling through to "no config found" would +/// be a worse answer than an empty one. +fn load_config_document( + args: &[String], +) -> Result, Box> { + let base_path = locate_config(args).map(std::path::PathBuf::from); + let conf_d = locate_conf_d(base_path.as_deref()); + + if base_path.is_none() && conf_d.is_none() { + return Ok(None); + } + + merge_config_sources(base_path.as_deref(), conf_d.as_deref()).map(Some) +} + +/// The half of loading that does not consult the search path. +/// +/// Split out so it can be tested against temp directories: the search path +/// reaches `/etc/ergo-node`, and a test that walks it would read whatever this +/// machine happens to have installed. +fn merge_config_sources( + base: Option<&std::path::Path>, + conf_d: Option<&std::path::Path>, +) -> Result> { + let mut merged = toml::Table::new(); + + if let Some(p) = base { + // A named base file that is missing is an error, not an empty layer — + // it is either the operator's explicit argument or a path the search + // just confirmed exists. + let text = std::fs::read_to_string(p) + .map_err(|e| format!("reading config {}: {e}", p.display()))?; + let table: toml::Table = + toml::from_str(&text).map_err(|e| format!("parsing config {}: {e}", p.display()))?; + merge_config_table(&mut merged, table); + } + + let mut layers = Vec::new(); + if let Some(dir) = conf_d { + for f in conf_d_files(dir).map_err(|e| format!("reading {}: {e}", dir.display()))? { + let text = std::fs::read_to_string(&f) + .map_err(|e| format!("reading config layer {}: {e}", f.display()))?; + let table: toml::Table = toml::from_str(&text) + .map_err(|e| format!("parsing config layer {}: {e}", f.display()))?; + merge_config_table(&mut merged, table); + layers.push(f); + } + } + + Ok(ConfigDocument { + text: toml::to_string(&merged)?, + base: base.map(|p| p.to_path_buf()), + layers, + }) +} + /// Minimal config written to `./ergo.toml` on first run when no config file /// was found in any search location. Testnet, full archival node, IPv6 /// listener — data_dir falls through to the in-pwd `./ergo-node-data` @@ -1164,18 +1981,19 @@ async fn main() -> Result<(), Box> { // hidden from --help. Takes the same config path arg as the daemon so // the data_dir is resolved consistently. if args.iter().any(|a| a == "--reset-scores-migration") { - let config_path = locate_config(&args).ok_or_else(|| -> Box { - "no config found (pass an explicit path, or place one at ./ergo.toml, \ - ~/.config/ergo-node/ergo.toml, or /etc/ergo-node/ergo.toml)".into() - })?; - let config_content = std::fs::read_to_string(&config_path)?; - let root_config: RootConfig = toml::from_str(&config_content)?; + let doc = load_config_document(&args)? + .ok_or_else(|| -> Box { NO_CONFIG_FOUND.into() })?; + let root_config: RootConfig = toml::from_str(&doc.text)?; let node_config = root_config.node.unwrap_or_default(); - let data_dir = std::path::PathBuf::from(node_config.data_dir); - let store = RedbModifierStore::new(&data_dir.join("modifiers.redb"))?; + let data_dir = std::path::PathBuf::from(node_config.data_dir.clone()); + // Deliberately small: this path deletes one chain-meta key and exits. + let store = + RedbModifierStore::new(&data_dir.join("modifiers.redb"), MAINTENANCE_CACHE_BYTES)?; store.chain_meta_delete(b"scores_migrated_v1")?; store.flush()?; - tracing::info!("scores-migration sentinel cleared; next normal start will re-run the migration"); + tracing::info!( + "scores-migration sentinel cleared; next normal start will re-run the migration" + ); return Ok(()); } @@ -1187,12 +2005,9 @@ async fn main() -> Result<(), Box> { // deliberate operator step, so that a compaction whose stats look wrong // costs nothing. Requires the node to be stopped (redb file lock). if args.iter().any(|a| a == "--compact-state") { - let config_path = locate_config(&args).ok_or_else(|| -> Box { - "no config found (pass an explicit path, or place one at ./ergo.toml, \ - ~/.config/ergo-node/ergo.toml, or /etc/ergo-node/ergo.toml)".into() - })?; - let config_content = std::fs::read_to_string(&config_path)?; - let root_config: RootConfig = toml::from_str(&config_content)?; + let doc = load_config_document(&args)? + .ok_or_else(|| -> Box { NO_CONFIG_FOUND.into() })?; + let root_config: RootConfig = toml::from_str(&doc.text)?; let node_config = root_config.node.unwrap_or_default(); let data_dir = std::path::PathBuf::from(node_config.data_dir); let source = data_dir.join("state.redb"); @@ -1255,8 +2070,8 @@ async fn main() -> Result<(), Box> { return Ok(()); } - let config_path = match locate_config(&args) { - Some(p) => p, + let config_doc = match load_config_document(&args)? { + Some(d) => d, None => { std::fs::write("./ergo.toml", COLD_BOOTSTRAP_CONFIG)?; tracing::warn!( @@ -1265,11 +2080,25 @@ async fn main() -> Result<(), Box> { "no config found — wrote a default (testnet, full archival, state in ./ergo-node-data). \ Edit ./ergo.toml or run ./install.sh for interactive setup." ); - "./ergo.toml".to_string() + load_config_document(&args)?.ok_or_else(|| -> Box { + "wrote ./ergo.toml but still could not load it".into() + })? } }; - let config = enr_p2p::config::Config::load(&config_path)?; + // Which files produced the effective config. Logged unconditionally: with + // layering, "what is this node actually configured with" stops being + // answerable by looking at one file, and an operator debugging a surprising + // value needs to know which layers were in play and in what order. + tracing::info!( + base = config_doc.base.as_ref().map(|p| p.display().to_string()), + layers = ?config_doc.layers.iter().map(|p| p.display().to_string()).collect::>(), + "configuration loaded" + ); + + // Both parsers consume the merged document, not a path — see + // facts/config.md § "One document, parsed twice". + let config = enr_p2p::config::Config::from_toml_str(&config_doc.text)?; // Derive chain config from P2P network setting let network = config.proxy.network; @@ -1278,6 +2107,14 @@ async fn main() -> Result<(), Box> { enr_p2p::types::Network::Mainnet => ChainConfig::mainnet(), }; + // Wall-clock start, surfaced as `launchTime` on GET /info (see + // ../facts/api.md). Epoch milliseconds rather than Instant because it + // leaves the process; consumers derive uptime as currentTime - launchTime. + let launch_time_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + tracing::info!( version = env!("CARGO_PKG_VERSION"), network = match network { @@ -1287,9 +2124,8 @@ async fn main() -> Result<(), Box> { "Ergo node starting" ); - // Parse node config from the same TOML file - let config_content = std::fs::read_to_string(&config_path)?; - let root_config: RootConfig = toml::from_str(&config_content)?; + // Parse node config from the same merged document + let root_config: RootConfig = toml::from_str(&config_doc.text)?; let stats_config = root_config.stats.clone(); let capture_config = root_config.debug.clone().and_then(|d| d.p2p_capture); let node_config = root_config.node.unwrap_or_default(); @@ -1308,14 +2144,19 @@ async fn main() -> Result<(), Box> { None => None, }; let capture_tap = capture_handle.as_ref().map(|h| h.tap()); - let capture_access: Option> = - capture_handle.as_ref().map(|h| h.clone() as Arc); + let capture_access: Option> = capture_handle + .as_ref() + .map(|h| h.clone() as Arc); let state_type = match node_config.state_type.as_str() { "utxo" => StateType::Utxo, "digest" => StateType::Digest, "light" => StateType::Light, other => { - return Err(format!("unknown state_type '{}' (expected 'utxo', 'digest', or 'light')", other).into()); + return Err(format!( + "unknown state_type '{}' (expected 'utxo', 'digest', or 'light')", + other + ) + .into()); } }; let verify_transactions = node_config.verify_transactions; @@ -1331,7 +2172,7 @@ async fn main() -> Result<(), Box> { checkpoint_height = ?configured_checkpoint, storing_snapshots = node_config.storing_snapshots, snapshot_interval = node_config.snapshot_interval, - cache_mb = node_config.cache_mb, + cache_mb = ?node_config.cache_mb, "node config" ); @@ -1350,10 +2191,16 @@ async fn main() -> Result<(), Box> { if node_config.mining.votes.is_empty() || node_config.mining.votes == "000000" { [0, 0, 0] } else { - let v = hex::decode(&node_config.mining.votes) - .map_err(|e| format!("invalid mining votes hex '{}': {e}", node_config.mining.votes))?; + let v = hex::decode(&node_config.mining.votes).map_err(|e| { + format!( + "invalid mining votes hex '{}': {e}", + node_config.mining.votes + ) + })?; if v.len() != 3 { - return Err(format!("mining votes must be exactly 3 bytes, got {}", v.len()).into()); + return Err( + format!("mining votes must be exactly 3 bytes, got {}", v.len()).into(), + ); } [v[0], v[1], v[2]] } @@ -1367,10 +2214,33 @@ async fn main() -> Result<(), Box> { tracing::info!(miner_pk = %pk_hex, votes = %node_config.mining.votes, "mining configured"); } - let data_dir = std::path::PathBuf::from(node_config.data_dir); + validate_cache_split(&node_config).map_err(|e| -> Box { e.into() })?; + + // Phase 1 of memory derivation (facts/memory.md). The modifier store must + // open before the header chain can be restored — the chain is restored FROM + // it — and redb fixes its cache at open with no resize path. So the index + // half of the floor is not knowable yet and enters as 0; phase 2 re-derives + // with the real figure for everything opened later. + let memory_budget = detect_memory_budget(node_config.memory_budget_mb); + + // Before anything is opened. A node that cannot finish should not create a + // data directory and half a database first. + check_memory_floor(&memory_budget, state_type, node_config.ignore_memory_floor)?; + + let plan_phase1 = derive_memory_plan(&node_config, &memory_budget, 0); + let (store_cache_bytes, _) = cache_split_bytes(&plan_phase1); + + let data_dir = std::path::PathBuf::from(node_config.data_dir.clone()); std::fs::create_dir_all(&data_dir)?; - tracing::info!(path = %data_dir.join("modifiers.redb").display(), "opening modifier store"); - let store = Arc::new(RedbModifierStore::new(&data_dir.join("modifiers.redb"))?); + tracing::info!( + path = %data_dir.join("modifiers.redb").display(), + cache_bytes = store_cache_bytes, + "opening modifier store" + ); + let store = Arc::new(RedbModifierStore::new( + &data_dir.join("modifiers.redb"), + store_cache_bytes, + )?); tracing::info!("modifier store opened"); // One-shot scores backfill migration. v0.4.x stored empty @@ -1383,21 +2253,18 @@ async fn main() -> Result<(), Box> { let entries = store.best_chain_entries()?; let total = entries.len(); if total > 0 { - tracing::info!( - total, - "scores migration: starting (one-time backfill)" - ); + tracing::info!(total, "scores migration: starting (one-time backfill)"); const CHUNK_SIZE: usize = 50_000; let mut prev_score = enr_chain::BigUint::default(); let mut batch: Vec<([u8; 32], Vec)> = Vec::with_capacity(CHUNK_SIZE); for (i, (height, header_id)) in entries.iter().enumerate() { - let data = store - .get(HEADER_TYPE_ID, header_id)? - .ok_or_else(|| format!( + let data = store.get(HEADER_TYPE_ID, header_id)?.ok_or_else(|| { + format!( "scores migration: header at h={} missing from PRIMARY (id={})", height, hex::encode(header_id), - ))?; + ) + })?; let header = enr_chain::parse_header(&data) .map_err(|e| format!("scores migration: parse_header at h={}: {e}", height))?; let difficulty = enr_chain::decode_compact_bits(header.n_bits) @@ -1422,7 +2289,10 @@ async fn main() -> Result<(), Box> { tracing::info!(done, total, "scores migration: progress"); } } - tracing::info!(headers = total, "scores migration: complete, persisting sentinel"); + tracing::info!( + headers = total, + "scores migration: complete, persisting sentinel" + ); store.flush()?; } store.chain_meta_put(b"scores_migrated_v1", &[1u8])?; @@ -1446,7 +2316,10 @@ async fn main() -> Result<(), Box> { ergo_chain_types::BlockId(ergo_chain_types::Digest32::from(*id_bytes)) }); let restore_entries = best_chain_entries.into_iter().map(|(h, id_bytes)| { - (h, ergo_chain_types::BlockId(ergo_chain_types::Digest32::from(id_bytes))) + ( + h, + ergo_chain_types::BlockId(ergo_chain_types::Digest32::from(id_bytes)), + ) }); let mut chain = HeaderChain::restore(chain_config, restore_entries) .map_err(|e| format!("header chain restore failed: {e:?}"))?; @@ -1456,13 +2329,44 @@ async fn main() -> Result<(), Box> { tip = %tip, "header chain restored", ), - None => tracing::info!( - headers = 0u64, - "header chain restored", - ), + None => tracing::info!(headers = 0u64, "header chain restored",), } - // Wire the extension loader so chain can read epoch-boundary extensions + // Phase 2: the header index is real now, so re-derive everything that has + // not been allocated yet — the state cache and the flush thresholds. + let chain_index_bytes = chain.memory_estimate().index_bytes; + let memory_plan = derive_memory_plan(&node_config, &memory_budget, chain_index_bytes); + let (_, state_cache_bytes) = cache_split_bytes(&memory_plan); + + if memory_plan.derived_any { + // Every input and every output, because auto-sizing's failure mode is + // not being wrong — it is being wrong invisibly, leaving the operator + // no line to point at. facts/journal-events.md § memory_budget_derived. + tracing::info!( + source = memory_budget.source.as_str(), + ceiling_mb = memory_budget.ceiling_bytes / MIB, + usable_mb = memory_budget.usable_bytes / MIB, + baseline_mb = BASELINE_ANON_BYTES / MIB, + chain_index_mb = chain_index_bytes / MIB, + available_mb = available_bytes(&memory_budget, chain_index_bytes) / MIB, + cache_mb = memory_plan.cache_mb, + cache_store_pct = memory_plan.cache_store_pct, + store_cache_mb = store_cache_bytes as u64 / MIB, + state_cache_mb = state_cache_bytes as u64 / MIB, + flush_heap_threshold_mb = memory_plan.flush_heap_threshold_mb, + synced_cache_mb = memory_plan.synced_cache_mb.unwrap_or(0), + "memory budget derived" + ); + if memory_budget.source == BudgetSource::MemTotal { + tracing::info!( + "memory budget came from MemTotal and takes a conservative share; \ + set MemoryMax on the unit or memory_budget_mb in ergo.toml to let \ + the node use more" + ); + } + } + + // Wire the extension loader so chain can read epoch-boundary extensions // for parameter recomputation and nipopow proof construction. Bridges // chain (which knows nothing about storage) to enr-store via header lookup. { @@ -1539,9 +2443,14 @@ async fn main() -> Result<(), Box> { } }; match store_for_score_loader.header_score(&header_id) { - Ok(Some(bytes)) if !bytes.is_empty() => Some(enr_chain::BigUint::from_bytes_be(&bytes)), + Ok(Some(bytes)) if !bytes.is_empty() => { + Some(enr_chain::BigUint::from_bytes_be(&bytes)) + } Ok(_) => { - tracing::warn!(height, "score_loader: empty or missing score (post-migration store bug?)"); + tracing::warn!( + height, + "score_loader: empty or missing score (post-migration store bug?)" + ); None } Err(e) => { @@ -1572,10 +2481,7 @@ async fn main() -> Result<(), Box> { const LINKAGE_CHECK_DEPTH: u32 = 4096; match chain.verify_best_chain_linkage(Some(LINKAGE_CHECK_DEPTH)) { Ok(()) => { - tracing::info!( - depth = LINKAGE_CHECK_DEPTH, - "best-chain linkage verified" - ); + tracing::info!(depth = LINKAGE_CHECK_DEPTH, "best-chain linkage verified"); } Err(e) => { tracing::error!( @@ -1622,14 +2528,27 @@ async fn main() -> Result<(), Box> { StateType::Digest | StateType::Light => 1, }, verifying: verify_transactions && state_type != StateType::Light, - blocks_to_keep: if state_type == StateType::Light { 0 } else { blocks_to_keep as i32 }, + blocks_to_keep: if state_type == StateType::Light { + 0 + } else { + blocks_to_keep as i32 + }, }; // Start P2P with modifier sink (no validator) let peer_storage = Box::new(PeerStorageAdapter::new(store.clone())); // capture_tap from [debug.p2p_capture] in ergo.toml — None when the // section is absent or `enabled = false`. See facts/p2p-capture.md. - let p2p = Arc::new(enr_p2p::node::P2pNode::start(config, Some(modifier_tx), mode_config, peer_storage, capture_tap).await?); + let p2p = Arc::new( + enr_p2p::node::P2pNode::start( + config, + Some(modifier_tx), + mode_config, + peer_storage, + capture_tap, + ) + .await?, + ); // Register message codes consumed by the main crate's event stream so // the router doesn't blindly forward them to all peers. @@ -1642,9 +2561,11 @@ async fn main() -> Result<(), Box> { // Store-blind router, store-aware closure. redb reads are sync + cheap. { let serve_store = store.clone(); - p2p.set_local_serve(std::sync::Arc::new(move |modifier_type: u8, id: &[u8; 32]| { - serve_store.get(modifier_type, id).ok().flatten() - })) + p2p.set_local_serve(std::sync::Arc::new( + move |modifier_type: u8, id: &[u8; 32]| { + serve_store.get(modifier_type, id).ok().flatten() + }, + )) .await; } @@ -1667,8 +2588,14 @@ async fn main() -> Result<(), Box> { let pipeline_store = store.clone(); tokio::spawn(async move { - let mut pipeline = - ValidationPipeline::new(modifier_rx, pipeline_chain, pipeline_store, progress_tx, delivery_control_tx, delivery_data_tx); + let mut pipeline = ValidationPipeline::new( + modifier_rx, + pipeline_chain, + pipeline_store, + progress_tx, + delivery_control_tx, + delivery_data_tx, + ); pipeline.set_tx_sender(tx_tx); pipeline.run().await; }); @@ -1691,9 +2618,8 @@ async fn main() -> Result<(), Box> { // Snapshot store: open if serving is enabled let snapshot_store = if node_config.storing_snapshots > 0 { - let store = ergo_node_rust::snapshot_store::SnapshotStore::open( - &data_dir.join("snapshots.redb"), - )?; + let store = + ergo_node_rust::snapshot_store::SnapshotStore::open(&data_dir.join("snapshots.redb"))?; Some(std::sync::Arc::new(store)) } else { None @@ -1712,7 +2638,8 @@ async fn main() -> Result<(), Box> { while let Some(event) = events.recv().await { let handled = if let enr_p2p::protocol::peer::ProtocolEvent::Message { peer_id, - message: enr_p2p::protocol::messages::ProtocolMessage::Unknown { code, ref body }, + message: + enr_p2p::protocol::messages::ProtocolMessage::Unknown { code, ref body }, } = event { // Snapshot serving (codes 76, 78, 80) @@ -1768,10 +2695,9 @@ async fn main() -> Result<(), Box> { false }; - if !handled - && sync_events_tx.send(event).await.is_err() { - break; - } + if !handled && sync_events_tx.send(event).await.is_err() { + break; + } } }); } @@ -1786,8 +2712,8 @@ async fn main() -> Result<(), Box> { enr_p2p::types::Network::Mainnet => MAINNET_GENESIS_DIGEST, }; let genesis_bytes = hex::decode(genesis_digest_hex).expect("invalid genesis digest hex"); - let genesis_digest = ADDigest::try_from(genesis_bytes.as_slice()) - .expect("invalid genesis digest length"); + let genesis_digest = + ADDigest::try_from(genesis_bytes.as_slice()).expect("invalid genesis digest length"); // Candidate generator — constructed before the validator so the // post-apply lifecycle hook (CandidateGenerator::on_block_applied) can @@ -1813,22 +2739,87 @@ async fn main() -> Result<(), Box> { let (block_applied_tx, block_applied_rx) = tokio::sync::mpsc::channel::>(64); let (height_watch_tx, height_watch_rx) = tokio::sync::watch::channel(0u32); + + // Memory gauges for /debug/memory. Storage is a plain Arc so one + // allocation serves a producer in `validation/`-via-`Validator` or `sync/` + // and a reader in `api/` — those crates cannot name each other's types + // (facts/api.md). `u64::MAX` is the never-published sentinel on both ends, + // which is what makes an unmeasured field an absent JSON key rather than a + // zero asserting an empty prover. + // Minted through the reader's own constructor so the sentinel has one + // definition at the mint site rather than two that happen to agree. + let unset_gauge = || ergo_api::PublishedGauge::unset().storage(); + let prover_modified_nodes_bytes = unset_gauge(); + let prover_resident_nodes_bytes = unset_gauge(); + let shared_window_bytes = unset_gauge(); + + // `sync/` re-stamps its own `WINDOW_BYTES_UNSET` into the window gauge in + // `HeaderSync::new`, and that constant is defined independently of the + // reader's. They agree today and nothing enforces it: if they diverged, an + // unmeasured window would render as a ~16 EiB reading instead of an absent + // key — a wrong answer wearing the shape of a right one, which is the exact + // failure `/debug/memory` exists to avoid. + debug_assert!( + { + let probe = Arc::new(std::sync::atomic::AtomicU64::new( + ergo_sync::WINDOW_BYTES_UNSET, + )); + ergo_api::PublishedGauge::from_storage(probe) + .get() + .is_none() + }, + "ergo_sync::WINDOW_BYTES_UNSET is not the sentinel PublishedGauge reads as unset" + ); let mut chain_guard = chain.lock().await; let swap_reader = Arc::new(ergo_node_rust::SwappableReader::empty()); + // The checkpoint the validator is ACTUALLY constructed with, captured from + // whichever branch below runs. It decides which blocks skip script + // evaluation entirely: at or below it, `apply_state` builds no + // `ScriptEvalInputs` and runs nothing. + // + // It used to have a second consumer — `sync` floored `script_verified_height` + // at this value, and the two had to be the same number or a checkpointed + // node left a permanent frontier hole. That watermark is gone with deferred + // evaluation, so the coupling is gone with it; the value now has exactly + // one meaning and one reader. + // + // This is deliberately NOT `configured_checkpoint.unwrap_or(0)`. Digest + // mode resuming from a stored tip defaults to `height - 100`, not 0 — so + // that one expression would put the floor up to a whole chain below the + // eval-skip boundary on an unconfigured digest node. + // + // Every branch obtains its checkpoint through `resolve_checkpoint` so a + // future branch computing one directly stands out from its neighbours. + let effective_checkpoint = std::cell::Cell::new(0u32); + let resolve_checkpoint = |checkpoint: u32| -> u32 { + effective_checkpoint.set(checkpoint); + checkpoint + }; + let mut validator: Option = match state_type { StateType::Utxo => { let state_path = data_dir.join("state.redb"); - let params = AVLTreeParams { key_length: 32, value_length: None }; + let params = AVLTreeParams { + key_length: 32, + value_length: None, + }; let keep_versions = 256u32; tracing::info!( path = %state_path.display(), - cache_mb = node_config.cache_mb, + // The split, not the configured total — logging cache_mb here + // would claim this database got the whole budget. + cache_bytes = state_cache_bytes, "opening UTXO state storage" ); - let mut storage = RedbAVLStorage::open(&state_path, params, keep_versions, CacheSize::Bytes(node_config.cache_mb as usize * 1024 * 1024)) - .expect("failed to open UTXO state storage"); + let mut storage = RedbAVLStorage::open( + &state_path, + params, + keep_versions, + CacheSize::Bytes(state_cache_bytes), + ) + .expect("failed to open UTXO state storage"); // `revalidate` in UTXO mode: the state tree cannot be rolled back // to genesis in place (the undo log holds keep_versions, not the @@ -1846,14 +2837,17 @@ async fn main() -> Result<(), Box> { .expect("revalidate: failed to remove UTXO state file"); storage = RedbAVLStorage::open( &state_path, - AVLTreeParams { key_length: 32, value_length: None }, + AVLTreeParams { + key_length: 32, + value_length: None, + }, keep_versions, - CacheSize::Bytes(node_config.cache_mb as usize * 1024 * 1024), + CacheSize::Bytes(state_cache_bytes), ) .expect("revalidate: failed to re-open UTXO state storage"); } - let checkpoint = configured_checkpoint.unwrap_or(0); + let checkpoint = resolve_checkpoint(configured_checkpoint.unwrap_or(0)); if let Some(current_version) = storage.version() { // Resume branch: storage has data, load its root into a fresh @@ -1880,7 +2874,9 @@ async fn main() -> Result<(), Box> { prover.restore_root(root, tree_height); let prover_digest = prover.digest().expect("prover has no root"); - let prover_digest_arr: [u8; 33] = prover_digest.as_ref().try_into() + let prover_digest_arr: [u8; 33] = prover_digest + .as_ref() + .try_into() .expect("prover digest should be 33 bytes"); let chain_height = chain_guard.height(); let stored_height = storage.block_height(); @@ -1961,18 +2957,51 @@ async fn main() -> Result<(), Box> { // processed. shared_validated_height.store(height, std::sync::atomic::Ordering::Relaxed); let mining_ctx = mining_generator.as_ref().map(|g| MiningCtx { - config: g.config.clone(), proof_cache: mining_proof_cache.clone(), snapshot_reader: Arc::new(sr), generator: g.clone(), }); + // Recover `emission_box_id` from the block at the resume + // height. The coinbase spends and recreates the emission box + // in every block that has one, so this block's insertions + // carry the current one — see facts/validation.md + // § "Recovering emission_box_id on resume". + // + // Without this the field stays None, which is the documented + // value for "all ERG emitted", and a node restarted AT TIP + // serves no mining candidate until a peer delivers the next + // block. For a solo miner that never happens: no candidate, + // no block, no application, no candidate. + let resume_block_txs: Option> = + chain_guard.header_at(height).and_then(|h| { + let (type_id, section_id) = enr_chain::section_ids(&h)[0]; + store.get(type_id, §ion_id).ok().flatten() + }); + let emission_source = match resume_block_txs { + Some(ref bytes) => ergo_validation::EmissionSource::TipBlock(bytes), + // Pruned or incomplete store. Says which, because an + // unexplained None here is byte-identical to the + // legitimate one — the exact ambiguity being removed. + None => ergo_validation::EmissionSource::Unavailable( + "block transactions for the resume height are not in the store", + ), + }; + Some(Validator::new( - ValidatorInner::Utxo(UtxoValidator::new(storage, prover, height, checkpoint)), + ValidatorInner::Utxo(UtxoValidator::new( + storage, + prover, + height, + checkpoint, + emission_source, + )), shared_validated_height.clone(), shared_state_context.clone(), block_applied_tx.clone(), height_watch_tx.clone(), mining_ctx, + prover_modified_nodes_bytes.clone(), + prover_resident_nodes_bytes.clone(), )) } else if utxo_bootstrap { // Snapshot bootstrap — validator will be created after snapshot download @@ -1986,11 +3015,19 @@ async fn main() -> Result<(), Box> { let tree = AVLTree::with_resolver(resolver, 32, None); let mut prover = BatchAVLProver::new(tree, true); - for (box_id, box_bytes) in build_genesis_boxes(network) { - prover.perform_one_operation(&Operation::Insert(KeyValue { - key: Bytes::copy_from_slice(&box_id), - value: Bytes::copy_from_slice(&box_bytes), - })).expect("genesis box insert failed"); + // Bound to a local rather than consumed by the loop: one of + // these three carries the emission contract, and the validator + // recovers `emission_box_id` from them below. At height 0 there + // is no block to read it from. + let genesis_boxes = build_genesis_boxes(network); + + for (box_id, box_bytes) in &genesis_boxes { + prover + .perform_one_operation(&Operation::Insert(KeyValue { + key: Bytes::copy_from_slice(box_id), + value: Bytes::copy_from_slice(box_bytes), + })) + .expect("genesis box insert failed"); } // First update commits genesis state with block_height=0 @@ -2014,11 +3051,15 @@ async fn main() -> Result<(), Box> { let actual = prover.digest().expect("prover has no root after genesis"); let expected: [u8; 33] = genesis_digest.into(); assert_eq!( - actual.as_ref(), &expected[..], + actual.as_ref(), + &expected[..], "genesis UTXO state digest mismatch" ); - tracing::info!(checkpoint, "block validator starting from genesis (UTXO mode)"); + tracing::info!( + checkpoint, + "block validator starting from genesis (UTXO mode)" + ); // Genesis resync — recompute(0) is a no-op per the chain // contract; active_parameters stays at construction defaults @@ -2026,21 +3067,28 @@ async fn main() -> Result<(), Box> { // will carry). let _ = chain_guard.recompute_active_parameters_from_storage(0); let mining_ctx = mining_generator.as_ref().map(|g| MiningCtx { - config: g.config.clone(), proof_cache: mining_proof_cache.clone(), snapshot_reader: Arc::new(sr), generator: g.clone(), }); Some(Validator::new( ValidatorInner::Utxo({ - let mut uv = UtxoValidator::new(storage, prover, 0, checkpoint); + let mut uv = UtxoValidator::new( + storage, + prover, + 0, + checkpoint, + ergo_validation::EmissionSource::GenesisBoxes(&genesis_boxes), + ); // Diagnostic: regenerate historical ADProofs that UTXO mode does // not store. Set ENR_DUMP_ADPROOFS_AT=h1,h2,... for a one-shot // genesis replay; writes adproofs-.104 (raw type-104 section) // into data_dir at each listed height. Empty/unset = no-op. if let Ok(spec) = std::env::var("ENR_DUMP_ADPROOFS_AT") { - let heights: std::collections::HashSet = - spec.split(',').filter_map(|s| s.trim().parse().ok()).collect(); + let heights: std::collections::HashSet = spec + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); if !heights.is_empty() { tracing::warn!( ?heights, dir = %data_dir.display(), @@ -2056,6 +3104,8 @@ async fn main() -> Result<(), Box> { block_applied_tx.clone(), height_watch_tx.clone(), mining_ctx, + prover_modified_nodes_bytes.clone(), + prover_resident_nodes_bytes.clone(), )) } } @@ -2065,7 +3115,9 @@ async fn main() -> Result<(), Box> { let tip = chain_guard.tip(); let height = chain_guard.height(); let digest = tip.state_root; - let checkpoint = configured_checkpoint.unwrap_or_else(|| height.saturating_sub(100)); + let checkpoint = resolve_checkpoint( + configured_checkpoint.unwrap_or_else(|| height.saturating_sub(100)), + ); tracing::info!( height, checkpoint, @@ -2078,7 +3130,7 @@ async fn main() -> Result<(), Box> { shared_validated_height.store(height, std::sync::atomic::Ordering::Relaxed); DigestValidator::from_state(digest, height, checkpoint) } else if revalidate && chain_guard.height() > 0 { - let checkpoint = configured_checkpoint.unwrap_or(0); + let checkpoint = resolve_checkpoint(configured_checkpoint.unwrap_or(0)); let chain_height = chain_guard.height(); // Scan forward to find the first height with all required sections. @@ -2099,7 +3151,9 @@ async fn main() -> Result<(), Box> { } if start_from == 0 { - tracing::warn!("revalidate: no complete blocks found in store, starting from genesis"); + tracing::warn!( + "revalidate: no complete blocks found in store, starting from genesis" + ); DigestValidator::new(genesis_digest, checkpoint) } else { let prev_height = start_from - 1; @@ -2117,12 +3171,16 @@ async fn main() -> Result<(), Box> { // Revalidation resets the effective validated // height to prev_height — publish it so the // atomic doesn't lie about the node's state. - shared_validated_height.store(prev_height, std::sync::atomic::Ordering::Relaxed); + shared_validated_height + .store(prev_height, std::sync::atomic::Ordering::Relaxed); DigestValidator::from_state(digest, prev_height, checkpoint) } } else { - let checkpoint = configured_checkpoint.unwrap_or(0); - tracing::info!(checkpoint, "block validator starting from genesis (digest mode)"); + let checkpoint = resolve_checkpoint(configured_checkpoint.unwrap_or(0)); + tracing::info!( + checkpoint, + "block validator starting from genesis (digest mode)" + ); DigestValidator::new(genesis_digest, checkpoint) }; Some(Validator::new( @@ -2132,6 +3190,8 @@ async fn main() -> Result<(), Box> { block_applied_tx.clone(), height_watch_tx.clone(), None, // mining requires UTXO mode + prover_modified_nodes_bytes.clone(), + prover_resident_nodes_bytes.clone(), )) } @@ -2169,6 +3229,10 @@ async fn main() -> Result<(), Box> { // Build sync config from P2P network settings let net = net_settings; + // Source for the pacing constants at the bottom of this literal. See the + // comment there for why they are named individually rather than swept in + // with `..SyncConfig::default()`. + let sync_defaults = SyncConfig::default(); let sync_config = SyncConfig { delivery_timeout: std::time::Duration::from_secs(net.delivery_timeout_secs), max_delivery_checks: net.max_delivery_checks, @@ -2176,29 +3240,62 @@ async fn main() -> Result<(), Box> { utxo_bootstrap, min_snapshot_peers, data_dir: data_dir.clone(), - flush_heap_threshold_mb: node_config.flush_heap_threshold_mb, - flush_max_blocks: node_config.flush_max_blocks, - flush_min_blocks: node_config.flush_min_blocks, - synced_flush_heap_threshold_mb: node_config.synced_flush_heap_threshold_mb, - synced_flush_max_blocks: node_config.synced_flush_max_blocks, - synced_flush_min_blocks: node_config.synced_flush_min_blocks, + flush_heap_threshold_mb: memory_plan.flush_heap_threshold_mb, + flush_max_blocks: memory_plan.flush_max_blocks, + flush_min_blocks: memory_plan.flush_min_blocks, + // Derived from THE mode binding, never re-parsed from node_config — + // sync doing deferred bookkeeping while the validator evaluates + // inline freezes the frontier; the reverse advances it over blocks + // nothing verified. Omitting this line used to compile, because the + // literal ended in `..SyncConfig::default()` and this would have taken + // `false` in silence. It no longer does; see the note at the bottom. + synced_flush_heap_threshold_mb: memory_plan.synced_flush_heap_threshold_mb, + synced_flush_max_blocks: memory_plan.synced_flush_max_blocks, + synced_flush_min_blocks: memory_plan.synced_flush_min_blocks, flush_probe, reconciliation_trust_threshold: node_config.reconciliation_trust_threshold, // Mirror the handshake's Light-mode override (line above) — in Light // there are no bodies to prune anyway, so 0 keeps sync/pruning + the // wire advertisement consistent. - blocks_to_keep: if state_type == StateType::Light { 0 } else { blocks_to_keep as i32 }, - ..SyncConfig::default() + blocks_to_keep: if state_type == StateType::Light { + 0 + } else { + blocks_to_keep as i32 + }, + + // Pacing constants: deliberately not operator-tunable, not derived from + // config, and not consensus-, memory-, or durability-relevant. + // + // Named individually instead of riding `..SyncConfig::default()` so that + // a 25th field on SyncConfig is a COMPILE ERROR here rather than a + // silent default. That trap already came close once: `script_eval_inline` + // would have taken `false` through the fallthrough and left sync doing + // deferred bookkeeping while the validator evaluated inline — green + // build, wrong node. + // + // Values still come from the Default impl, so there is one source of + // truth for them. Only the *enumeration* is restated here, which is + // precisely the part we want the compiler to police. + // + // Note this works because struct literals without `..` are exhaustively + // checked — unlike a `match` on an enum, where `if let` and `matches!` + // let a new variant through in silence. + sync_interval: sync_defaults.sync_interval, + stall_timeout: sync_defaults.stall_timeout, + synced_poll_interval: sync_defaults.synced_poll_interval, + delivery_check_interval: sync_defaults.delivery_check_interval, + min_sync_send_interval: sync_defaults.min_sync_send_interval, }; // Snapshot bootstrap channels — only created when needed - let (snapshot_tx, snapshot_rx, validator_tx_send, validator_rx) = if validator.is_none() && utxo_bootstrap { - let (stx, srx) = tokio::sync::oneshot::channel::(); - let (vtx, vrx) = tokio::sync::oneshot::channel::(); - (Some(stx), Some(srx), Some(vtx), Some(vrx)) - } else { - (None, None, None, None) - }; + let (snapshot_tx, snapshot_rx, validator_tx_send, validator_rx) = + if validator.is_none() && utxo_bootstrap { + let (stx, srx) = tokio::sync::oneshot::channel::(); + let (vtx, vrx) = tokio::sync::oneshot::channel::(); + (Some(stx), Some(srx), Some(vtx), Some(vrx)) + } else { + (None, None, None, None) + }; // Cross-DB durability handshake — startup reconciliation. // Detect drift between state.redb's META_BLOCK_HEIGHT (canonical) and @@ -2279,6 +3376,43 @@ async fn main() -> Result<(), Box> { } } + // Seed the mining proof cache from the restored tip. + // + // `update_mining_proofs` otherwise runs ONLY on the post-apply path, so a + // node restarted while already at the chain tip has an empty cache and the + // mining task refuses to build — `/mining/candidate` returns 503 until a + // peer delivers the next block. Where the restarted node is the only miner + // it never recovers: no candidate, no block, no application, no candidate. + // Observed in the field as an hour of 503s with three peers connected. + // + // Runs AFTER the reconciliation handshake above, deliberately: that block + // can `reset_to` a lower height, and seeding beforehand would cache proofs + // for a tip the validator has since rolled back off. The mining task + // compares `tip_height` against the validated height and would discard + // them anyway — silently, which is worse than not having tried. + // + // See facts/mining.md § "Startup: the proof cache must be seeded". + if let Some(ref v) = validator { + let h = v.validated_height(); + if h > 0 { + let tip_header = { + let chain_guard = chain.lock().await; + chain_guard.header_at(h) + }; + match tip_header { + Some(header) => v.update_mining_proofs(&header), + // Not an error for a node that is not mining, and not worth + // refusing to start over — but say so, because the symptom is + // otherwise an unexplained 503 from a healthy-looking node. + None => tracing::warn!( + height = h, + "no header at the validated height — mining proofs not seeded; \ + /mining/candidate will 503 until the next block is applied" + ), + } + } + } + // Start sync in a background task let api_downloaded_height = shared_downloaded_height.clone(); let sync_shared_downloaded_height = shared_downloaded_height.clone(); @@ -2290,10 +3424,20 @@ async fn main() -> Result<(), Box> { // clones of the P2P node. See facts/sync.md "Graceful shutdown". let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let mut sync = HeaderSync::new( - sync_config, transport, sync_chain, sync_store, validator, - progress_rx, delivery_control_rx, delivery_data_rx, - snapshot_tx, validator_rx, sync_shared_downloaded_height, - sync_block_request_gate, sync_peer_chain_tip, + sync_config, + transport, + sync_chain, + sync_store, + validator, + progress_rx, + delivery_control_rx, + delivery_data_rx, + snapshot_tx, + validator_rx, + sync_shared_downloaded_height, + sync_block_request_gate, + sync_peer_chain_tip, + shared_window_bytes.clone(), shutdown_rx, ); @@ -2301,11 +3445,21 @@ async fn main() -> Result<(), Box> { // layer calls resize_cache() on the existing storage handle at first // synced() entry — no second Database handle, no mmap coherency bug. if state_type == StateType::Utxo { - if let Some(synced_cache_mb) = node_config.synced_cache_mb { - let cache_bytes = synced_cache_mb as usize * 1024 * 1024; - sync.set_at_tip_cache(cache_bytes); + if let Some(synced_cache_mb) = memory_plan.synced_cache_mb { + // `synced_cache_mb` is the at-tip TOTAL, mirroring `cache_mb`, so + // the same store/state split applies. Only the state side is + // resizable in place — the modifier store has no resize path. + // + // Note this moves less than it appears to: redb splits its budget + // 90% read / 10% write, and the in-place resize reaches only the + // read half. See facts/state.md § "An in-place resize moves only + // 90% of the budget". + let (_, synced_state_bytes) = + split_cache_mb(synced_cache_mb, memory_plan.cache_store_pct); + sync.set_at_tip_cache(synced_state_bytes); tracing::info!( synced_cache_mb, + synced_state_bytes, "at-tip cache resize wired; will fire on first synced() entry" ); } @@ -2319,12 +3473,20 @@ async fn main() -> Result<(), Box> { if let Some(snapshot_rx) = snapshot_rx { let state_path = data_dir.join("state.redb"); let validator_tx = validator_tx_send.unwrap(); + // Not routed through `resolve_checkpoint`: this validator is built + // after `sync_config` already exists, so recording here would be too + // late to reach it. Safe because this is the UTXO snapshot path, whose + // match branch above recorded the identical `unwrap_or(0)`. If this + // ever gains a different default the way digest-resume has, the floor + // and the eval-skip boundary diverge and `sync` must be told directly. let checkpoint = configured_checkpoint.unwrap_or(0); let shared_validated_height = shared_validated_height.clone(); let shared_state_context = shared_state_context.clone(); let block_applied_tx = block_applied_tx.clone(); let snapshot_swap_reader = swap_reader.clone(); let snapshot_chain = chain.clone(); + let prover_modified_nodes_bytes = prover_modified_nodes_bytes.clone(); + let prover_resident_nodes_bytes = prover_resident_nodes_bytes.clone(); tokio::spawn(async move { match snapshot_rx.await { Ok(snapshot_data) => { @@ -2334,9 +3496,17 @@ async fn main() -> Result<(), Box> { "loading snapshot into state" ); - let params = AVLTreeParams { key_length: 32, value_length: None }; - let mut storage = RedbAVLStorage::open(&state_path, params, 256, CacheSize::Bytes(node_config.cache_mb as usize * 1024 * 1024)) - .expect("failed to open state storage for snapshot"); + let params = AVLTreeParams { + key_length: 32, + value_length: None, + }; + let mut storage = RedbAVLStorage::open( + &state_path, + params, + 256, + CacheSize::Bytes(state_cache_bytes), + ) + .expect("failed to open state storage for snapshot"); let root_hash = snapshot_data.root_hash; let tree_height = snapshot_data.tree_height as usize; @@ -2348,11 +3518,13 @@ async fn main() -> Result<(), Box> { version_bytes.push(snapshot_data.tree_height); let version = Bytes::from(version_bytes); - let nodes_iter = snapshot_data.nodes.into_iter().map(|(label, packed)| { - (label, Bytes::from(packed)) - }); + let nodes_iter = snapshot_data + .nodes + .into_iter() + .map(|(label, packed)| (label, Bytes::from(packed))); - storage.load_snapshot(nodes_iter, root_hash, tree_height, version.clone(), height) + storage + .load_snapshot(nodes_iter, root_hash, tree_height, version.clone(), height) .expect("failed to load snapshot into state"); tracing::info!("snapshot loaded, creating validator"); @@ -2365,7 +3537,8 @@ async fn main() -> Result<(), Box> { // Mirrors the resume branch in the UTXO validator block. { let mut chain_guard = snapshot_chain.lock().await; - if let Err(e) = chain_guard.recompute_active_parameters_from_storage(height) { + if let Err(e) = chain_guard.recompute_active_parameters_from_storage(height) + { tracing::warn!( error = %e, resume_height = height, @@ -2394,12 +3567,29 @@ async fn main() -> Result<(), Box> { prover.restore_root(root, tree_h); let validator = Validator::new( - ValidatorInner::Utxo(UtxoValidator::new(storage, prover, height, checkpoint)), + ValidatorInner::Utxo(UtxoValidator::new( + storage, + prover, + height, + checkpoint, + // Snapshot bootstrap installs a state tree at a + // height whose block transactions were never + // downloaded, so there is nothing to recover from. + // Harmless today — mining ctx is None here — but it + // stops being harmless the moment that TODO below + // is done, so it states the reason rather than + // leaving a bare None to be puzzled over. + ergo_validation::EmissionSource::Unavailable( + "utxo snapshot bootstrap — the block at the snapshot height was never downloaded", + ), + )), shared_validated_height.clone(), shared_state_context.clone(), block_applied_tx.clone(), height_watch_tx.clone(), None, // TODO: mining ctx for snapshot bootstrap + prover_modified_nodes_bytes.clone(), + prover_resident_nodes_bytes.clone(), ); // Publish the bootstrap snapshot height to the @@ -2507,7 +3697,11 @@ async fn main() -> Result<(), Box> { } } }); - tracing::info!(snapshot_interval, storing_snapshots, "snapshot creation trigger active"); + tracing::info!( + snapshot_interval, + storing_snapshots, + "snapshot creation trigger active" + ); } } @@ -2687,7 +3881,8 @@ async fn main() -> Result<(), Box> { // REST API server { - let api_bind_addr: std::net::SocketAddr = node_config.api_address + let api_bind_addr: std::net::SocketAddr = node_config + .api_address .as_deref() .unwrap_or(match network { enr_p2p::types::Network::Testnet => "0.0.0.0:9052", @@ -2717,12 +3912,34 @@ async fn main() -> Result<(), Box> { let mining_height = shared_validated_height.clone(); let mining_chain = chain.clone(); let mining_store = store.clone(); + // Read-only state access for proofs and UTXO lookups. Re-read per + // iteration via `current()` rather than captured once, so the + // at-tip storage reopen swaps underneath us correctly. + let mining_swap_reader = swap_reader.clone(); + let mining_mempool = mempool.clone(); tokio::spawn(async move { let mut last_height = 0u32; loop { tokio::time::sleep(MINING_POLL_INTERVAL).await; let current = mining_height.load(std::sync::atomic::Ordering::Relaxed); - if current == last_height || current == 0 { + if current == 0 { + continue; + } + // Rebuild on a new tip OR when the cached candidate has aged + // out. `candidate_ttl` (default 15s) invalidates the cache, + // but nothing used to regenerate on expiry — and the tip only + // moves every ~2 min on mainnet, so /mining/candidate served + // work for 15s after each block and returned 503 for the + // remaining ~105. The knob is documented as "maximum candidate + // lifetime before forced regeneration"; this is the forced + // regeneration half. + // + // Refreshing also keeps the candidate's timestamp current and, + // once mempool selection is wired in, picks up transactions + // that arrived after the last block rather than mining the + // near-empty pool left behind by it. + let stale = gen.cached_work(current).is_none(); + if current == last_height && !stale { continue; } last_height = current; @@ -2785,11 +4002,8 @@ async fn main() -> Result<(), Box> { // The parent extension lookup mirrors the chain extension loader: // header → section_ids[2] → extension bytes → mining helper. let parent_interlinks = { - let parent_extension_id = - enr_chain::section_ids(&proof_data.parent)[2].1; - match mining_store - .get(enr_chain::EXTENSION_TYPE_ID, &parent_extension_id) - { + let parent_extension_id = enr_chain::section_ids(&proof_data.parent)[2].1; + match mining_store.get(enr_chain::EXTENSION_TYPE_ID, &parent_extension_id) { Ok(Some(ext_bytes)) => { ergo_mining::extension::unpack_parent_interlinks(&ext_bytes) } @@ -2806,51 +4020,104 @@ async fn main() -> Result<(), Box> { } }; - // Build extension + header + WorkMessage - let extension = match ergo_mining::extension::build_extension( - &proof_data.parent, - &parent_interlinks, - boundary_params.as_ref(), - &proposed_update_bytes, - ) { - Ok(ext) => ext, - Err(e) => { - tracing::warn!("mining: extension build failed: {e}"); - continue; - } + // Everything below used to be assembled inline here — + // extension, CandidateBlock, work message — which is why + // `generate_candidate` had no production caller and mined + // blocks carried the emission transaction alone. The crate + // owns assembly; this task supplies what only it can reach. + // See facts/mining.md § "Ownership". + + let reader = match mining_swap_reader.current() { + Some(r) => r, + // Mid-swap at the at-tip storage reopen. Skipping costs + // one poll interval; the next iteration re-reads. + None => continue, }; - let candidate = ergo_mining::CandidateBlock { - parent: proof_data.parent.clone(), - version: proof_data.parent.version, - n_bits, - state_root: proof_data.state_root, - ad_proof_bytes: proof_data.ad_proof_bytes.clone(), - transactions: vec![proof_data.emission_tx.clone()], - timestamp: { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as u64; - std::cmp::max(now, proof_data.parent.timestamp + 1) - }, - extension, - votes: gen.config.votes, - header_bytes: vec![], + // Prioritised mempool transactions, with the serialized size + // selection bounds against. + let candidate_txs: Vec<(ergo_validation::Transaction, usize)> = { + let pool = mining_mempool.lock().await; + pool.all_prioritized() + .into_iter() + .map(|u| (u.tx.clone(), u.tx_bytes.len())) + .collect() + }; + + // Ancestors for the upcoming-block context, newest first, + // WITHOUT the parent — generate_candidate prepends it. A + // one-header window here would fail any script reading + // headers[5] and get a valid transaction evicted. + let (active_params, ancestor_headers) = { + let chain_guard = mining_chain.lock().await; + let params = chain_guard.active_parameters().clone(); + let mut hs = chain_guard + .headers_from(proof_data.parent.height.saturating_sub(9), 10); + hs.reverse(); + hs.retain(|h| h.height != proof_data.parent.height); + (params, hs) + }; + + let lookup_reader = reader.clone(); + let utxo_lookup = move |id: &[u8; 32]| -> Option { + let bytes = lookup_reader.lookup_key(id)?; + ergo_validation::deserialize_box(&bytes).ok() + }; + + // Proofs without the validator: sync/ owns it and it is + // !Sync. This reads the committed tree through the reader + // and builds its own prover, so it cannot disturb + // validation. facts/validation.md § "Free Function". + let proof_reader = reader.clone(); + // Always `Some`: the Option layer means "no UTXO access at + // all" (digest mode), and we hold a reader by this point. + let validator_proofs = move |txs: &[ergo_validation::Transaction]| { + Some(ergo_validation::proofs_from_storage( + proof_reader.resolver(), + proof_reader.root_state(), + txs, + )) }; - match ergo_mining::candidate::build_work_message( - &candidate, - &gen.config.miner_pk.h, + match ergo_mining::generate_candidate( + &gen.config, + &proof_data.parent, + n_bits, + &parent_interlinks, + &proof_data.emission_box, + boundary_params.as_ref(), + &proposed_update_bytes, + &candidate_txs, + &active_params, + &ancestor_headers, + &utxo_lookup, + &validator_proofs, ) { - Ok((header_bytes, work)) => { - let mut candidate = candidate; - candidate.header_bytes = header_bytes; - gen.cache_candidate(candidate, work, current); - tracing::debug!(height = current + 1, "mining candidate cached"); + Ok(generated) => { + // Step 3.6: the crate identifies unusable + // transactions and cannot remove them itself. + // Dropping these on the floor silently re-selects + // and re-rejects them every 15s forever. + if !generated.invalid_txs.is_empty() { + let mut pool = mining_mempool.lock().await; + for id in &generated.invalid_txs { + pool.invalidate(id); + } + tracing::debug!( + count = generated.invalid_txs.len(), + "mining: evicted transactions rejected during selection" + ); + } + let tx_count = generated.block.transactions.len(); + gen.cache_candidate(generated.block, generated.work, current); + tracing::debug!( + height = current + 1, + transactions = tx_count, + "mining candidate cached" + ); } Err(e) => { - tracing::warn!("mining: work message build failed: {e}"); + tracing::warn!("mining: candidate generation failed: {e}"); } } } @@ -2868,8 +4135,7 @@ async fn main() -> Result<(), Box> { state_context: api_state_ctx, peer_count: Arc::new(move || { let count = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(p2p_for_api.peer_count()) + tokio::runtime::Handle::current().block_on(p2p_for_api.peer_count()) }); ergo_api::PeerCounts { connected: count } }), @@ -2882,10 +4148,26 @@ async fn main() -> Result<(), Box> { }), validated_height: shared_validated_height.clone(), downloaded_height: api_downloaded_height.clone(), + // Memory gauges. Views over the same storage the producers write: + // `Validator` for the two prover figures every + // PROVER_GAUGE_INTERVAL_BLOCKS, `HeaderSync` for the window after + // each applied block. Never written = absent JSON key, so digest + // mode omits the prover fields rather than claiming an empty one. + prover_modified_nodes_bytes: Arc::new(ergo_api::PublishedGauge::from_storage( + prover_modified_nodes_bytes.clone(), + )), + prover_resident_nodes_bytes: Arc::new(ergo_api::PublishedGauge::from_storage( + prover_resident_nodes_bytes.clone(), + )), + sync_window_bytes: Arc::new(ergo_api::PublishedGauge::from_storage( + shared_window_bytes.clone(), + )), + // Same atomic the fastsync gap decision reads — sync maintains it + // as a monotonic max over every peer's SyncInfo. Advisory only. + max_peer_height: peer_chain_tip.clone(), peer_api_urls: Arc::new(move || { tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(p2p_for_api_urls.peer_rest_urls()) + tokio::runtime::Handle::current().block_on(p2p_for_api_urls.peer_rest_urls()) }) .into_iter() .map(|(peer_id, addr, rest_url)| ergo_api::PeerRestInfo { @@ -2897,8 +4179,7 @@ async fn main() -> Result<(), Box> { }), peer_all: Arc::new(move || { tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(p2p_for_all.all_peers()) + tokio::runtime::Handle::current().block_on(p2p_for_all.all_peers()) }) .into_iter() .map(|entry| ergo_api::PeerInfo { @@ -2914,8 +4195,7 @@ async fn main() -> Result<(), Box> { }), peer_status: Arc::new(move || { let status = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(p2p_for_status.network_status()) + tokio::runtime::Handle::current().block_on(p2p_for_status.network_status()) }); ergo_api::PeerStatusSummary { last_incoming_message: status.last_incoming_message_ms, @@ -2934,16 +4214,14 @@ async fn main() -> Result<(), Box> { .block_on(p2p_for_connect.queue_outbound_connection(addr)) }) }), - snapshots_info: Arc::new(move || { - match &snapshot_store_for_api { - Some(store) => store - .snapshots_info() - .unwrap_or_default() - .into_iter() - .map(|(height, digest)| ergo_api::SnapshotInfoEntry { height, digest }) - .collect(), - None => Vec::new(), - } + snapshots_info: Arc::new(move || match &snapshot_store_for_api { + Some(store) => store + .snapshots_info() + .unwrap_or_default() + .into_iter() + .map(|(height, digest)| ergo_api::SnapshotInfoEntry { height, digest }) + .collect(), + None => Vec::new(), }), api_key_hash: None, modifier_tx: Some(modifier_tx_for_mining.clone()), @@ -2954,11 +4232,15 @@ async fn main() -> Result<(), Box> { Some(Arc::new(|| { let _ = tikv_jemalloc_ctl::epoch::advance(); ergo_api::JemallocSnapshot { - allocated: tikv_jemalloc_ctl::stats::allocated::read().unwrap_or(0) as u64, + allocated: tikv_jemalloc_ctl::stats::allocated::read().unwrap_or(0) + as u64, active: tikv_jemalloc_ctl::stats::active::read().unwrap_or(0) as u64, - resident: tikv_jemalloc_ctl::stats::resident::read().unwrap_or(0) as u64, - retained: tikv_jemalloc_ctl::stats::retained::read().unwrap_or(0) as u64, - metadata: tikv_jemalloc_ctl::stats::metadata::read().unwrap_or(0) as u64, + resident: tikv_jemalloc_ctl::stats::resident::read().unwrap_or(0) + as u64, + retained: tikv_jemalloc_ctl::stats::retained::read().unwrap_or(0) + as u64, + metadata: tikv_jemalloc_ctl::stats::metadata::read().unwrap_or(0) + as u64, } })) } @@ -2970,6 +4252,7 @@ async fn main() -> Result<(), Box> { node_info: std::sync::Arc::new(ergo_api::NodeMeta { name: "ergo-node-rust".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), + launch_time: launch_time_ms, network: match network { enr_p2p::types::Network::Testnet => "testnet".to_string(), enr_p2p::types::Network::Mainnet => "mainnet".to_string(), @@ -2996,13 +4279,8 @@ async fn main() -> Result<(), Box> { }); tokio::spawn(async move { - if let Err(e) = ergo_api::serve( - api_state, - api_bind_addr, - api_stats_config, - api_p2p_counters, - ) - .await + if let Err(e) = + ergo_api::serve(api_state, api_bind_addr, api_stats_config, api_p2p_counters).await { tracing::error!("REST API server failed: {e}"); } @@ -3015,9 +4293,8 @@ async fn main() -> Result<(), Box> { let fastsync_enabled = node_config.fastsync; let fastsync_peer = node_config.fastsync_peer.clone(); let fastsync_threshold = node_config.fastsync_threshold_blocks; - let fastsync_peer_wait = std::time::Duration::from_secs( - node_config.fastsync_peer_wait_timeout_sec, - ); + let fastsync_peer_wait = + std::time::Duration::from_secs(node_config.fastsync_peer_wait_timeout_sec); let api_port = api_bind_addr.port(); let bootstrap_gate = block_request_gate.clone(); let bootstrap_peer_tip = peer_chain_tip.clone(); @@ -3067,7 +4344,10 @@ async fn main() -> Result<(), Box> { if gap <= fastsync_threshold { tracing::info!( - peer_tip, downloaded, gap, threshold = fastsync_threshold, + peer_tip, + downloaded, + gap, + threshold = fastsync_threshold, "gap at/below fastsync threshold — going straight to P2P" ); bootstrap_gate.store(true, Ordering::Relaxed); @@ -3075,13 +4355,17 @@ async fn main() -> Result<(), Box> { } tracing::info!( - peer_tip, downloaded, gap, threshold = fastsync_threshold, + peer_tip, + downloaded, + gap, + threshold = fastsync_threshold, "gap exceeds fastsync threshold — spawning fastsync" ); let node_url = format!("http://127.0.0.1:{api_port}"); let mut cmd = tokio::process::Command::new("ergo-fastsync"); cmd.arg("--node-url").arg(&node_url); - cmd.arg("--handoff-distance").arg(fastsync_threshold.to_string()); + cmd.arg("--handoff-distance") + .arg(fastsync_threshold.to_string()); if let Some(ref peer) = fastsync_peer { cmd.arg("--peer-url").arg(peer); } @@ -3173,6 +4457,561 @@ mod tests { use ergo_avltree_rust::operation::{KeyValue, Operation}; use std::sync::Arc; + // ── Layered config loading (facts/config.md) ───────────────────────── + + fn table(toml_str: &str) -> toml::Table { + toml::from_str(toml_str).expect("test fixture parses") + } + + /// Merge a sequence of TOML fragments in order, as `conf.d` would. + fn merged(fragments: &[&str]) -> toml::Table { + let mut acc = toml::Table::new(); + for f in fragments { + merge_config_table(&mut acc, table(f)); + } + acc + } + + #[test] + fn later_file_wins_for_scalars() { + let m = merged(&["[outbound]\nmax_peers = 10", "[outbound]\nmax_peers = 40"]); + assert_eq!(m["outbound"]["max_peers"].as_integer(), Some(40)); + } + + /// The rule most easily got wrong: the naive `insert` of the later table + /// over the earlier one passes every test where the later file sets every + /// key, and fails the moment someone sets one. + #[test] + fn later_file_does_not_clobber_sibling_keys() { + let m = merged(&[ + "[outbound]\nmin_peers = 3\nmax_peers = 10\nseed_peers = [\"a:1\"]", + "[outbound]\nmax_peers = 40", + ]); + assert_eq!(m["outbound"]["max_peers"].as_integer(), Some(40)); + assert_eq!( + m["outbound"]["min_peers"].as_integer(), + Some(3), + "setting one key must not drop its siblings" + ); + assert_eq!( + m["outbound"]["seed_peers"].as_array().map(Vec::len), + Some(1), + "setting a scalar must not drop an array in the same table" + ); + } + + #[test] + fn nested_tables_merge_per_key() { + let m = merged(&[ + "[listen.ipv6]\naddress = \"[::]:9030\"\nmode = \"full\"", + "[listen.ipv6]\nmax_inbound = 20", + ]); + assert_eq!(m["listen"]["ipv6"]["mode"].as_str(), Some("full")); + assert_eq!(m["listen"]["ipv6"]["max_inbound"].as_integer(), Some(20)); + } + + #[test] + fn bare_array_replaces_wholesale() { + let m = merged(&[ + "[outbound]\nseed_peers = [\"a:1\", \"b:2\"]", + "[outbound]\nseed_peers = [\"c:3\"]", + ]); + let peers = m["outbound"]["seed_peers"].as_array().unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].as_str(), Some("c:3")); + } + + #[test] + fn add_appends_in_file_order() { + let m = merged(&[ + "[outbound]\nseed_peers = [\"a:1\"]", + "[outbound]\nseed_peers_add = [\"b:2\"]", + "[outbound]\nseed_peers_add = [\"c:3\"]", + ]); + let peers: Vec<&str> = m["outbound"]["seed_peers"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(peers, ["a:1", "b:2", "c:3"]); + assert!( + !m["outbound"] + .as_table() + .unwrap() + .contains_key("seed_peers_add"), + "_add is consumed by the merge, not passed through to the parser" + ); + } + + /// The documented precedence: an operator writing a bare array is stating + /// the complete list, so silently retaining an earlier `_add` would make + /// that statement untrue. + #[test] + fn bare_array_discards_prior_add_contributions() { + let m = merged(&[ + "[outbound]\nseed_peers = [\"a:1\"]", + "[outbound]\nseed_peers_add = [\"b:2\"]", + "[outbound]\nseed_peers = [\"c:3\"]", + ]); + let peers: Vec<&str> = m["outbound"]["seed_peers"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(peers, ["c:3"]); + } + + #[test] + fn add_with_no_prior_field_becomes_the_field() { + let m = merged(&["[outbound]\nseed_peers_add = [\"a:1\"]"]); + assert_eq!( + m["outbound"]["seed_peers"].as_array().map(Vec::len), + Some(1) + ); + } + + #[test] + fn add_applies_to_capture_ip_lists() { + for field in ["include_ips", "exclude_ips"] { + let m = merged(&[ + &format!("[debug.p2p_capture]\n{field} = [\"1.1.1.1\"]"), + &format!("[debug.p2p_capture]\n{field}_add = [\"2.2.2.2\"]"), + ]); + assert_eq!( + m["debug"]["p2p_capture"][field].as_array().map(Vec::len), + Some(2), + "{field} should accept _add" + ); + } + } + + /// `_add` is a convention for three named fields, not a general mechanism. + /// An unrecognised one is taken literally (and warned about) rather than + /// silently appending to something. + #[test] + fn add_on_an_unlisted_field_is_not_additive() { + let m = merged(&["[outbound]\nmin_peers = 3\nmin_peers_add = [7]"]); + assert_eq!( + m["outbound"]["min_peers"].as_integer(), + Some(3), + "an unlisted _add must not modify its base field" + ); + assert!(m["outbound"] + .as_table() + .unwrap() + .contains_key("min_peers_add")); + } + + #[test] + fn conf_d_files_are_lexical_by_name() { + let dir = tempfile::tempdir().expect("tempdir"); + for name in ["99-local.toml", "00-defaults.toml", "50-debconf.toml"] { + std::fs::write(dir.path().join(name), "").unwrap(); + } + std::fs::write(dir.path().join("notes.txt"), "").unwrap(); + + let files = conf_d_files(dir.path()).expect("read conf.d"); + let names: Vec = files + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + names, + ["00-defaults.toml", "50-debconf.toml", "99-local.toml"], + "lexical order IS the mechanism; non-toml files are skipped" + ); + } + + /// Both halves are independently valid: a base file with no conf.d + /// (tarball installs) and a conf.d with no base file (the .deb end state + /// after migration). + #[test] + fn either_half_alone_loads() { + let dir = tempfile::tempdir().expect("tempdir"); + let base = dir.path().join("ergo.toml"); + std::fs::write(&base, "[outbound]\nmax_peers = 10\n").unwrap(); + let confd = dir.path().join("conf.d"); + std::fs::create_dir(&confd).unwrap(); + std::fs::write(confd.join("50-x.toml"), "[outbound]\nmin_peers = 7\n").unwrap(); + + let base_only = merge_config_sources(Some(&base), None).expect("base only"); + let t: toml::Table = toml::from_str(&base_only.text).unwrap(); + assert_eq!(t["outbound"]["max_peers"].as_integer(), Some(10)); + assert!(base_only.layers.is_empty()); + + let confd_only = merge_config_sources(None, Some(&confd)).expect("conf.d only"); + let t: toml::Table = toml::from_str(&confd_only.text).unwrap(); + assert_eq!(t["outbound"]["min_peers"].as_integer(), Some(7)); + assert!(confd_only.base.is_none()); + assert_eq!(confd_only.layers.len(), 1); + + let both = merge_config_sources(Some(&base), Some(&confd)).expect("both"); + let t: toml::Table = toml::from_str(&both.text).unwrap(); + assert_eq!(t["outbound"]["max_peers"].as_integer(), Some(10)); + assert_eq!(t["outbound"]["min_peers"].as_integer(), Some(7)); + } + + /// The merged text is what BOTH parsers consume, so it has to survive a + /// round-trip through `toml::to_string` — including tables, which TOML + /// requires be emitted after plain values. + #[test] + fn merged_document_reparses() { + let doc = { + let dir = tempfile::tempdir().expect("tempdir"); + let base = dir.path().join("ergo.toml"); + std::fs::write( + &base, + "[proxy]\nnetwork = \"mainnet\"\n\n[outbound]\nmin_peers = 3\nseed_peers = [\"a:1\"]\n\n[listen.ipv6]\naddress = \"[::]:9030\"\n", + ) + .unwrap(); + merge_config_sources(Some(&base), None).expect("load") + }; + let t: toml::Table = toml::from_str(&doc.text).expect("merged text reparses"); + assert_eq!(t["proxy"]["network"].as_str(), Some("mainnet")); + assert_eq!(t["listen"]["ipv6"]["address"].as_str(), Some("[::]:9030")); + } + + #[test] + fn a_named_base_file_that_is_missing_is_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let missing = dir.path().join("nope.toml"); + assert!(merge_config_sources(Some(&missing), None).is_err()); + } + + #[test] + fn a_malformed_layer_names_its_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let confd = dir.path().join("conf.d"); + std::fs::create_dir(&confd).unwrap(); + std::fs::write(confd.join("50-bad.toml"), "this is not = = toml").unwrap(); + + let err = merge_config_sources(None, Some(&confd)) + .expect_err("malformed layer must fail") + .to_string(); + assert!( + err.contains("50-bad.toml"), + "the error must name the offending file, got: {err}" + ); + } + + // ── Startup memory floor (facts/memory.md) ─────────────────────────── + + /// A budget as `detect_memory_budget` would build it: usable derived from + /// the ceiling by the source's own fraction. Named apart from the existing + /// `budget(usable_mb)` helper below, which fixes *usable* directly — these + /// two test different halves and conflating them would hide the source + /// fraction, which is the whole point here. + fn budget_for(source: BudgetSource, ceiling_bytes: u64) -> MemoryBudget { + MemoryBudget { + source, + ceiling_bytes, + usable_bytes: (ceiling_bytes as f64 * source.usable_fraction()) as u64, + } + } + + const GIB: u64 = 1024 * 1024 * 1024; + + #[test] + fn utxo_refuses_below_the_floor() { + let b = budget_for(BudgetSource::MemTotal, 2 * GIB); + assert!(check_memory_floor(&b, StateType::Utxo, false).is_err()); + } + + #[test] + fn light_and_digest_are_allowed_below_the_floor() { + let b = budget_for(BudgetSource::MemTotal, 2 * GIB); + for st in [StateType::Light, StateType::Digest] { + assert!( + check_memory_floor(&b, st, false).is_ok(), + "{st:?} holds no prover tree — refusing it would block the one \ + mode a small box should run" + ); + } + } + + #[test] + fn ignore_memory_floor_downgrades_the_refusal() { + let b = budget_for(BudgetSource::MemTotal, 2 * GIB); + assert!(check_memory_floor(&b, StateType::Utxo, true).is_ok()); + } + + #[test] + fn at_or_above_the_floor_starts() { + for ceiling in [3 * GIB, 4 * GIB, 32 * GIB] { + let b = budget_for(BudgetSource::Cgroup, ceiling); + assert!(check_memory_floor(&b, StateType::Utxo, false).is_ok()); + } + } + + /// The regime distinction the contract insists on: the SAME 3 GiB is a + /// working node when a cgroup states it (90% → 2.7 GB usable, the + /// configuration that synced 831k blocks with zero OOM kills) and a much + /// tighter one when it is merely how much RAM the box has (50% → 1.5 GB). + #[test] + fn source_decides_how_much_the_same_ceiling_buys() { + let stated = budget_for(BudgetSource::Cgroup, 3 * GIB); + let observed = budget_for(BudgetSource::MemTotal, 3 * GIB); + assert_eq!(stated.ceiling_bytes, observed.ceiling_bytes); + assert!( + stated.usable_bytes > observed.usable_bytes * 3 / 2, + "a stated budget must buy materially more than an observed one" + ); + // Both clear the ceiling check — the ceiling is not the whole story, + // which is why the cold-sync-peak warning exists separately. + assert!(check_memory_floor(&stated, StateType::Utxo, false).is_ok()); + assert!(check_memory_floor(&observed, StateType::Utxo, false).is_ok()); + } + + /// 4 GiB of RAM with nothing stating a budget resolves to 2 GiB usable — + /// under the measured cold-sync peak, while passing the ceiling check. + /// This is the case the second warning exists for. + #[test] + fn recommended_ram_unstated_lands_under_the_cold_sync_peak() { + let b = budget_for(BudgetSource::MemTotal, 4 * GIB); + assert!(check_memory_floor(&b, StateType::Utxo, false).is_ok()); + assert!( + b.usable_bytes < COLD_SYNC_PEAK_BYTES, + "4 GiB at MemTotal's conservative share is below the cold-sync peak" + ); + } + + #[test] + fn cache_store_pct_out_of_range_is_rejected() { + for pct in [0u32, 100, 250] { + let cfg = NodeConfig { + cache_store_pct: Some(pct), + ..NodeConfig::default() + }; + assert!( + validate_cache_split(&cfg).is_err(), + "cache_store_pct {pct} should be rejected — a zero share means a \ + database with no page cache at all" + ); + } + for pct in [1u32, 50, 99] { + let cfg = NodeConfig { + cache_store_pct: Some(pct), + ..NodeConfig::default() + }; + assert!( + validate_cache_split(&cfg).is_ok(), + "pct {pct} should be accepted" + ); + } + } + + /// Build a `Validator` around either variant, with throwaway channels. + fn wrap(inner: ValidatorInner) -> Validator { + let (block_tx, _rx) = tokio::sync::mpsc::channel(1); + let (height_tx, _hrx) = tokio::sync::watch::channel(0u32); + Validator::new( + inner, + Arc::new(std::sync::atomic::AtomicU32::new(0)), + Arc::new(tokio::sync::RwLock::new(None)), + block_tx, + height_tx, + None, + ergo_api::PublishedGauge::unset().storage(), + ergo_api::PublishedGauge::unset().storage(), + ) + } + + #[test] + fn the_wrapper_hands_out_utxo_persistence_and_withholds_digest_persistence() { + // A missing forward here does NOT break visibly. `state_persistence` + // returning None for both arms compiles, and `sync/` reads None as + // "nothing to persist" — so the node simply stops flushing and loses + // state on the next unclean shutdown. The compiler forces the method + // to exist, never to be right. + // + // BOTH arms are asserted deliberately: a test that only checked the + // digest side would pass with both arms wrongly returning None, which + // is precisely the bug it is meant to catch. + + let digest = wrap(ValidatorInner::Digest(DigestValidator::new( + ergo_chain_types::ADDigest::zero(), + 0, + ))); + assert!( + digest.state_persistence().is_none(), + "digest mode owns no redb and must hand out no persistence" + ); + assert!( + digest.mining_state().is_none(), + "digest mode has no UTXO set to assemble candidates from" + ); + + let dir = tempfile::tempdir().expect("tempdir"); + let params = AVLTreeParams { + key_length: 32, + value_length: None, + }; + let mut storage = RedbAVLStorage::open( + &dir.path().join("state.redb"), + params, + 16, + CacheSize::Bytes(1024 * 1024), + ) + .expect("open state storage"); + + // Establish a version so UtxoValidator::new's precondition holds — + // the empty first commit documented on that constructor. + let mut prover = + BatchAVLProver::new(AVLTree::with_resolver(storage.resolver(), 32, None), true); + storage + .update_with_height(&mut prover, vec![], 0) + .expect("empty first commit"); + + let utxo = wrap(ValidatorInner::Utxo(UtxoValidator::new( + storage, + prover, + 0, + 0, + // This test is about which traits the wrapper exposes, not about + // emission tracking, and the tree here is empty anyway. + ergo_validation::EmissionSource::Unavailable("trait-surface test fixture"), + ))); + assert!( + utxo.state_persistence().is_some(), + "UTXO mode owns state.redb — handing out None here silently stops \ + every flush in the node" + ); + assert!( + utxo.mining_state().is_some(), + "UTXO mode can assemble candidates and must expose MiningState" + ); + } + + fn budget(usable_mb: u64) -> MemoryBudget { + MemoryBudget { + source: BudgetSource::Explicit, + ceiling_bytes: usable_mb * MIB, + usable_bytes: usable_mb * MIB, + } + } + + #[test] + fn an_absent_key_is_derived_and_a_present_one_is_obeyed_exactly() { + let b = budget(4096); + + let derived = derive_memory_plan(&NodeConfig::default(), &b, 0); + assert!( + derived.cache_mb > 0, + "absent cache_mb must derive a real size" + ); + assert!(derived.derived_any); + + let stated = NodeConfig { + cache_mb: Some(777), + ..NodeConfig::default() + }; + let plan = derive_memory_plan(&stated, &b, 0); + assert_eq!( + plan.cache_mb, 777, + "a stated value must be obeyed exactly, not adjusted — silently \ + overriding it is the failure this design exists to prevent" + ); + assert_ne!( + plan.flush_heap_threshold_mb, 0, + "stating one key must not disable derivation of the others" + ); + } + + #[test] + fn the_flush_trigger_always_sits_above_the_cache_it_must_outlive() { + // The trigger compares against total jemalloc `allocated`, which + // already contains the caches. A threshold at or below cache size + // fires on every check forever — a pathology that produces a node + // that runs and flushes constantly rather than one that fails. + for usable_mb in [512u64, 1024, 2048, 4096, 16384] { + let plan = derive_memory_plan(&NodeConfig::default(), &budget(usable_mb), 0); + assert!( + plan.flush_heap_threshold_mb > plan.cache_mb, + "usable {usable_mb} MB: flush trigger {} must exceed cache {}", + plan.flush_heap_threshold_mb, + plan.cache_mb + ); + } + } + + #[test] + fn a_budget_smaller_than_the_floor_still_yields_a_usable_cache() { + // 128 MB is below BASELINE_ANON_BYTES, so `available` saturates to + // zero. Deriving a zero-byte cache would be worse than a small one: + // redb with no page cache makes the startup chain walk ~70x slower. + let plan = derive_memory_plan(&NodeConfig::default(), &budget(128), 0); + assert!( + plan.cache_mb >= 64, + "a starved budget must still floor at a usable cache, got {}", + plan.cache_mb + ); + } + + #[test] + fn the_growing_chain_index_shrinks_the_derived_cache() { + let b = budget(4096); + let early = derive_memory_plan(&NodeConfig::default(), &b, 10 * MIB); + let late = derive_memory_plan(&NodeConfig::default(), &b, 400 * MIB); + assert!( + late.cache_mb < early.cache_mb, + "the index is part of the floor and grows with the chain, so the \ + cache derived against it must shrink: early {} late {}", + early.cache_mb, + late.cache_mb + ); + } + + #[test] + fn budget_source_decides_how_much_of_the_ceiling_is_spent() { + // A cgroup limit is somebody stating this node may have that much. + // MemTotal states only that the machine has it. + assert_eq!(BudgetSource::Explicit.usable_fraction(), 1.00); + assert!(BudgetSource::Cgroup.usable_fraction() > BudgetSource::MemTotal.usable_fraction()); + } + + /// A plan with only the two fields the split reads. Built directly rather + /// than through derivation so these tests keep asserting the split's + /// arithmetic and not the budget calibration, which moves. + fn plan_for_test(cache_mb: u64, cache_store_pct: u32) -> MemoryPlan { + MemoryPlan { + cache_mb, + cache_store_pct, + flush_heap_threshold_mb: 0, + flush_max_blocks: 0, + flush_min_blocks: 0, + synced_cache_mb: None, + synced_flush_heap_threshold_mb: None, + synced_flush_max_blocks: None, + synced_flush_min_blocks: None, + derived_any: false, + } + } + + #[test] + fn cache_split_divides_the_total_exactly() { + let plan = plan_for_test(512, 25); + let (store, state) = cache_split_bytes(&plan); + assert_eq!(store, 128 * 1024 * 1024); + assert_eq!(state, 384 * 1024 * 1024); + assert_eq!( + store + state, + 512 * 1024 * 1024, + "the split must sum to cache_mb exactly" + ); + } + + #[test] + fn cache_split_rounding_loses_no_bytes() { + // 333 is deliberately awkward: 777 MB * 33% does not divide evenly. + // The remainder must land in state rather than vanishing, or the + // configured total silently under-delivers. + let plan = plan_for_test(777, 33); + let (store, state) = cache_split_bytes(&plan); + assert_eq!(store + state, 777 * 1024 * 1024); + } + #[test] fn testnet_genesis_boxes_produce_correct_digest() { let boxes = build_genesis_boxes(enr_p2p::types::Network::Testnet); @@ -3185,12 +5024,7 @@ mod tests { "5527430474b673e4aafb08e0079c639de23e6a17e87edd00f78662b43c88aeda", ]; for (i, (id, _)) in boxes.iter().enumerate() { - assert_eq!( - hex::encode(id), - expected_ids[i], - "box {} ID mismatch", - i - ); + assert_eq!(hex::encode(id), expected_ids[i], "box {} ID mismatch", i); } // Insert into AVL+ tree and verify genesis state digest @@ -3231,12 +5065,7 @@ mod tests { "5527430474b673e4aafb08e0079c639de23e6a17e87edd00f78662b43c88aeda", ]; for (i, (id, _)) in boxes.iter().enumerate() { - assert_eq!( - hex::encode(id), - expected_ids[i], - "box {} ID mismatch", - i - ); + assert_eq!(hex::encode(id), expected_ids[i], "box {} ID mismatch", i); } // Insert into AVL+ tree and verify genesis state digest diff --git a/src/nipopow_serve.rs b/src/nipopow_serve.rs index eae3457..02b51e3 100644 --- a/src/nipopow_serve.rs +++ b/src/nipopow_serve.rs @@ -415,7 +415,7 @@ mod tests { inner.put_u32(6).unwrap(); // m inner.put_u32(10).unwrap(); // k inner.put_u32(0).unwrap(); // num_prefixes = 0 - // suffix_head_size + minimal header placeholder + // suffix_head_size + minimal header placeholder inner.put_u32(1).unwrap(); // suffix_head_size inner.push(0x00); // bogus header byte (will fail parse) diff --git a/src/peer_storage_adapter.rs b/src/peer_storage_adapter.rs index 060b102..7aa6f32 100644 --- a/src/peer_storage_adapter.rs +++ b/src/peer_storage_adapter.rs @@ -108,11 +108,17 @@ struct Cursor<'a> { } impl<'a> Cursor<'a> { - fn new(buf: &'a [u8]) -> Self { Self { buf, pos: 0 } } + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } fn take(&mut self, n: usize) -> Result<&'a [u8], String> { if self.pos + n > self.buf.len() { - return Err(format!("short read: want {} have {}", n, self.buf.len() - self.pos)); + return Err(format!( + "short read: want {} have {}", + n, + self.buf.len() - self.pos + )); } let s = &self.buf[self.pos..self.pos + n]; self.pos += n; @@ -157,10 +163,7 @@ mod tests { agent_name: "ergoref".to_string(), node_name: "test-node".to_string(), version: (5, 0, 25), - features: vec![ - (16, vec![0xaa, 0xbb]), - (3, vec![0x01, 0x00, 0x02, 0x04]), - ], + features: vec![(16, vec![0xaa, 0xbb]), (3, vec![0x01, 0x00, 0x02, 0x04])], } } diff --git a/src/pipeline.rs b/src/pipeline.rs index 0e0c614..35fe493 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -2,8 +2,8 @@ use std::num::NonZeroUsize; use std::sync::Arc; use enr_chain::{ - AppendResult, BlockId, ChainError, Header, HeaderChain, HeaderTracker, - decode_compact_bits, HEADER_TYPE_ID, TRANSACTION_TYPE_ID, + decode_compact_bits, AppendResult, BlockId, ChainError, Header, HeaderChain, HeaderTracker, + HEADER_TYPE_ID, TRANSACTION_TYPE_ID, }; use enr_store::{ModifierStore, RedbModifierStore}; use ergo_sync::delivery::{DeliveryControl, DeliveryData}; @@ -113,7 +113,7 @@ impl ValidationPipeline { } // Parent is another fork header — read it from the store - let parent_data = match self.store.get(HEADER_TYPE_ID, ¤t_parent.0.0) { + let parent_data = match self.store.get(HEADER_TYPE_ID, ¤t_parent.0 .0) { Ok(Some(data)) => data, _ => { tracing::debug!(parent = %current_parent, "fork chain broken — parent not in store"); @@ -165,37 +165,42 @@ impl ValidationPipeline { let mut section_entries: Vec = Vec::new(); { - let chain_guard = self.chain.lock().await; - for (type_id, id, data, peer_id) in &batch { - received_ids.push(*id); - if *type_id == HEADER_TYPE_ID { - raw_headers.push((data.as_slice(), *peer_id)); - } else if *type_id == TRANSACTION_TYPE_ID && !data.is_empty() { - // Unconfirmed transaction — forward to mempool - if let Some(ref tx_sender) = self.tx_sender { - let _ = tx_sender.try_send((*id, data.clone())); + let chain_guard = self.chain.lock().await; + for (type_id, id, data, peer_id) in &batch { + received_ids.push(*id); + if *type_id == HEADER_TYPE_ID { + raw_headers.push((data.as_slice(), *peer_id)); + } else if *type_id == TRANSACTION_TYPE_ID && !data.is_empty() { + // Unconfirmed transaction — forward to mempool + if let Some(ref tx_sender) = self.tx_sender { + let _ = tx_sender.try_send((*id, data.clone())); + } + } else if !data.is_empty() { + // Block sections (102=BlockTransactions, 104=ADProofs, 108=Extension) + // have the header ID in the first 32 bytes. Look up the header to + // derive the height so the store can index by (type_id, height). + let height = if data.len() >= 32 { + let header_id: [u8; 32] = data[..32].try_into().unwrap(); + let block_id = + ergo_chain_types::BlockId(ergo_chain_types::Digest32::from(header_id)); + chain_guard.height_of(&block_id).unwrap_or(0) + } else { + 0 + }; + section_entries.push((*type_id, *id, height, data.clone(), None)); } - } else if !data.is_empty() { - // Block sections (102=BlockTransactions, 104=ADProofs, 108=Extension) - // have the header ID in the first 32 bytes. Look up the header to - // derive the height so the store can index by (type_id, height). - let height = if data.len() >= 32 { - let header_id: [u8; 32] = data[..32].try_into().unwrap(); - let block_id = ergo_chain_types::BlockId(ergo_chain_types::Digest32::from(header_id)); - chain_guard.height_of(&block_id).unwrap_or(0) - } else { - 0 - }; - section_entries.push((*type_id, *id, height, data.clone(), None)); } - } } // drop chain_guard // Notify delivery tracker (data plane — ok to drop) if !received_ids.is_empty() - && self.delivery_data_tx.try_send(DeliveryData::Received(received_ids)).is_err() { - tracing::debug!("delivery data channel full, dropped Received notification"); - } + && self + .delivery_data_tx + .try_send(DeliveryData::Received(received_ids)) + .is_err() + { + tracing::debug!("delivery data channel full, dropped Received notification"); + } // Store non-header block sections directly (no validation) if !section_entries.is_empty() { @@ -230,7 +235,9 @@ impl ValidationPipeline { // different bytes. These would break SyncInfo (commonPoint fails). if let Ok(reserialized) = header.scorex_serialize_bytes() { if data != reserialized.as_slice() { - let first_diff = data.iter().zip(reserialized.iter()) + let first_diff = data + .iter() + .zip(reserialized.iter()) .position(|(a, b)| a != b); tracing::error!( height = header.height, @@ -246,7 +253,11 @@ impl ValidationPipeline { if let Err(e) = enr_chain::verify_pow(&header) { if let Some(pid) = peer_id { - tracing::warn!(peer_id = pid, height = header.height, "PENALTY invalid PoW: {e}"); + tracing::warn!( + peer_id = pid, + height = header.height, + "PENALTY invalid PoW: {e}" + ); } else { tracing::debug!( "pipeline: rejecting header at height {}: {e}", @@ -290,10 +301,17 @@ impl ValidationPipeline { match chain.try_append(header.clone()) { Ok(AppendResult::Extended) => { chained += 1; - let score_bytes = chain.score_at(header_height) + let score_bytes = chain + .score_at(header_height) .expect("score for just-appended header") .to_bytes_be(); - store_entries.push((HEADER_TYPE_ID, header_id.0.0, header_height, raw, Some(score_bytes))); + store_entries.push(( + HEADER_TYPE_ID, + header_id.0 .0, + header_height, + raw, + Some(score_bytes), + )); if header_height % 400 < 2 { tracing::debug!(height = header_height, id = %header_id, "chained header ID"); } @@ -305,10 +323,17 @@ impl ValidationPipeline { match chain.try_append(buf.clone()) { Ok(AppendResult::Extended) => { chained += 1; - let buf_score_bytes = chain.score_at(buf_height) + let buf_score_bytes = chain + .score_at(buf_height) .expect("score for just-appended buffered header") .to_bytes_be(); - store_entries.push((HEADER_TYPE_ID, bid.0.0, buf_height, buf_raw, Some(buf_score_bytes))); + store_entries.push(( + HEADER_TYPE_ID, + bid.0 .0, + buf_height, + buf_raw, + Some(buf_score_bytes), + )); self.tracker.observe(&buf); next_parent = bid; } @@ -320,22 +345,26 @@ impl ValidationPipeline { // Header is valid but forks from the best chain. // Compute its cumulative score and store immediately // (later headers in this batch may extend this fork). - let parent_score = chain.score_at(fork_height) - .unwrap_or_default(); + let parent_score = chain.score_at(fork_height).unwrap_or_default(); let difficulty = decode_compact_bits(header.n_bits) .to_biguint() .unwrap_or_default(); let fork_score = &parent_score + &difficulty; // Determine fork number at this height - let fork_num = self.store.header_ids_at_height(header_height) + let fork_num = self + .store + .header_ids_at_height(header_height) .map(|forks| forks.last().map(|(_, f)| f + 1).unwrap_or(1)) .unwrap_or(1); let score_bytes = fork_score.to_bytes_be(); if let Err(e) = self.store.put_header( - &header_id.0.0, header_height, fork_num, - &score_bytes, &raw, + &header_id.0 .0, + header_height, + fork_num, + &score_bytes, + &raw, ) { tracing::error!(height = header_height, "store fork header failed: {e}"); } @@ -355,7 +384,9 @@ impl ValidationPipeline { Err(ChainError::ParentNotFound { .. }) => { // Parent not in best chain. Check if it's a known fork header // in the store — if so, this extends that fork chain. - let parent_score_opt = self.store.header_score(&parent_id.0.0) + let parent_score_opt = self + .store + .header_score(&parent_id.0 .0) .ok() .flatten() .filter(|s| !s.is_empty()); @@ -369,16 +400,24 @@ impl ValidationPipeline { .unwrap_or_default(); let fork_score = &parent_score + &difficulty; - let fork_num = self.store.header_ids_at_height(header_height) + let fork_num = self + .store + .header_ids_at_height(header_height) .map(|forks| forks.last().map(|(_, f)| f + 1).unwrap_or(1)) .unwrap_or(1); let score_bytes = fork_score.to_bytes_be(); if let Err(e) = self.store.put_header( - &header_id.0.0, header_height, fork_num, - &score_bytes, &raw, + &header_id.0 .0, + header_height, + fork_num, + &score_bytes, + &raw, ) { - tracing::error!(height = header_height, "store fork chain header failed: {e}"); + tracing::error!( + height = header_height, + "store fork chain header failed: {e}" + ); } let best_score = chain.cumulative_score(); @@ -423,18 +462,20 @@ impl ValidationPipeline { // so fetching the lowest missing parent is sufficient: once // it arrives and chains, the rest drain from the buffer. if (self.reorg_requested.is_empty() || self.reorg_requested.len() < 3) - && self.reorg_requested.insert(parent_id.0.0) { - let _ = self.delivery_control_tx.send( - DeliveryControl::NeedModifier { - type_id: HEADER_TYPE_ID, - id: parent_id.0.0, - }, - ); - } + && self.reorg_requested.insert(parent_id.0 .0) + { + let _ = self + .delivery_control_tx + .send(DeliveryControl::NeedModifier { + type_id: HEADER_TYPE_ID, + id: parent_id.0 .0, + }); + } buffered += 1; - if let Some((_, (evicted, _))) = self.buffer.push(parent_id, (header, raw)) { - evicted_ids.push(evicted.id.0.0); + if let Some((_, (evicted, _))) = self.buffer.push(parent_id, (header, raw)) + { + evicted_ids.push(evicted.id.0 .0); } } } @@ -442,7 +483,7 @@ impl ValidationPipeline { | Err(ChainError::InvalidGenesisHeight { .. }) => { buffered += 1; if let Some((_, (evicted, _))) = self.buffer.push(parent_id, (header, raw)) { - evicted_ids.push(evicted.id.0.0); + evicted_ids.push(evicted.id.0 .0); } } Err(_) => { @@ -460,10 +501,8 @@ impl ValidationPipeline { if let Some((fork_point, new_branch_with_raw)) = pending_reorg { let old_tip = chain.height(); let old_tip_id = chain.tip().id; - let headers_only: Vec
= new_branch_with_raw - .iter() - .map(|(h, _)| h.clone()) - .collect(); + let headers_only: Vec
= + new_branch_with_raw.iter().map(|(h, _)| h.clone()).collect(); match chain.try_reorg_deep(fork_point, headers_only) { Ok(demoted_ids) => { let new_tip = chain.height(); @@ -492,10 +531,11 @@ impl ValidationPipeline { let reorg_entries: Vec = new_branch_with_raw .into_iter() .map(|(h, raw)| { - let score_bytes = chain.score_at(h.height) + let score_bytes = chain + .score_at(h.height) .expect("score for reorged header") .to_bytes_be(); - (HEADER_TYPE_ID, h.id.0.0, h.height, raw, Some(score_bytes)) + (HEADER_TYPE_ID, h.id.0 .0, h.height, raw, Some(score_bytes)) }) .collect(); let reorg_entry_count = reorg_entries.len(); @@ -528,7 +568,9 @@ impl ValidationPipeline { // Purge buffer entries at or below the chain tip (stale duplicates) let before_purge = self.buffer.len(); - let stale_keys: Vec = self.buffer.iter() + let stale_keys: Vec = self + .buffer + .iter() .filter(|(_, (h, _))| h.height <= height_after) .map(|(k, _)| *k) .collect(); @@ -573,8 +615,9 @@ impl ValidationPipeline { .map(|forks| forks.last().map(|(_, f)| f + 1).unwrap_or(1)) .unwrap_or(1); let score_bytes = score.unwrap_or_default(); - if let Err(e) = - self.store.put_header(&id, height, fork_num, &score_bytes, &raw) + if let Err(e) = self + .store + .put_header(&id, height, fork_num, &score_bytes, &raw) { tracing::error!(height, "store demoted header failed: {e}"); } @@ -590,7 +633,11 @@ impl ValidationPipeline { // Notify delivery tracker of evicted modifier IDs for re-request (data plane — ok to drop) if !evicted_ids.is_empty() { tracing::debug!(count = evicted_ids.len(), "buffer evictions → re-request"); - if self.delivery_data_tx.try_send(DeliveryData::Evicted(evicted_ids)).is_err() { + if self + .delivery_data_tx + .try_send(DeliveryData::Evicted(evicted_ids)) + .is_err() + { tracing::debug!("delivery data channel full, dropped Evicted notification"); } } @@ -610,14 +657,18 @@ impl ValidationPipeline { if chained > 0 || buffered > 0 { tracing::debug!( batch_size, - chained, buffered, rejected, purged, + chained, + buffered, + rejected, + purged, chain_tip = height_after, "pipeline: batch breakdown" ); } else if rejected > 0 || purged > 0 { tracing::debug!( batch_size, - rejected, purged, + rejected, + purged, chain_tip = height_after, "pipeline: batch breakdown (all known)" ); @@ -645,9 +696,28 @@ mod tests { let (delivery_data_tx, delivery_data_rx) = mpsc::channel(64); let chain = Arc::new(Mutex::new(HeaderChain::new(ChainConfig::testnet()))); let dir = tempfile::TempDir::new().unwrap(); - let store = Arc::new(RedbModifierStore::new(&dir.path().join("test.redb")).unwrap()); - let pipeline = ValidationPipeline::new(rx, chain, store, progress_tx, delivery_control_tx, delivery_data_tx); - (pipeline, tx, progress_rx, delivery_control_rx, delivery_data_rx, dir) + // 8 MiB: a test fixture has no working set worth caching, and taking + // redb's 1 GiB default per test store is what this parameter exists + // to stop. + let store = Arc::new( + RedbModifierStore::new(&dir.path().join("test.redb"), 8 * 1024 * 1024).unwrap(), + ); + let pipeline = ValidationPipeline::new( + rx, + chain, + store, + progress_tx, + delivery_control_tx, + delivery_data_tx, + ); + ( + pipeline, + tx, + progress_rx, + delivery_control_rx, + delivery_data_rx, + dir, + ) } #[test] @@ -678,7 +748,10 @@ mod tests { let bytes1 = header.scorex_serialize_bytes().unwrap(); let header2 = Header::scorex_parse_bytes(&bytes1).unwrap(); let bytes2 = header2.scorex_serialize_bytes().unwrap(); - assert_eq!(bytes1, bytes2, "scorex serialize round-trip must be byte-identical"); + assert_eq!( + bytes1, bytes2, + "scorex serialize round-trip must be byte-identical" + ); } #[test] @@ -734,9 +807,17 @@ mod tests { rt.block_on(pipeline.process_batch(batch)); // Header has valid PoW but parent is missing, so it goes to the LRU buffer - assert_eq!(pipeline.buffer.len(), 1, "valid PoW header should be buffered"); + assert_eq!( + pipeline.buffer.len(), + 1, + "valid PoW header should be buffered" + ); let chain = rt.block_on(pipeline.chain.lock()); - assert_eq!(chain.height(), 0, "unchainable header should not increase height"); + assert_eq!( + chain.height(), + 0, + "unchainable header should not increase height" + ); } /// Test vector from JVM ergo-core HeaderSerializationSpecification. @@ -773,8 +854,7 @@ mod tests { // Verify header ID matches JVM (Blake2b256 of serialized bytes) let id_hex = format!("{}", header.id); assert_eq!( - id_hex, - "f46c89e44f13a92d8409341490f97f05c85785fa8d2d2164332cc066eda95c39", + id_hex, "f46c89e44f13a92d8409341490f97f05c85785fa8d2d2164332cc066eda95c39", "header ID must match JVM test vector" ); diff --git a/src/snapshot_serve.rs b/src/snapshot_serve.rs index acd56bb..6124f8c 100644 --- a/src/snapshot_serve.rs +++ b/src/snapshot_serve.rs @@ -78,5 +78,8 @@ pub fn handle_snapshot_request( /// Returns true if `code` is a snapshot request that should be intercepted /// for serving (not forwarded to the sync machine). pub fn is_snapshot_request(code: u8) -> bool { - matches!(code, GET_SNAPSHOTS_INFO | GET_MANIFEST | GET_UTXO_SNAPSHOT_CHUNK) + matches!( + code, + GET_SNAPSHOTS_INFO | GET_MANIFEST | GET_UTXO_SNAPSHOT_CHUNK + ) } diff --git a/src/snapshot_store.rs b/src/snapshot_store.rs index 583056f..f310f5a 100644 --- a/src/snapshot_store.rs +++ b/src/snapshot_store.rs @@ -68,7 +68,8 @@ impl SnapshotStore { let mut meta = txn.open_table(METADATA)?; // Read existing info, append new entry - let mut info = meta.get(SNAPSHOTS_INFO_KEY)? + let mut info = meta + .get(SNAPSHOTS_INFO_KEY)? .map(|g| parse_info(g.value())) .unwrap_or_default(); info.push((height, manifest_id)); @@ -138,7 +139,9 @@ impl SnapshotStore { let chunk_ids: Vec<[u8; 32]> = { let meta = txn.open_table(METADATA)?; let result = meta.get(chunk_key.as_str())?; - result.map(|g| parse_chunk_id_list(g.value())).unwrap_or_default() + result + .map(|g| parse_chunk_id_list(g.value())) + .unwrap_or_default() }; // Delete chunks @@ -164,7 +167,8 @@ impl SnapshotStore { meta.remove(chunk_key.as_str())?; // Remove from snapshots_info - let mut info = meta.get(SNAPSHOTS_INFO_KEY)? + let mut info = meta + .get(SNAPSHOTS_INFO_KEY)? .map(|g| parse_info(g.value())) .unwrap_or_default(); info.retain(|(_, id)| *id != manifest_id); diff --git a/state/Cargo.toml b/state/Cargo.toml index 7392659..f09c689 100644 --- a/state/Cargo.toml +++ b/state/Cargo.toml @@ -1,11 +1,16 @@ [package] name = "enr-state" -version = "0.1.0" +version.workspace = true edition = "2021" [dependencies] ergo_avltree_rust = "0.1.1" -redb = "4" +# cache_metrics is REQUIRED, not optional tuning: without it cache_stats() +# returns zeros and /debug/memory would confidently report an empty cache for +# the largest consumer during sync. Declared per-crate rather than only at the +# workspace root so `cargo test -p ` — which does not build the root +# crate — still collects them. +redb = { version = "4", features = ["cache_metrics"] } bytes = "1" anyhow = "1" tracing = "0.1" diff --git a/state/src/storage.rs b/state/src/storage.rs index 780cb82..2dcdf58 100644 --- a/state/src/storage.rs +++ b/state/src/storage.rs @@ -30,10 +30,14 @@ pub struct AVLTreeParams { /// Lightweight read-only handle for snapshot operations. /// Shares the underlying redb `Database` with `RedbAVLStorage` via `Arc`. +/// +/// Carries the owning storage's tree parameters so that a resolver built from +/// a reader unpacks nodes exactly the way the storage's own resolver does. #[derive(Clone)] pub struct SnapshotReader { db: Arc, key_length: usize, + value_length: Option, } /// A serialized snapshot of the AVL+ tree, split into manifest and chunks. @@ -143,6 +147,86 @@ fn read_memtotal() -> Option { None } +/// Build a `Resolver` closure over `db` that loads nodes from the `nodes` +/// table on demand. Misses log WARN with the digest hex and return a +/// `LabelOnly` placeholder that preserves the requested label. +/// +/// **Every call allocates.** `unpack` builds a fresh `NodeId`, and what the +/// closure returns is a clone of that node, so no handle is ever shared with +/// another tree. Nothing here caches, and nothing here may start to: a +/// prover restored onto nodes still keyed in *another* prover's address-keyed +/// map emits a different proof for identical tree state — same digest, +/// different bytes. That is the hazard recorded under `rollback()` in +/// `facts/state.md`, and a read-only prover is exactly where someone would +/// later be tempted to add a shared node cache "for performance". +/// +/// Single implementation behind both [`RedbAVLStorage::resolver`] and +/// [`SnapshotReader::resolver`] — two copies would drift, and drift here is +/// the wrong-proof hazard above. +fn make_resolver(db: Arc, key_length: usize, value_length: Option) -> Resolver { + Arc::new(move |digest: &Digest32| { + let read_txn = match db.begin_read() { + Ok(txn) => txn, + Err(e) => { + error!(error = %e, "resolver: begin_read failed"); + log_resolver_miss(digest, "begin_read_error"); + return Node::LabelOnly(NodeHeader::new(Some(*digest), None)); + } + }; + let table = match read_txn.open_table(NODES_TABLE) { + Ok(t) => t, + Err(e) => { + error!(error = %e, "resolver: open_table failed"); + log_resolver_miss(digest, "open_table_error"); + return Node::LabelOnly(NodeHeader::new(Some(*digest), None)); + } + }; + match table.get(digest.as_slice()) { + Ok(Some(data)) => { + let bytes: &[u8] = data.value(); + let dummy: Resolver = Arc::new(|_| panic!("resolver called during unpack")); + let tree = AVLTree::with_resolver(dummy, key_length, value_length); + let node_id = tree.unpack(&Bytes::copy_from_slice(bytes)); + let node = node_id.borrow().clone(); + node + } + Ok(None) => { + log_resolver_miss(digest, "not_in_storage"); + Node::LabelOnly(NodeHeader::new(Some(*digest), None)) + } + Err(e) => { + error!(error = %e, "resolver: table.get failed"); + log_resolver_miss(digest, "table_get_error"); + Node::LabelOnly(NodeHeader::new(Some(*digest), None)) + } + } + }) +} + +/// Read top node hash and height from the `meta` table. +/// +/// Returns `None` if storage is empty. This is the *committed* root: a redb +/// read transaction cannot observe a prover's uncommitted in-memory state. +/// +/// Single implementation behind both [`RedbAVLStorage::root_state`] and +/// [`SnapshotReader::root_state`]. +fn read_root_state(db: &Database) -> Option<(Digest32, usize)> { + let read_txn = db.begin_read().ok()?; + let meta = read_txn.open_table(META_TABLE).ok()?; + + let hash_guard = meta.get(META_TOP_NODE_HASH).ok()??; + let hash_bytes: &[u8] = hash_guard.value(); + let mut hash: Digest32 = [0u8; 32]; + hash.copy_from_slice(hash_bytes); + drop(hash_guard); + + let height_guard = meta.get(META_TOP_NODE_HEIGHT).ok()??; + let height_bytes: &[u8] = height_guard.value(); + let height = u32::from_be_bytes(height_bytes.try_into().ok()?) as usize; + + Some((hash, height)) +} + /// Persistent, versioned, crash-safe AVL+ authenticated dictionary over redb. pub struct RedbAVLStorage { db: Arc, @@ -272,59 +356,32 @@ impl RedbAVLStorage { self.db.clear_read_cache(); } + /// Page cache occupancy. Requires redb's `cache_metrics` feature, which + /// this crate's `Cargo.toml` declares unconditionally — without it redb + /// reports 0 and `/debug/memory` would attribute nothing to what is + /// usually the process's largest consumer during sync. + pub fn cache_bytes_used(&self) -> u64 { + self.db.cache_stats().used_bytes() as u64 + } + /// Create a read-only snapshot reader that shares the database handle. /// Call this BEFORE handing the storage to PersistentBatchAVLProver. pub fn snapshot_reader(&self) -> SnapshotReader { SnapshotReader { db: Arc::clone(&self.db), key_length: self.tree_params.key_length, + value_length: self.tree_params.value_length, } } /// Create a Resolver closure that reads nodes from storage on demand. /// Misses log WARN with the digest hex for post-failure diagnostics. pub fn resolver(&self) -> Resolver { - let db = Arc::clone(&self.db); - let key_length = self.tree_params.key_length; - let value_length = self.tree_params.value_length; - - Arc::new(move |digest: &Digest32| { - let read_txn = match db.begin_read() { - Ok(txn) => txn, - Err(e) => { - error!(error = %e, "resolver: begin_read failed"); - log_resolver_miss(digest, "begin_read_error"); - return Node::LabelOnly(NodeHeader::new(Some(*digest), None)); - } - }; - let table = match read_txn.open_table(NODES_TABLE) { - Ok(t) => t, - Err(e) => { - error!(error = %e, "resolver: open_table failed"); - log_resolver_miss(digest, "open_table_error"); - return Node::LabelOnly(NodeHeader::new(Some(*digest), None)); - } - }; - match table.get(digest.as_slice()) { - Ok(Some(data)) => { - let bytes: &[u8] = data.value(); - let dummy: Resolver = Arc::new(|_| panic!("resolver called during unpack")); - let tree = AVLTree::with_resolver(dummy, key_length, value_length); - let node_id = tree.unpack(&Bytes::copy_from_slice(bytes)); - let node = node_id.borrow().clone(); - node - } - Ok(None) => { - log_resolver_miss(digest, "not_in_storage"); - Node::LabelOnly(NodeHeader::new(Some(*digest), None)) - } - Err(e) => { - error!(error = %e, "resolver: table.get failed"); - log_resolver_miss(digest, "table_get_error"); - Node::LabelOnly(NodeHeader::new(Some(*digest), None)) - } - } - }) + make_resolver( + Arc::clone(&self.db), + self.tree_params.key_length, + self.tree_params.value_length, + ) } /// Read a single node's packed bytes by label. @@ -342,22 +399,7 @@ impl RedbAVLStorage { /// Read top node hash and height from metadata. pub fn root_state(&self) -> Option<(Digest32, usize)> { - let read_txn = self.db.begin_read().ok()?; - let meta = read_txn.open_table(META_TABLE).ok()?; - - let hash_guard = meta.get(META_TOP_NODE_HASH).ok()??; - let hash_bytes: &[u8] = hash_guard.value(); - let mut hash: Digest32 = [0u8; 32]; - hash.copy_from_slice(hash_bytes); - drop(hash_guard); - - let height_guard = meta.get(META_TOP_NODE_HEIGHT).ok()??; - let height_bytes: &[u8] = height_guard.value(); - let height = u32::from_be_bytes( - height_bytes.try_into().ok()?, - ) as usize; - - Some((hash, height)) + read_root_state(&self.db) } // ── helpers ──────────────────────────────────────────────────────── @@ -410,7 +452,11 @@ impl RedbAVLStorage { /// Create a lightweight AVLTree for pack/unpack only (no real resolver). fn make_tree(&self) -> AVLTree { let dummy: Resolver = Arc::new(|_| panic!("dummy resolver")); - AVLTree::with_resolver(dummy, self.tree_params.key_length, self.tree_params.value_length) + AVLTree::with_resolver( + dummy, + self.tree_params.key_length, + self.tree_params.value_length, + ) } fn serialize_version_chain(chain: &VecDeque<(u64, ADDigest)>) -> Vec { @@ -478,8 +524,10 @@ impl RedbAVLStorage { } meta_table.insert(META_TOP_NODE_HASH, root_hash.as_slice())?; - meta_table - .insert(META_TOP_NODE_HEIGHT, (height as u32).to_be_bytes().as_slice())?; + meta_table.insert( + META_TOP_NODE_HEIGHT, + (height as u32).to_be_bytes().as_slice(), + )?; meta_table.insert(META_CURRENT_VERSION, version.as_ref())?; meta_table.insert(META_LSN, 1u64.to_be_bytes().as_slice())?; meta_table.insert(META_BLOCK_HEIGHT, block_height.to_be_bytes().as_slice())?; @@ -563,6 +611,46 @@ impl RedbAVLStorage { // ── SnapshotReader ─────────────────────────────────────────────────── impl SnapshotReader { + /// Page cache occupancy — the same figure as + /// [`RedbAVLStorage::cache_bytes_used`], because this reader shares the + /// owning storage's `Arc`. + /// + /// Exists because the API cannot see the storage: `RedbAVLStorage` is + /// moved into the validator at startup, and `ergo_api::UtxoAccess` only + /// ever holds a reader. A reader that exists has a database, so this is + /// live even mid-swap. + pub fn cache_bytes_used(&self) -> u64 { + self.db.cache_stats().used_bytes() as u64 + } + + /// The same resolver [`RedbAVLStorage::resolver`] hands out, reachable + /// from a reader. Together with [`SnapshotReader::root_state`] it is + /// everything needed to stand up a **read-only prover** over the + /// committed tree. + /// + /// Exists because mining must compute AD proofs for a candidate without + /// touching the live prover, and cannot reach the validator that already + /// knows how (owned by `sync/`, and `!Sync`). A second `RedbAVLStorage` + /// on the same file is not an option either — redb holds an exclusive + /// file lock, which is why this reader type exists at all. + /// + /// Read-only: the closure only ever opens read transactions. Nodes it + /// resolves are freshly allocated per call and shared with no other + /// tree — see [`make_resolver`] for why that is load-bearing rather than + /// incidental. + pub fn resolver(&self) -> Resolver { + make_resolver(Arc::clone(&self.db), self.key_length, self.value_length) + } + + /// The committed root hash and tree height — the same figure + /// [`RedbAVLStorage::root_state`] reports, reachable from a reader. + /// + /// A reader that exists has a database, so there is no liveness check: + /// `None` means the tree is empty, never that the handle went stale. + pub fn root_state(&self) -> Option<(Digest32, usize)> { + read_root_state(&self.db) + } + /// Dump the AVL+ tree as a snapshot manifest + chunks. /// /// Opens a single read transaction for consistency. Walks the tree in @@ -664,8 +752,22 @@ impl SnapshotReader { subtree_roots.push(right_label); } else { // level < manifest_depth: recurse. - self.walk_manifest(table, &left_label, level + 1, manifest_depth, manifest, subtree_roots)?; - self.walk_manifest(table, &right_label, level + 1, manifest_depth, manifest, subtree_roots)?; + self.walk_manifest( + table, + &left_label, + level + 1, + manifest_depth, + manifest, + subtree_roots, + )?; + self.walk_manifest( + table, + &right_label, + level + 1, + manifest_depth, + manifest, + subtree_roots, + )?; } Ok(()) @@ -738,9 +840,9 @@ impl SnapshotReader { warn!("corrupt leaf node: truncated value length"); return None; } - let vlen = u32::from_be_bytes( - packed[vlen_offset..vlen_offset + 4].try_into().ok()?, - ) as usize; + let vlen = + u32::from_be_bytes(packed[vlen_offset..vlen_offset + 4].try_into().ok()?) + as usize; if packed.len() < vlen_offset + 4 + vlen { warn!("corrupt leaf node: truncated value"); return None; @@ -872,8 +974,7 @@ impl RedbAVLStorage { let mut removed_with_bytes = Vec::with_capacity(removed_labels.len()); for label in &removed_labels { if let Some(data) = nodes_table.get(label.as_slice())? { - removed_with_bytes - .push((*label, Bytes::copy_from_slice(data.value()))); + removed_with_bytes.push((*label, Bytes::copy_from_slice(data.value()))); } } @@ -918,8 +1019,7 @@ impl RedbAVLStorage { // 6. Write new/modified nodes. Track labels we just wrote so // the delete loop can refuse to remove them — see the // overlap guard at step 7 for the reasoning. - let mut written_labels: HashSet = - HashSet::with_capacity(changed_nodes.len()); + let mut written_labels: HashSet = HashSet::with_capacity(changed_nodes.len()); for (label, packed) in &changed_nodes { nodes_table.insert(label.as_slice(), packed.as_ref())?; written_labels.insert(*label); @@ -1075,8 +1175,7 @@ impl VersionedAVLStorage for RedbAVLStorage { // Restore metadata from the last processed undo record. let undo = last_undo.as_ref().unwrap(); - meta_table - .insert(META_TOP_NODE_HASH, undo.prev_top_node_hash.as_slice())?; + meta_table.insert(META_TOP_NODE_HASH, undo.prev_top_node_hash.as_slice())?; meta_table.insert( META_TOP_NODE_HEIGHT, undo.prev_top_node_height.to_be_bytes().as_slice(), @@ -1336,8 +1435,10 @@ fn open_source_read_only(source: &Path) -> Result { without writing to it — start the node once and stop it gracefully, then retry", source.display() ), - Err(e) => Err(anyhow::Error::new(e) - .context(format!("failed to open {} read-only", source.display()))), + Err(e) => { + Err(anyhow::Error::new(e) + .context(format!("failed to open {} read-only", source.display()))) + } } } @@ -1353,7 +1454,11 @@ fn open_source_read_only(source: &Path) -> Result { /// nothing about the other twenty million rows. /// /// Returns the recomputed root label. -fn verify_compacted(dest: &Path, expected_root: &Digest32, expected_nodes: u64) -> Result { +fn verify_compacted( + dest: &Path, + expected_root: &Digest32, + expected_nodes: u64, +) -> Result { let db = redb::Builder::new() .set_cache_size(COMPACTION_CACHE_BYTES) .open_read_only(dest) diff --git a/state/src/undo.rs b/state/src/undo.rs index efe77f8..cbe4f43 100644 --- a/state/src/undo.rs +++ b/state/src/undo.rs @@ -80,7 +80,10 @@ impl UndoRecord { let label = read_digest(&mut pos)?; let packed_len = read_u32(&mut pos)? as usize; if pos + packed_len > data.len() { - bail!("undo record truncated reading packed bytes at offset {}", pos); + bail!( + "undo record truncated reading packed bytes at offset {}", + pos + ); } let packed = Bytes::copy_from_slice(&data[pos..pos + packed_len]); pos += packed_len; @@ -99,7 +102,10 @@ impl UndoRecord { let prev_top_node_height = read_u32(&mut pos)?; let version_len = read_u32(&mut pos)? as usize; if pos + version_len > data.len() { - bail!("undo record truncated reading prev_version at offset {}", pos); + bail!( + "undo record truncated reading prev_version at offset {}", + pos + ); } let prev_version = Bytes::copy_from_slice(&data[pos..pos + version_len]); pos += version_len; diff --git a/state/tests/journal_events_test.rs b/state/tests/journal_events_test.rs index a2b8881..11ca22a 100644 --- a/state/tests/journal_events_test.rs +++ b/state/tests/journal_events_test.rs @@ -80,8 +80,7 @@ fn reopen_with_committed_state_emits_digest_hex() { // emission below has to be distinguished from it by content, not // by presence/absence. `logs_assert` scans the line set. { - let mut storage = - RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); + let mut storage = RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); let resolver = storage.resolver(); let tree = AVLTree::with_resolver(resolver, KEY_LEN, None); let mut prover = BatchAVLProver::new(tree, true); diff --git a/state/tests/storage_tests.rs b/state/tests/storage_tests.rs index a7fc736..0d37cd7 100644 --- a/state/tests/storage_tests.rs +++ b/state/tests/storage_tests.rs @@ -3,9 +3,10 @@ use bytes::Bytes; use enr_state::{AVLTreeParams, CacheSize, RedbAVLStorage, SnapshotReader}; use ergo_avltree_rust::authenticated_tree_ops::AuthenticatedTreeOps; use ergo_avltree_rust::batch_avl_prover::BatchAVLProver; -use ergo_avltree_rust::batch_node::{AVLTree, Blake2b256}; +use ergo_avltree_rust::batch_node::{AVLTree, Blake2b256, Node, NodeId}; use ergo_avltree_rust::operation::{Digest32, KeyValue, Operation}; use ergo_avltree_rust::versioned_avl_storage::VersionedAVLStorage; +use std::rc::Rc; use tempfile::tempdir; const KEY_LEN: usize = 32; @@ -34,7 +35,8 @@ fn make_value(seed: u8, len: usize) -> Bytes { fn setup(keep_versions: u32) -> (RedbAVLStorage, BatchAVLProver, tempfile::TempDir) { let dir = tempdir().unwrap(); let path = dir.path().join("state.redb"); - let mut storage = RedbAVLStorage::open(&path, params(), keep_versions, CacheSize::default()).unwrap(); + let mut storage = + RedbAVLStorage::open(&path, params(), keep_versions, CacheSize::default()).unwrap(); let resolver = storage.resolver(); let tree = AVLTree::with_resolver(resolver, KEY_LEN, None); @@ -407,8 +409,7 @@ fn flush_persists_state_across_reopen() { let expected_version; { - let mut storage = - RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); + let mut storage = RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); let resolver = storage.resolver(); let tree = AVLTree::with_resolver(resolver, KEY_LEN, None); let mut prover = BatchAVLProver::new(tree, true); @@ -473,8 +474,7 @@ fn flush_on_empty_storage_succeeds() { // Flush on a freshly opened storage with no updates must not error. let dir = tempdir().unwrap(); let path = dir.path().join("state.redb"); - let storage = - RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); + let storage = RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); assert!(storage.version().is_none()); storage.flush().unwrap(); } @@ -528,8 +528,7 @@ fn parse_dfs_nodes(data: &[u8], key_length: usize) -> Vec<(Digest32, Vec)> { // Leaf: type(1) + key(key_length) + value_len(4) + value + next_key(key_length) let vlen_offset = pos + 1 + key_length; let value_len = - u32::from_be_bytes(data[vlen_offset..vlen_offset + 4].try_into().unwrap()) - as usize; + u32::from_be_bytes(data[vlen_offset..vlen_offset + 4].try_into().unwrap()) as usize; 1 + key_length + 4 + value_len + key_length }; @@ -583,7 +582,10 @@ fn dump_snapshot_round_trip() { // 5. Verify metadata. assert_eq!(snap.root_hash, expected_root, "root hash mismatch"); - assert_eq!(snap.tree_height, expected_height as u8, "tree height mismatch"); + assert_eq!( + snap.tree_height, expected_height as u8, + "tree height mismatch" + ); // 6. Verify manifest header. assert_eq!(snap.manifest[0], expected_height as u8); @@ -594,7 +596,10 @@ fn dump_snapshot_round_trip() { assert!(!manifest_nodes.is_empty(), "manifest has no nodes"); // First node's label should be the root hash. - assert_eq!(manifest_nodes[0].0, expected_root, "first manifest node is not root"); + assert_eq!( + manifest_nodes[0].0, expected_root, + "first manifest node is not root" + ); // 8. Verify chunks are non-empty. assert!(!snap.chunks.is_empty(), "no chunks produced"); @@ -684,9 +689,57 @@ fn cache_size_percent_returns_fraction_of_ram() { let half = CacheSize::Percent(0.5); let resolved = half.resolve(); // On any machine running these tests, half of RAM should be >128MB. - assert!(resolved > 128 * 1024 * 1024, "half of RAM unexpectedly small: {resolved}"); + assert!( + resolved > 128 * 1024 * 1024, + "half of RAM unexpectedly small: {resolved}" + ); // And less than 1TB, just to catch parse failures returning garbage. - assert!(resolved < 1024 * 1024 * 1024 * 1024, "half of RAM unexpectedly large: {resolved}"); + assert!( + resolved < 1024 * 1024 * 1024 * 1024, + "half of RAM unexpectedly large: {resolved}" + ); +} + +#[test] +fn cache_occupancy_is_reported() { + let dir = tempdir().unwrap(); + let storage = RedbAVLStorage::open( + &dir.path().join("s.redb"), + params(), + 32, + CacheSize::Bytes(8 * 1024 * 1024), + ) + .unwrap(); + assert!( + storage.cache_bytes_used() > 0, + "cache_metrics disabled? used_bytes was 0" + ); +} + +#[test] +fn snapshot_reader_reports_the_same_cache_occupancy() { + // The API's only handle on state is a SnapshotReader, so the figure has to + // be reachable from there — and it must be the same database, not a + // second one that happens to also report a number. + let dir = tempdir().unwrap(); + let storage = RedbAVLStorage::open( + &dir.path().join("s.redb"), + params(), + 32, + CacheSize::Bytes(8 * 1024 * 1024), + ) + .unwrap(); + let reader = storage.snapshot_reader(); + + assert!( + reader.cache_bytes_used() > 0, + "cache_metrics disabled? used_bytes was 0" + ); + assert_eq!( + reader.cache_bytes_used(), + storage.cache_bytes_used(), + "reader and storage disagree — not the same Arc?" + ); } // ── block_height persistence ───────────────────────────────────────── @@ -696,8 +749,7 @@ fn update_persists_block_height() { let dir = tempdir().unwrap(); let path = dir.path().join("state.redb"); { - let mut storage = - RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); + let mut storage = RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); let resolver = storage.resolver(); let tree = AVLTree::with_resolver(resolver, KEY_LEN, None); let mut prover = BatchAVLProver::new(tree, true); @@ -731,8 +783,7 @@ fn update_persists_block_height() { fn rollback_restores_block_height() { let dir = tempdir().unwrap(); let path = dir.path().join("state.redb"); - let mut storage = - RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); + let mut storage = RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); let resolver = storage.resolver(); let tree = AVLTree::with_resolver(resolver, KEY_LEN, None); let mut prover = BatchAVLProver::new(tree, true); @@ -744,7 +795,9 @@ fn rollback_restores_block_height() { value: make_value(1, 64), })) .unwrap(); - storage.update_with_height(&mut prover, vec![], 100).unwrap(); + storage + .update_with_height(&mut prover, vec![], 100) + .unwrap(); let digest_at_100 = storage.version().unwrap(); // Update at height 101. @@ -757,7 +810,9 @@ fn rollback_restores_block_height() { value: make_value(2, 64), })) .unwrap(); - storage.update_with_height(&mut prover, vec![], 101).unwrap(); + storage + .update_with_height(&mut prover, vec![], 101) + .unwrap(); // Update at height 102. prover.base.tree.reset(); @@ -769,7 +824,9 @@ fn rollback_restores_block_height() { value: make_value(3, 64), })) .unwrap(); - storage.update_with_height(&mut prover, vec![], 102).unwrap(); + storage + .update_with_height(&mut prover, vec![], 102) + .unwrap(); assert_eq!(storage.block_height(), Some(102)); @@ -786,8 +843,7 @@ fn load_snapshot_sets_block_height() { let dir = tempdir().unwrap(); let path = dir.path().join("state.redb"); { - let mut storage = - RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); + let mut storage = RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); let resolver = storage.resolver(); let tree = AVLTree::with_resolver(resolver, KEY_LEN, None); @@ -837,8 +893,7 @@ fn crash_simulation_preserves_pre_update_block_height() { // 1. Commit block_height = 42 via the normal update path. let committed_version; { - let mut storage = - RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); + let mut storage = RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); let resolver = storage.resolver(); let tree = AVLTree::with_resolver(resolver, KEY_LEN, None); let mut prover = BatchAVLProver::new(tree, true); @@ -880,8 +935,7 @@ fn block_height_is_none_on_empty_storage() { // Invariant: block_height() returns None iff version() is None. let dir = tempdir().unwrap(); let path = dir.path().join("state.redb"); - let storage = - RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); + let storage = RedbAVLStorage::open(&path, params(), 10, CacheSize::default()).unwrap(); assert!(storage.version().is_none()); assert!(storage.block_height().is_none()); } @@ -925,7 +979,9 @@ fn churn_round(path: &Path, keys: u8, round: u8, value_len: usize, height: u32) })) .unwrap(); } - storage.update_with_height(&mut prover, vec![], height).unwrap(); + storage + .update_with_height(&mut prover, vec![], height) + .unwrap(); storage.flush().unwrap(); } @@ -963,12 +1019,7 @@ fn reachable_node_count(path: &Path) -> u64 { let nodes = txn.open_table(NODES_DEF).unwrap(); let meta = txn.open_table(META_DEF).unwrap(); - let root = meta - .get("top_node_hash") - .unwrap() - .unwrap() - .value() - .to_vec(); + let root = meta.get("top_node_hash").unwrap().unwrap().value().to_vec(); let mut stack = vec![root]; let mut count = 0u64; @@ -1071,7 +1122,10 @@ fn compact_to_reclaims_unreachable_rows() { ); assert_eq!(stats.digest, version, "compaction must preserve the digest"); - assert_eq!(stats.block_height, block_height, "block_height must carry over"); + assert_eq!( + stats.block_height, block_height, + "block_height must carry over" + ); assert_eq!(stats.nodes_written, reachable); assert_eq!(stats.source_bytes, source_before.len() as u64); assert_eq!( @@ -1079,7 +1133,11 @@ fn compact_to_reclaims_unreachable_rows() { reachable, "dest must hold exactly the reachable nodes" ); - assert_eq!(undo_row_count(&dest), 0, "dest must have an empty undo table"); + assert_eq!( + undo_row_count(&dest), + 0, + "dest must have an empty undo table" + ); assert!( stats.dest_bytes < stats.source_bytes, "dest ({}) is not smaller than source ({})", @@ -1184,7 +1242,8 @@ fn compact_to_fails_when_a_reachable_node_is_missing() { write_txn.set_quick_repair(true); { let mut meta = write_txn.open_table(META_DEF).unwrap(); - meta.insert("top_node_hash", [0xABu8; 32].as_slice()).unwrap(); + meta.insert("top_node_hash", [0xABu8; 32].as_slice()) + .unwrap(); } write_txn.commit().unwrap(); } @@ -1247,7 +1306,10 @@ fn compact_to_refuses_an_existing_destination() { std::fs::write(&dest, b"not mine").unwrap(); let err = RedbAVLStorage::compact_to(&src, &dest, None).unwrap_err(); - assert!(err.to_string().contains("already exists"), "unexpected error: {err}"); + assert!( + err.to_string().contains("already exists"), + "unexpected error: {err}" + ); // Refusing to overwrite means refusing to delete, too. assert_eq!(std::fs::read(&dest).unwrap(), b"not mine"); } @@ -1261,7 +1323,10 @@ fn compact_to_rejects_an_empty_source() { let dest = dir.path().join("state.redb.compacted"); let err = RedbAVLStorage::compact_to(&src, &dest, None).unwrap_err(); - assert!(err.to_string().contains("nothing to compact"), "unexpected error: {err}"); + assert!( + err.to_string().contains("nothing to compact"), + "unexpected error: {err}" + ); assert!(!dest.exists()); } @@ -1299,3 +1364,196 @@ fn compaction_stats_are_reported() { // The AVL digest is the 32-byte root label plus the tree height byte. assert_eq!(stats.digest.len(), 33); } + +// ── SnapshotReader: read-only prover access ────────────────────────── +// +// resolver() + root_state() are everything mining needs to stand up a +// read-only prover over the committed tree without reaching the validator. + +/// Storage with an internal root node — a single-key tree roots on a leaf, +/// which has no child handles to compare. +fn storage_with_internal_root() -> (RedbAVLStorage, tempfile::TempDir) { + let (mut storage, mut prover, dir) = setup(10); + for seed in 1..=8u8 { + prover + .perform_one_operation(&Operation::Insert(KeyValue { + key: make_key(seed), + value: make_value(seed, 64), + })) + .unwrap(); + } + storage.update(&mut prover, vec![]).unwrap(); + (storage, dir) +} + +/// Left/right child handles of an internal node, or a failure naming what +/// came back instead. +fn child_handles(node: &Node) -> (NodeId, NodeId) { + match node { + Node::Internal(internal) => (internal.left.clone(), internal.right.clone()), + Node::Leaf(_) => panic!("expected an internal root, got a leaf"), + Node::LabelOnly(_) => panic!("resolver miss: node was not in storage"), + } +} + +#[test] +fn reader_resolver_returns_the_same_node_as_the_storage_resolver() { + let (storage, _dir) = storage_with_internal_root(); + let (root, _) = storage.root_state().expect("no root state"); + + let via_storage = (storage.resolver())(&root); + let via_reader = (storage.snapshot_reader().resolver())(&root); + + let (s_left, s_right) = child_handles(&via_storage); + let (r_left, r_right) = child_handles(&via_reader); + + assert_eq!(s_left.borrow().get_label(), r_left.borrow().get_label()); + assert_eq!(s_right.borrow().get_label(), r_right.borrow().get_label()); +} + +#[test] +fn reader_resolver_hands_out_independent_node_handles() { + // The invariant that fails silently. A reader resolver that shared node + // handles with the validator's prover would let a restored prover walk + // nodes still keyed in another prover's address-keyed map, and emit a + // *different proof for identical tree state* — same digest, different + // bytes. Equal labels above prove it is the same node; distinct + // allocations here prove it is not the same memory. + let (storage, _dir) = storage_with_internal_root(); + let (root, _) = storage.root_state().expect("no root state"); + + let (s_left, s_right) = child_handles(&(storage.resolver())(&root)); + let (r_left, r_right) = child_handles(&(storage.snapshot_reader().resolver())(&root)); + + assert!( + !Rc::ptr_eq(&s_left, &r_left), + "reader and storage resolvers share a node handle" + ); + assert!( + !Rc::ptr_eq(&s_right, &r_right), + "reader and storage resolvers share a node handle" + ); +} + +#[test] +fn reader_resolver_allocates_fresh_handles_per_call() { + // Same guard, one resolver. Caching resolved nodes is the "for + // performance" change that would break the invariant above from the + // inside, and it would keep every existing test green. + let (storage, _dir) = storage_with_internal_root(); + let (root, _) = storage.root_state().expect("no root state"); + let resolver = storage.snapshot_reader().resolver(); + + let (first, _) = child_handles(&resolver(&root)); + let (second, _) = child_handles(&resolver(&root)); + + assert_eq!(first.borrow().get_label(), second.borrow().get_label()); + assert!( + !Rc::ptr_eq(&first, &second), + "resolver is caching node handles between calls" + ); +} + +#[test] +fn reader_resolver_survives_a_miss_with_the_label_intact() { + // A miss must still name the node the caller asked for, or a failed + // lookup loses the one piece of evidence that identifies it. + let (storage, _dir) = storage_with_internal_root(); + let absent: Digest32 = [0xAB; 32]; + + let node = (storage.snapshot_reader().resolver())(&absent); + + match node { + Node::LabelOnly(hdr) => assert_eq!(hdr.label, Some(absent)), + other => panic!("expected LabelOnly for an absent node, got {other:?}"), + } +} + +#[test] +fn reader_root_state_matches_storage_root_state() { + let (storage, _dir) = storage_with_internal_root(); + + assert_eq!( + storage.snapshot_reader().root_state(), + storage.root_state(), + "reader and storage disagree on the committed root" + ); +} + +#[test] +fn reader_root_state_is_none_on_an_empty_tree() { + // A reader that exists has a database, so None means "empty", never + // "stale handle" — there is no liveness check to confuse it with. + let dir = tempdir().unwrap(); + let storage = RedbAVLStorage::open( + &dir.path().join("state.redb"), + params(), + 10, + CacheSize::default(), + ) + .unwrap(); + + assert_eq!(storage.snapshot_reader().root_state(), None); +} + +#[test] +fn reader_root_state_ignores_uncommitted_prover_state() { + // root_state() is the committed root. A prover mid-batch has a newer + // tree in memory; a reader must not see it, or mining would build a + // candidate on a root no peer has. + let (mut storage, mut prover, _dir) = setup(10); + prover + .perform_one_operation(&Operation::Insert(KeyValue { + key: make_key(1), + value: make_value(1, 64), + })) + .unwrap(); + storage.update(&mut prover, vec![]).unwrap(); + prover.base.tree.reset(); + prover.base.changed_nodes_buffer.clear(); + prover.base.changed_nodes_buffer_to_check.clear(); + + let reader = storage.snapshot_reader(); + let committed = reader.root_state().expect("no root state"); + + // Uncommitted: performed on the prover, never handed to update(). + prover + .perform_one_operation(&Operation::Insert(KeyValue { + key: make_key(2), + value: make_value(2, 64), + })) + .unwrap(); + assert_ne!( + prover.digest().unwrap()[..32], + committed.0[..], + "test is not exercising anything — the prover root did not move" + ); + + assert_eq!( + reader.root_state(), + Some(committed), + "reader observed uncommitted prover state" + ); +} + +#[test] +fn reader_resolver_is_read_only() { + // Resolving must not touch the tree, the version chain, or metadata. + let (storage, _dir) = storage_with_internal_root(); + let before_root = storage.root_state(); + let before_version = storage.version(); + let before_targets: Vec<_> = storage.rollback_versions().collect(); + + let resolver = storage.snapshot_reader().resolver(); + let (root, _) = before_root.expect("no root state"); + for _ in 0..16 { + let _ = resolver(&root); + } + + assert_eq!(storage.root_state(), before_root); + assert_eq!(storage.version(), before_version); + assert_eq!( + storage.rollback_versions().collect::>(), + before_targets + ); +} diff --git a/store/Cargo.toml b/store/Cargo.toml index f5d5ea2..4041e5a 100644 --- a/store/Cargo.toml +++ b/store/Cargo.toml @@ -1,12 +1,17 @@ [package] name = "enr-store" -version = "0.1.0" +version.workspace = true edition = "2021" description = "Persistent modifier storage for ergo-node-rust" [dependencies] parking_lot = "0.12" -redb = "4" +# cache_metrics is REQUIRED, not optional tuning: without it cache_stats() +# returns zeros and /debug/memory would confidently report an empty cache for +# the largest consumer during sync. Declared per-crate rather than only at the +# workspace root so `cargo test -p ` — which does not build the root +# crate — still collects them. +redb = { version = "4", features = ["cache_metrics"] } tracing = "0.1" [dev-dependencies] diff --git a/store/src/lib.rs b/store/src/lib.rs index b2c882f..049651c 100644 --- a/store/src/lib.rs +++ b/store/src/lib.rs @@ -31,13 +31,7 @@ pub trait ModifierStore: Send + Sync { /// always go through `put_batch` so the cumulative score is carried /// alongside the data in a single atomic write. Single-header writes /// from the validation pipeline use `put_batch` with a one-element slice. - fn put( - &self, - type_id: u8, - id: &[u8; 32], - height: u32, - data: &[u8], - ) -> Result<(), Self::Error>; + fn put(&self, type_id: u8, id: &[u8; 32], height: u32, data: &[u8]) -> Result<(), Self::Error>; /// Store a batch of modifiers atomically. /// All entries are written in a single transaction — all succeed or none do. @@ -47,38 +41,20 @@ pub trait ModifierStore: Send + Sync { /// `type_id == 101`; `None` for all other modifier types. A /// `type_id == 101` entry with `score == None` is rejected and /// no entries from the batch are written. - fn put_batch( - &self, - entries: &[ModifierBatchEntry], - ) -> Result<(), Self::Error>; + fn put_batch(&self, entries: &[ModifierBatchEntry]) -> Result<(), Self::Error>; /// Retrieve a modifier by type and ID. - fn get( - &self, - type_id: u8, - id: &[u8; 32], - ) -> Result>, Self::Error>; + fn get(&self, type_id: u8, id: &[u8; 32]) -> Result>, Self::Error>; /// Retrieve the modifier ID at a given height for a type. - fn get_id_at( - &self, - type_id: u8, - height: u32, - ) -> Result, Self::Error>; + fn get_id_at(&self, type_id: u8, height: u32) -> Result, Self::Error>; /// Check whether a modifier exists without reading its data. - fn contains( - &self, - type_id: u8, - id: &[u8; 32], - ) -> Result; + fn contains(&self, type_id: u8, id: &[u8; 32]) -> Result; /// Returns the tip (highest height and its modifier ID) for a type. /// None if no modifiers of that type have been stored. - fn tip( - &self, - type_id: u8, - ) -> Result, Self::Error>; + fn tip(&self, type_id: u8) -> Result, Self::Error>; /// Store a header with its fork number and cumulative score. /// Writes to PRIMARY (type_id=101), HEADER_FORKS, HEADER_SCORES. @@ -95,10 +71,7 @@ pub trait ModifierStore: Send + Sync { /// Get all header IDs at a given height across all forks. /// Returns Vec<(header_id, fork_number)> sorted by fork number. - fn header_ids_at_height( - &self, - height: u32, - ) -> Result, Self::Error>; + fn header_ids_at_height(&self, height: u32) -> Result, Self::Error>; /// Get the cumulative score for a header. /// @@ -106,10 +79,7 @@ pub trait ModifierStore: Send + Sync { /// bytes. Populated for **every** header — main-chain and forks /// alike — after the one-shot scores backfill migration runs at /// store open. Returns `None` only when `id` was never written. - fn header_score( - &self, - id: &[u8; 32], - ) -> Result>, Self::Error>; + fn header_score(&self, id: &[u8; 32]) -> Result>, Self::Error>; /// Update only the score for an existing header. /// @@ -121,11 +91,7 @@ pub trait ModifierStore: Send + Sync { /// Returns Err if `id` is not present in PRIMARY (would be a /// caller bug — the migration walks BEST_CHAIN which is consistent /// with PRIMARY). - fn put_header_score( - &self, - id: &[u8; 32], - score: &[u8], - ) -> Result<(), Self::Error>; + fn put_header_score(&self, id: &[u8; 32], score: &[u8]) -> Result<(), Self::Error>; /// Batch variant of [`put_header_score`] — writes many `(id, score)` /// pairs in a single redb write transaction. @@ -145,10 +111,7 @@ pub trait ModifierStore: Send + Sync { /// the migration also leaves substantial redb recovery work for /// the next unclean restart (~6 min open time observed) — /// chunking into ~50_000-entry batches collapses that. - fn put_header_score_batch( - &self, - entries: &[([u8; 32], Vec)], - ) -> Result<(), Self::Error>; + fn put_header_score_batch(&self, entries: &[([u8; 32], Vec)]) -> Result<(), Self::Error>; /// Delete all modifier rows of the given `type_ids` at heights strictly /// less than `horizon`. Atomic in a single redb write transaction. @@ -162,11 +125,7 @@ pub trait ModifierStore: Send + Sync { /// Used by sync's flush_pair to delete non-header section bodies /// (102 BlockTransactions, 104 ADProofs, 108 Extension) older than /// the `blocks_to_keep`-derived horizon. - fn prune_below_height( - &self, - horizon: u32, - type_ids: &[u8], - ) -> Result; + fn prune_below_height(&self, horizon: u32, type_ids: &[u8]) -> Result; /// Returns the lowest height present in HEIGHT_INDEX for `type_id`, /// or None if no entries exist for that type. Mirror of `tip(type_id)`. @@ -174,10 +133,7 @@ pub trait ModifierStore: Send + Sync { /// /// For `type_id == 101` routes to BEST_CHAIN's lowest entry (same /// pattern as `tip(101)` routing to `best_header_tip`). - fn min_height_present( - &self, - type_id: u8, - ) -> Result, Self::Error>; + fn min_height_present(&self, type_id: u8) -> Result, Self::Error>; /// Read a value from the chain_meta table. /// @@ -185,34 +141,21 @@ pub trait ModifierStore: Send + Sync { /// migration sentinels and per-chain-state flags. Keys are /// stable byte strings (see `facts/store.md` for assigned keys); /// values are treated as opaque bytes by the store crate. - fn chain_meta_get( - &self, - key: &[u8], - ) -> Result>, Self::Error>; + fn chain_meta_get(&self, key: &[u8]) -> Result>, Self::Error>; /// Write a value to the chain_meta table. Overwrites any /// previous value at the same key. - fn chain_meta_put( - &self, - key: &[u8], - value: &[u8], - ) -> Result<(), Self::Error>; + fn chain_meta_put(&self, key: &[u8], value: &[u8]) -> Result<(), Self::Error>; /// Remove a value from the chain_meta table. /// /// Idempotent: removing a key that does not exist is `Ok(())`. /// Primary use case is operator-driven re-runs of one-shot /// migrations (delete the migration's sentinel and restart). - fn chain_meta_delete( - &self, - key: &[u8], - ) -> Result<(), Self::Error>; + fn chain_meta_delete(&self, key: &[u8]) -> Result<(), Self::Error>; /// Get the best chain header ID at a height. - fn best_header_at( - &self, - height: u32, - ) -> Result, Self::Error>; + fn best_header_at(&self, height: u32) -> Result, Self::Error>; /// Get the best chain tip (highest height and header ID). fn best_header_tip(&self) -> Result, Self::Error>; @@ -234,10 +177,7 @@ pub trait ModifierStore: Send + Sync { /// best-chain header is recorded at `height`. The returned bytes are /// the caller-provided `data` passed to `put` / `put_batch` / /// `put_header`; this method does not parse them. - fn read_header_at( - &self, - height: u32, - ) -> Result>, Self::Error>; + fn read_header_at(&self, height: u32) -> Result>, Self::Error>; /// Write or overwrite a peer record. /// @@ -246,18 +186,11 @@ pub trait ModifierStore: Send + Sync { /// the store; the p2p crate owns the schema. /// /// Overwrites any prior value at the same address. - fn put_peer( - &self, - addr: SocketAddr, - record: &[u8], - ) -> Result<(), Self::Error>; + fn put_peer(&self, addr: SocketAddr, record: &[u8]) -> Result<(), Self::Error>; /// Remove a peer record. Idempotent: removing an absent address /// is `Ok(())`. - fn delete_peer( - &self, - addr: SocketAddr, - ) -> Result<(), Self::Error>; + fn delete_peer(&self, addr: SocketAddr) -> Result<(), Self::Error>; /// Read every peer record. Single read transaction. Returns a /// `Vec<(addr, record_bytes)>` with no ordering guarantee — caller @@ -266,9 +199,7 @@ pub trait ModifierStore: Send + Sync { /// Rows whose key cannot be decoded as a `SocketAddr` are skipped /// with a `tracing::warn!` rather than aborting the call; the /// store is not the place to nuke the p2p layer over a corrupt row. - fn list_peers( - &self, - ) -> Result)>, Self::Error>; + fn list_peers(&self) -> Result)>, Self::Error>; /// Force a durable commit — fsync all pending writes to disk. /// diff --git a/store/src/redb.rs b/store/src/redb.rs index 811d3e1..885ce6c 100644 --- a/store/src/redb.rs +++ b/store/src/redb.rs @@ -1,10 +1,12 @@ use crate::{ModifierBatchEntry, ModifierStore}; -use ::redb::{Database, Durability, ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition}; +use ::redb::{ + Database, Durability, ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition, +}; +use parking_lot::RwLock; use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::path::Path; use std::time::Instant; -use parking_lot::RwLock; const PRIMARY: TableDefinition<(u8, [u8; 32]), &[u8]> = TableDefinition::new("primary"); const HEIGHT_INDEX: TableDefinition<(u8, u32), [u8; 32]> = TableDefinition::new("height_index"); @@ -189,20 +191,36 @@ impl From<::redb::CommitError> for StoreError { } impl RedbModifierStore { - /// Opens or creates a redb database at the given path. + /// Opens or creates a redb database at the given path with an + /// explicitly sized page cache. + /// + /// `cache_bytes` is handed to `Builder::set_cache_size`, which splits + /// it 90/10 between the read and write caches. There is no default: + /// redb's own is 1 GiB per handle, which nobody chose, no config + /// controlled, and `/debug/memory` could not see — during the + /// 2026-08-11 genesis resync, heap profiling put 89.3% of header-phase + /// growth under `put_batch` → `BtreeMut::insert`. Every caller now has + /// to state a number. + /// + /// A plain byte count, deliberately not `state/`'s `CacheSize` enum: + /// this crate does not depend on `state/` and must not acquire that + /// dependency to share a type. The caller computes the split. /// /// Per-step durations are logged at `info` level on every open /// (`tracing::info!`). Operators can grep for `"store open:"` in /// node logs to diagnose slow startups; the cost is essentially /// free since each step would already be timed by anyone trying /// to debug it. - pub fn new(path: &Path) -> Result { + pub fn new(path: &Path, cache_bytes: usize) -> Result { let t_total = Instant::now(); let t = Instant::now(); - let db = Database::create(path)?; + let db = Database::builder() + .set_cache_size(cache_bytes) + .create(path)?; tracing::info!( elapsed_ms = t.elapsed().as_millis() as u64, + cache_bytes, "store open: Database::create" ); @@ -241,6 +259,28 @@ impl RedbModifierStore { Ok(store) } + /// Current page cache occupancy in bytes (read cache + write cache). + /// + /// Bounded by the `cache_bytes` passed to [`Self::new`]. Requires + /// redb's `cache_metrics` feature — without it this reports 0, which + /// is why the feature is declared in this crate's own `Cargo.toml` + /// and not only at the workspace root. + pub fn cache_bytes_used(&self) -> u64 { + self.db.cache_stats().used_bytes() as u64 + } + + /// Cumulative page cache evictions since open. + /// + /// A rising count means the cache is undersized; without it an + /// undersized cache is indistinguishable from a comfortable one. + /// Occupancy alone cannot tell them apart — it tracks the live + /// working set, not the ceiling, and sits well below `cache_bytes` + /// in both cases (measured ~830 KB under a 15.6 MiB write load at + /// both an 8 MiB and a 1 GiB ceiling; only the eviction count moved). + pub fn cache_evictions(&self) -> u64 { + self.db.cache_stats().evictions() + } + /// Reconstructs the per-type tip cache from HEIGHT_INDEX. /// /// For each non-header modifier type, performs a single backward @@ -370,13 +410,7 @@ impl RedbModifierStore { impl ModifierStore for RedbModifierStore { type Error = StoreError; - fn put( - &self, - type_id: u8, - id: &[u8; 32], - height: u32, - data: &[u8], - ) -> Result<(), Self::Error> { + fn put(&self, type_id: u8, id: &[u8; 32], height: u32, data: &[u8]) -> Result<(), Self::Error> { // Headers must go through put_batch so the cumulative score is // written in the same atomic transaction as the header data. if type_id == 101 { @@ -402,10 +436,7 @@ impl ModifierStore for RedbModifierStore { /// BEST_CHAIN inserts for headers are unconditional: main-chain headers /// authoritatively own their height slot and will overwrite a stale /// entry left by an earlier fork-first arrival or a deep reorg. - fn put_batch( - &self, - entries: &[ModifierBatchEntry], - ) -> Result<(), Self::Error> { + fn put_batch(&self, entries: &[ModifierBatchEntry]) -> Result<(), Self::Error> { // Validate score precondition upfront so that an invalid batch // is rejected without partial writes. (redb would roll back an // un-committed txn anyway, but failing here is cheaper and the @@ -473,9 +504,7 @@ impl ModifierStore for RedbModifierStore { // Update non-header tips cache (HEIGHT_INDEX-backed). let mut tips = self.tips.write(); for (type_id, id, height, _, _) in entries { - if *type_id != 101 - && *height > 0 - && tips.get(type_id).is_none_or(|tip| *height > tip.0) + if *type_id != 101 && *height > 0 && tips.get(type_id).is_none_or(|tip| *height > tip.0) { tips.insert(*type_id, (*height, *id)); } @@ -493,11 +522,7 @@ impl ModifierStore for RedbModifierStore { Ok(()) } - fn get( - &self, - type_id: u8, - id: &[u8; 32], - ) -> Result>, Self::Error> { + fn get(&self, type_id: u8, id: &[u8; 32]) -> Result>, Self::Error> { let read_txn = self.db.begin_read()?; let table = match read_txn.open_table(PRIMARY) { Ok(t) => t, @@ -508,11 +533,7 @@ impl ModifierStore for RedbModifierStore { Ok(value.map(|guard| guard.value().to_vec())) } - fn get_id_at( - &self, - type_id: u8, - height: u32, - ) -> Result, Self::Error> { + fn get_id_at(&self, type_id: u8, height: u32) -> Result, Self::Error> { // Headers (type_id=101) are looked up via BEST_CHAIN, not HEIGHT_INDEX. // HEIGHT_INDEX is the legacy schema for headers and is cleared by // migration on first open. @@ -529,11 +550,7 @@ impl ModifierStore for RedbModifierStore { Ok(value.map(|guard| guard.value())) } - fn contains( - &self, - type_id: u8, - id: &[u8; 32], - ) -> Result { + fn contains(&self, type_id: u8, id: &[u8; 32]) -> Result { let read_txn = self.db.begin_read()?; let table = match read_txn.open_table(PRIMARY) { Ok(t) => t, @@ -543,10 +560,7 @@ impl ModifierStore for RedbModifierStore { Ok(table.get((type_id, *id))?.is_some()) } - fn tip( - &self, - type_id: u8, - ) -> Result, Self::Error> { + fn tip(&self, type_id: u8) -> Result, Self::Error> { // Headers (type_id=101) live in the fork-aware tables; their tip is // tracked separately. Route the lookup so the documented contract // ("highest stored modifier of this type") holds for headers too. @@ -603,10 +617,7 @@ impl ModifierStore for RedbModifierStore { Ok(()) } - fn header_ids_at_height( - &self, - height: u32, - ) -> Result, Self::Error> { + fn header_ids_at_height(&self, height: u32) -> Result, Self::Error> { let read_txn = self.db.begin_read()?; let table = match read_txn.open_table(HEADER_FORKS) { Ok(t) => t, @@ -624,10 +635,7 @@ impl ModifierStore for RedbModifierStore { Ok(results) } - fn header_score( - &self, - id: &[u8; 32], - ) -> Result>, Self::Error> { + fn header_score(&self, id: &[u8; 32]) -> Result>, Self::Error> { let read_txn = self.db.begin_read()?; let table = match read_txn.open_table(HEADER_SCORES) { Ok(t) => t, @@ -638,11 +646,7 @@ impl ModifierStore for RedbModifierStore { Ok(value.map(|guard| guard.value().to_vec())) } - fn put_header_score( - &self, - id: &[u8; 32], - score: &[u8], - ) -> Result<(), Self::Error> { + fn put_header_score(&self, id: &[u8; 32], score: &[u8]) -> Result<(), Self::Error> { // Verify the header exists in PRIMARY before writing. The // scores backfill migration walks BEST_CHAIN — which is // invariant-consistent with PRIMARY — so a missing PRIMARY @@ -676,10 +680,7 @@ impl ModifierStore for RedbModifierStore { Ok(()) } - fn put_header_score_batch( - &self, - entries: &[([u8; 32], Vec)], - ) -> Result<(), Self::Error> { + fn put_header_score_batch(&self, entries: &[([u8; 32], Vec)]) -> Result<(), Self::Error> { // Empty batch is a no-op — skip the txn entirely so we don't // pay for an empty commit or accidentally create the // PRIMARY/HEADER_SCORES tables on a fresh DB. @@ -722,11 +723,7 @@ impl ModifierStore for RedbModifierStore { Ok(()) } - fn prune_below_height( - &self, - horizon: u32, - type_ids: &[u8], - ) -> Result { + fn prune_below_height(&self, horizon: u32, type_ids: &[u8]) -> Result { // Headers (type_id=101) are never pruned. They live in the // fork-aware tables and their retention is governed elsewhere; // the blocks_to_keep horizon applies only to non-header section @@ -791,10 +788,7 @@ impl ModifierStore for RedbModifierStore { Ok(count) } - fn min_height_present( - &self, - type_id: u8, - ) -> Result, Self::Error> { + fn min_height_present(&self, type_id: u8) -> Result, Self::Error> { // Headers (type_id=101) live in the fork-aware tables; the // lowest height is BEST_CHAIN's first entry. Mirrors how // tip(101) routes to best_header_tip. @@ -826,10 +820,7 @@ impl ModifierStore for RedbModifierStore { } } - fn chain_meta_get( - &self, - key: &[u8], - ) -> Result>, Self::Error> { + fn chain_meta_get(&self, key: &[u8]) -> Result>, Self::Error> { let read_txn = self.db.begin_read()?; let table = match read_txn.open_table(CHAIN_META) { Ok(t) => t, @@ -840,11 +831,7 @@ impl ModifierStore for RedbModifierStore { Ok(value.map(|guard| guard.value().to_vec())) } - fn chain_meta_put( - &self, - key: &[u8], - value: &[u8], - ) -> Result<(), Self::Error> { + fn chain_meta_put(&self, key: &[u8], value: &[u8]) -> Result<(), Self::Error> { let mut write_txn = self.db.begin_write()?; write_txn .set_durability(Durability::None) @@ -858,10 +845,7 @@ impl ModifierStore for RedbModifierStore { Ok(()) } - fn chain_meta_delete( - &self, - key: &[u8], - ) -> Result<(), Self::Error> { + fn chain_meta_delete(&self, key: &[u8]) -> Result<(), Self::Error> { let mut write_txn = self.db.begin_write()?; write_txn .set_durability(Durability::None) @@ -881,10 +865,7 @@ impl ModifierStore for RedbModifierStore { Ok(()) } - fn best_header_at( - &self, - height: u32, - ) -> Result, Self::Error> { + fn best_header_at(&self, height: u32) -> Result, Self::Error> { let read_txn = self.db.begin_read()?; let table = match read_txn.open_table(BEST_CHAIN) { Ok(t) => t, @@ -915,10 +896,7 @@ impl ModifierStore for RedbModifierStore { Ok(result) } - fn read_header_at( - &self, - height: u32, - ) -> Result>, Self::Error> { + fn read_header_at(&self, height: u32) -> Result>, Self::Error> { let read_txn = self.db.begin_read()?; let best_chain = match read_txn.open_table(BEST_CHAIN) { @@ -936,14 +914,12 @@ impl ModifierStore for RedbModifierStore { Err(::redb::TableError::TableDoesNotExist(_)) => return Ok(None), Err(e) => return Err(StoreError::Table(e)), }; - Ok(primary.get((101u8, id))?.map(|guard| guard.value().to_vec())) + Ok(primary + .get((101u8, id))? + .map(|guard| guard.value().to_vec())) } - fn put_peer( - &self, - addr: SocketAddr, - record: &[u8], - ) -> Result<(), Self::Error> { + fn put_peer(&self, addr: SocketAddr, record: &[u8]) -> Result<(), Self::Error> { let key = encode_addr(addr); let mut write_txn = self.db.begin_write()?; write_txn @@ -958,10 +934,7 @@ impl ModifierStore for RedbModifierStore { Ok(()) } - fn delete_peer( - &self, - addr: SocketAddr, - ) -> Result<(), Self::Error> { + fn delete_peer(&self, addr: SocketAddr) -> Result<(), Self::Error> { let key = encode_addr(addr); let mut write_txn = self.db.begin_write()?; write_txn @@ -979,9 +952,7 @@ impl ModifierStore for RedbModifierStore { Ok(()) } - fn list_peers( - &self, - ) -> Result)>, Self::Error> { + fn list_peers(&self) -> Result)>, Self::Error> { let read_txn = self.db.begin_read()?; let table = match read_txn.open_table(PEER_DB) { Ok(t) => t, @@ -1024,9 +995,15 @@ mod tests { use super::*; use tempfile::TempDir; + /// Page cache size for tests that don't care about the number. + /// Small on purpose: these stores hold a handful of rows, and the + /// constructor no longer has a default to fall back on. + const TEST_CACHE_BYTES: usize = 8 * 1024 * 1024; + fn test_store() -> (RedbModifierStore, TempDir) { let dir = TempDir::new().unwrap(); - let store = RedbModifierStore::new(&dir.path().join("test.redb")).unwrap(); + let store = + RedbModifierStore::new(&dir.path().join("test.redb"), TEST_CACHE_BYTES).unwrap(); (store, dir) } @@ -1047,7 +1024,9 @@ mod tests { let id = test_id(1); let data = b"hello world"; - store.put_batch(&[(101, id, 1, data.to_vec(), s())]).unwrap(); + store + .put_batch(&[(101, id, 1, data.to_vec(), s())]) + .unwrap(); let result = store.get(101, &id).unwrap(); assert_eq!(result, Some(data.to_vec())); } @@ -1063,9 +1042,18 @@ mod tests { store.put_batch(&entries).unwrap(); - assert_eq!(store.get(101, &test_id(1)).unwrap(), Some(b"data1".to_vec())); - assert_eq!(store.get(101, &test_id(2)).unwrap(), Some(b"data2".to_vec())); - assert_eq!(store.get(102, &test_id(3)).unwrap(), Some(b"data3".to_vec())); + assert_eq!( + store.get(101, &test_id(1)).unwrap(), + Some(b"data1".to_vec()) + ); + assert_eq!( + store.get(101, &test_id(2)).unwrap(), + Some(b"data2".to_vec()) + ); + assert_eq!( + store.get(102, &test_id(3)).unwrap(), + Some(b"data3".to_vec()) + ); } #[test] @@ -1119,8 +1107,12 @@ mod tests { let id = test_id(1); let data = b"same data"; - store.put_batch(&[(101, id, 1, data.to_vec(), s())]).unwrap(); - store.put_batch(&[(101, id, 1, data.to_vec(), s())]).unwrap(); + store + .put_batch(&[(101, id, 1, data.to_vec(), s())]) + .unwrap(); + store + .put_batch(&[(101, id, 1, data.to_vec(), s())]) + .unwrap(); assert_eq!(store.get(101, &id).unwrap(), Some(data.to_vec())); } @@ -1253,7 +1245,7 @@ mod tests { // Session 1: heights 1..=100 { - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); let entries: Vec = (1..=100u32) .map(|h| (101, test_id(h as u8), h, format!("h{h}").into_bytes(), s())) .collect(); @@ -1262,7 +1254,7 @@ mod tests { // Session 2: restart, write heights 101..=200 { - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); assert_eq!( store.best_header_tip().unwrap(), Some((100, test_id(100))), @@ -1276,7 +1268,7 @@ mod tests { // Session 3: restart, verify BEST_CHAIN is dense 1..=200 { - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); assert_eq!( store.best_header_tip().unwrap(), Some((200, test_id(200))), @@ -1309,7 +1301,7 @@ mod tests { // Phase 1: fresh start, sync 100 main-chain headers via put_batch. { - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); let entries: Vec = (1..=100u32) .map(|h| (101, test_id(h as u8), h, format!("h{h}").into_bytes(), s())) .collect(); @@ -1318,7 +1310,7 @@ mod tests { // Phase 2: restart, fork header arrives, then more main-chain via put_batch. { - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); assert_eq!(store.best_header_tip().unwrap(), Some((100, test_id(100)))); // A fork header arrives at height 50 (a height that already has @@ -1341,7 +1333,7 @@ mod tests { // Phase 3: restart, verify the new heights reached BEST_CHAIN. { - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); assert_eq!( store.best_header_tip().unwrap(), Some((200, test_id(200))), @@ -1392,7 +1384,9 @@ mod tests { // "BEST_CHAIN[100] is empty, so insert me." Now BEST_CHAIN[100] points // at the FORK header, not the main-chain header. let fork_100 = test_id(0xF0); - store.put_header(&fork_100, 100, 1, &[0x99], b"fork100").unwrap(); + store + .put_header(&fork_100, 100, 1, &[0x99], b"fork100") + .unwrap(); // The main-chain header is the rightful occupant of best_header_at(100). let best = store.best_header_at(100).unwrap(); @@ -1418,19 +1412,23 @@ mod tests { for h in 1..=3u32 { let id = test_id(h as u8); - primary.insert((101u8, id), format!("data{h}").as_bytes()).unwrap(); + primary + .insert((101u8, id), format!("data{h}").as_bytes()) + .unwrap(); height_idx.insert((101u8, h), id).unwrap(); } // Also insert a non-header entry (type 102) that should NOT migrate. let other_id = test_id(0xFF); - primary.insert((102u8, other_id), b"other".as_slice()).unwrap(); + primary + .insert((102u8, other_id), b"other".as_slice()) + .unwrap(); height_idx.insert((102u8, 1), other_id).unwrap(); } write_txn.commit().unwrap(); } // Phase 2: open with RedbModifierStore — triggers migration. - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); // New tables populated for h in 1..=3u32 { @@ -1469,7 +1467,10 @@ mod tests { assert_eq!(store.get_id_at(102, 1).unwrap(), Some(test_id(0xFF))); // PRIMARY data still accessible - assert_eq!(store.get(101, &test_id(1)).unwrap(), Some(b"data1".to_vec())); + assert_eq!( + store.get(101, &test_id(1)).unwrap(), + Some(b"data1".to_vec()) + ); } // --- read_header_at: height-indexed best-chain header bytes --- @@ -1671,7 +1672,10 @@ mod tests { store.put_header_score_batch(&updates).unwrap(); assert_eq!(store.header_score(&id1).unwrap(), Some(vec![0xAA, 0xAA])); - assert_eq!(store.header_score(&id2).unwrap(), Some(vec![0xBB, 0xBB, 0xBB])); + assert_eq!( + store.header_score(&id2).unwrap(), + Some(vec![0xBB, 0xBB, 0xBB]) + ); assert_eq!(store.header_score(&id3).unwrap(), Some(vec![0xCC])); } @@ -1735,13 +1739,13 @@ mod tests { // Second batch overwrites with new values. store - .put_header_score_batch(&[ - (id1, vec![0xAA, 0xBB]), - (id2, vec![0xCC, 0xDD, 0xEE]), - ]) + .put_header_score_batch(&[(id1, vec![0xAA, 0xBB]), (id2, vec![0xCC, 0xDD, 0xEE])]) .unwrap(); assert_eq!(store.header_score(&id1).unwrap(), Some(vec![0xAA, 0xBB])); - assert_eq!(store.header_score(&id2).unwrap(), Some(vec![0xCC, 0xDD, 0xEE])); + assert_eq!( + store.header_score(&id2).unwrap(), + Some(vec![0xCC, 0xDD, 0xEE]) + ); } #[test] @@ -1770,8 +1774,7 @@ mod tests { store.put_batch(&entries).unwrap(); let got = store.best_chain_entries().unwrap(); - let expected: Vec<(u32, [u8; 32])> = - (1..=5u32).map(|h| (h, test_id(h as u8))).collect(); + let expected: Vec<(u32, [u8; 32])> = (1..=5u32).map(|h| (h, test_id(h as u8))).collect(); assert_eq!(got, expected); } @@ -1803,9 +1806,7 @@ mod tests { // Sentinel-style write used by the v0.5.0 scores backfill migration. assert_eq!(store.chain_meta_get(b"scores_migrated_v1").unwrap(), None); - store - .chain_meta_put(b"scores_migrated_v1", &[1u8]) - .unwrap(); + store.chain_meta_put(b"scores_migrated_v1", &[1u8]).unwrap(); assert_eq!( store.chain_meta_get(b"scores_migrated_v1").unwrap(), Some(vec![1u8]) @@ -1826,7 +1827,10 @@ mod tests { store.chain_meta_put(b"keep", b"value").unwrap(); store.chain_meta_put(b"toss", b"value").unwrap(); store.chain_meta_delete(b"toss").unwrap(); - assert_eq!(store.chain_meta_get(b"keep").unwrap(), Some(b"value".to_vec())); + assert_eq!( + store.chain_meta_get(b"keep").unwrap(), + Some(b"value".to_vec()) + ); assert_eq!(store.chain_meta_get(b"toss").unwrap(), None); } @@ -1853,10 +1857,19 @@ mod tests { } fn v6_addr(segments: [u16; 8], port: u16) -> SocketAddr { - SocketAddr::new(IpAddr::V6(Ipv6Addr::new( - segments[0], segments[1], segments[2], segments[3], - segments[4], segments[5], segments[6], segments[7], - )), port) + SocketAddr::new( + IpAddr::V6(Ipv6Addr::new( + segments[0], + segments[1], + segments[2], + segments[3], + segments[4], + segments[5], + segments[6], + segments[7], + )), + port, + ) } #[test] @@ -1954,7 +1967,7 @@ mod tests { write_txn.commit().unwrap(); } - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); let listed = store.list_peers().unwrap(); assert_eq!(listed, vec![(good, b"good-record".to_vec())]); } @@ -1993,7 +2006,7 @@ mod tests { // Phase 1 — exercise every write path that should carry the // quick-repair flag. { - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); store .put_batch(&[(101, test_id(1), 1, b"h1".to_vec(), Some(vec![0x01]))]) .unwrap(); @@ -2032,19 +2045,10 @@ mod tests { // Phase 3 — reopen via the store and verify every write round- // trips. Catches the case where set_quick_repair flips some // bit redb doesn't expect at our use scale. - let store = RedbModifierStore::new(&path).unwrap(); - assert_eq!( - store.get(101, &test_id(1)).unwrap(), - Some(b"h1".to_vec()) - ); - assert_eq!( - store.get(102, &test_id(2)).unwrap(), - Some(b"bt".to_vec()) - ); - assert_eq!( - store.get(101, &test_id(3)).unwrap(), - Some(b"fork".to_vec()) - ); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); + assert_eq!(store.get(101, &test_id(1)).unwrap(), Some(b"h1".to_vec())); + assert_eq!(store.get(102, &test_id(2)).unwrap(), Some(b"bt".to_vec())); + assert_eq!(store.get(101, &test_id(3)).unwrap(), Some(b"fork".to_vec())); // Last score-batch write wins. assert_eq!(store.header_score(&test_id(1)).unwrap(), Some(vec![0xBB])); // chain_meta_delete after chain_meta_put leaves the key absent. @@ -2063,7 +2067,7 @@ mod tests { // across all three non-header types. Use put_batch so the // HEIGHT_INDEX rows exist exactly as they would in a real run. { - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); // Non-header entries — score is None for non-header types. let entries: Vec = vec![ (102, test_id(1), 10, b"bt-low".to_vec(), None), @@ -2084,7 +2088,7 @@ mod tests { // Session 2: reopen. `load_tips` runs and rebuilds the cache // from HEIGHT_INDEX via the per-type backward range scan. { - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); assert_eq!(store.tip(102).unwrap(), Some((50, test_id(2)))); assert_eq!(store.tip(104).unwrap(), Some((99, test_id(5)))); assert_eq!(store.tip(108).unwrap(), Some((1, test_id(6)))); @@ -2114,7 +2118,9 @@ mod tests { let mut height_idx = write_txn.open_table(HEIGHT_INDEX).unwrap(); for h in 1..=5u32 { let id = test_id(h as u8); - primary.insert((101u8, id), format!("h{h}").as_bytes()).unwrap(); + primary + .insert((101u8, id), format!("h{h}").as_bytes()) + .unwrap(); height_idx.insert((101u8, h), id).unwrap(); } // Also drop a non-header at height 20 so load_tips has @@ -2126,7 +2132,7 @@ mod tests { write_txn.commit().unwrap(); } - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); // Header tip comes from BEST_CHAIN via the legacy migration. assert_eq!(store.tip(101).unwrap(), Some((5, test_id(5)))); @@ -2179,7 +2185,7 @@ mod tests { // Build the store. { - let store = RedbModifierStore::new(&path).unwrap(); + let store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); for type_id in [102u8, 104, 108] { let entries: Vec = (1..=PER_TYPE) .map(|h| { @@ -2226,7 +2232,7 @@ mod tests { // tracing instrumentation logs each step's duration as well; // here we capture only the wall-clock for the assertion. let t = Instant::now(); - let _store = RedbModifierStore::new(&path).unwrap(); + let _store = RedbModifierStore::new(&path, TEST_CACHE_BYTES).unwrap(); let full_open_ms = t.elapsed().as_millis(); eprintln!( @@ -2320,7 +2326,13 @@ mod tests { id[0] = type_id; id[1..5].copy_from_slice(&height.to_be_bytes()); store - .put_batch(&[(type_id, id, height, format!("d{type_id}_{height}").into_bytes(), None)]) + .put_batch(&[( + type_id, + id, + height, + format!("d{type_id}_{height}").into_bytes(), + None, + )]) .unwrap(); id } @@ -2499,9 +2511,69 @@ mod tests { // Sanity: an out-of-range fork header at height 5 via put_header // (fork=0, first-arrival) lowers the BEST_CHAIN minimum. - store - .put_header(&test_id(5), 5, 0, &[0x05], b"h5") - .unwrap(); + store.put_header(&test_id(5), 5, 0, &[0x05], b"h5").unwrap(); assert_eq!(store.min_height_present(101).unwrap(), Some(5)); } + + // --- Page cache sizing + occupancy reporting (facts/store.md + // § "Page cache (added 2026-08-12)"). --- + + /// Writes ~15.6 MiB of headers through `put_batch` and returns + /// `(used_bytes, evictions)` for a store opened with `cache_bytes`. + /// + /// 40 commits rather than 4000 single-entry ones: the byte volume is + /// what pressures the cache, and the transaction overhead is what + /// makes a test slow. This shape runs in ~450ms. + fn cache_probe(cache_bytes: usize) -> (u64, u64) { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("m.redb"); + let store = RedbModifierStore::new(&path, cache_bytes).unwrap(); + + let mut height = 0u32; + for _ in 0..40 { + let mut entries: Vec = Vec::with_capacity(100); + for _ in 0..100 { + height += 1; + let mut id = [0u8; 32]; + id[..4].copy_from_slice(&height.to_be_bytes()); + entries.push((101, id, height, vec![7u8; 4096], s())); + } + store.put_batch(&entries).unwrap(); + } + + (store.cache_bytes_used(), store.cache_evictions()) + } + + /// Both accessors report, and the configured ceiling is actually in + /// force — an 8 MiB cache evicts under a ~15.6 MiB write load while + /// redb's 1 GiB default does not. + /// + /// Note what is *not* asserted: an upper bound on `used_bytes`. + /// Occupancy is governed by the live working set, not the ceiling — + /// measured at ~830 KB for 8 MiB and ~905 KB for 1 GiB under this + /// same load. Any "used <= N" bound large enough to hold for a + /// correct store also holds for one that ignores `cache_bytes` + /// entirely, so it would assert nothing. Evictions are the only + /// observable that responds to the parameter; don't swap them back + /// out for an occupancy ceiling. + #[test] + fn cache_size_is_honoured_and_stats_reported() { + let (used, evictions) = cache_probe(8 * 1024 * 1024); + assert!(used > 0, "cache_metrics disabled? used_bytes was 0"); + assert!( + evictions > 0, + "8 MiB cache took ~15.6 MiB of writes without evicting — \ + cache_bytes not reaching Builder::set_cache_size? evictions={evictions}" + ); + + // Control: same load, redb's default ceiling. If this ever starts + // evicting, the load outgrew the default rather than the store + // regressing, and the assertion above stops meaning anything. + let (_, default_evictions) = cache_probe(1024 * 1024 * 1024); + assert_eq!( + default_evictions, 0, + "control load now evicts at 1 GiB too — the 8 MiB assertion \ + no longer discriminates and this test needs re-tuning" + ); + } } diff --git a/sync/Cargo.toml b/sync/Cargo.toml index 4e28346..a9c576c 100644 --- a/sync/Cargo.toml +++ b/sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergo-sync" -version = "0.1.0" +version.workspace = true edition = "2021" license = "MIT" description = "Chain sync state machine for the Ergo Rust node" @@ -9,8 +9,6 @@ description = "Chain sync state machine for the Ergo Rust node" enr-p2p = { path = "../p2p" } enr-chain = { path = "../chain" } ergo-validation = { path = "../validation" } -crossbeam-channel = "0.5" -rayon = "1" blake2 = "0.10" redb = "4" thiserror = "2" diff --git a/sync/src/apply_state_error.rs b/sync/src/apply_state_error.rs index fb12a7e..fa3091c 100644 --- a/sync/src/apply_state_error.rs +++ b/sync/src/apply_state_error.rs @@ -1,39 +1,92 @@ -//! Classification of `apply_state` error strings for the -//! `validation_stuck` contract event. +//! Classification of `apply_state` errors for the `validation_stuck` +//! contract event. //! //! When the validation sweep's frontier wedges on an `apply_state` //! failure, [`crate::sweep_backoff`] emits the contract //! `validation_stuck` WARN event (per `facts/journal-events.md` //! § "Validation and sync"). That event's `error_kind` / `missing_key` //! fields name the failure mode. The sweep — which caught the error — -//! turns the error's `Display` string into those fields via +//! turns the [`ValidationError`] into those fields via //! [`classify_apply_state_error`] and hands the result to the backoff. //! //! Stall *detection* and emission live in the backoff; this module owns -//! only the `apply_state`-specific string parsing, so the backoff stays +//! only the `apply_state`-specific classification, so the backoff stays //! mode-agnostic. +//! +//! ## Classify on the variant, never on the Display string +//! +//! Until v0.8.0 this took a `&str` and grepped the whole rendered error +//! for `"does not exist"`, while its caller held the typed +//! [`ValidationError`] and stringified it one line earlier. **Nothing +//! referenced the variant.** So when deferred evaluation was removed, the +//! script-failure kind stopped being produced and nothing failed to +//! compile — the same silent-drift shape as an `if let` that swallows a +//! new variant. A string-keyed classifier cannot be broken by a change to +//! the errors it classifies, which sounds like robustness and is the +//! opposite: it means the compiler has nothing to tell you. +//! +//! The one string parse that remains is *inside* a matched variant, where +//! the AVL key genuinely arrives as prose from the tree layer. That parse +//! is scoped to the variant's own payload, not to the rendered whole, so +//! it can no longer misfire on an unrelated variant whose `reason` text +//! happens to contain the phrase. + +use ergo_validation::ValidationError; -/// Classify an `apply_state` error Display string. +/// Classify an `apply_state` error into the `validation_stuck` +/// `error_kind` / `missing_key` fields. /// -/// The AVL prover wraps its errors via `anyhow!`; the surface form of -/// the missing-key case is `Key b"" does not exists` -/// (typo preserved upstream — accept both spellings defensively). +/// Returns `(error_kind, missing_key_hex)`. The `error_kind` domain is +/// pinned by `facts/journal-events.md` at contract 2.0: /// -/// Returns `(error_kind, missing_key_hex)`. `error_kind` is one of -/// `"missing_key"` or `"other"`. `missing_key_hex` is `Some(_)` only -/// when `error_kind == "missing_key"` AND the byte-string literal -/// decoded cleanly to exactly 32 bytes. -pub fn classify_apply_state_error(display: &str) -> (&'static str, Option) { - if display.contains("does not exist") { - ("missing_key", extract_missing_key_hex(display)) - } else { - ("other", None) +/// | Kind | When | +/// |---|---| +/// | `missing_key` | the AVL tree lacks a key the block spends | +/// | `transaction_invalid` | [`ValidationError::TransactionInvalid`] | +/// | `other` | everything else | +/// +/// `transaction_invalid` is **not** a rename of the retired `script_eval`. +/// That value named the deferred eval-failure *path*; this one names the +/// error *variant*, which covers a failed script and equally a failed ERG +/// or token preservation check. The distinction the Doctor needs is "is +/// the state store damaged, or does this node keep refusing a block the +/// network accepted?" — not which internal stage noticed. +/// +/// `missing_key_hex` is `Some(_)` only when `error_kind == "missing_key"` +/// AND the byte-string literal decoded cleanly to exactly 32 bytes. +pub fn classify_apply_state_error(err: &ValidationError) -> (&'static str, Option) { + match err { + ValidationError::TransactionInvalid { .. } => ("transaction_invalid", None), + + // The two channels through which `ergo_avltree_rust` surfaces a + // missing key. Both wrap the same `anyhow!("Key {:?} does not + // exists", …)` raised in the shared `operation.rs`, reached from + // the persistent prover in UTXO mode and from the batch verifier + // in digest mode. Matching only the UTXO one would drop + // `missing_key` on every digest-mode node — silently, which is + // the failure this rewrite exists to prevent. + ValidationError::StateOperationFailed(reason) + | ValidationError::ProofVerificationFailed(reason) + if reason.contains("does not exist") => + { + ("missing_key", extract_missing_key_hex(reason)) + } + + // `other` is the contract's explicit catch-all, so a variant added + // to `ValidationError` landing here is the specified behaviour and + // not an unhandled case. Widening the domain is a contract change + // (consumers parse this field) and belongs to the main session. + _ => ("other", None), } } -/// Best-effort extraction of the 32-byte key from a -/// `Key b"" does not exists` error string. Returns None if -/// the format doesn't match or the decoded byte string isn't 32 bytes. +/// Best-effort extraction of the 32-byte key from an AVL layer +/// `Key b"" does not exists` message (typo preserved upstream — +/// the marker match stops before it, so both spellings are accepted). +/// +/// Takes the matched variant's payload, not the rendered error. Returns +/// None if the format doesn't match or the decoded byte string isn't 32 +/// bytes. fn extract_missing_key_hex(s: &str) -> Option { const MARKER: &str = "Key b\""; let after_marker = s.find(MARKER)? + MARKER.len(); @@ -64,12 +117,30 @@ fn parse_byte_string_literal(s: &str) -> Option> { return None; } match bytes[i + 1] { - b'n' => { out.push(b'\n'); i += 2; } - b'r' => { out.push(b'\r'); i += 2; } - b't' => { out.push(b'\t'); i += 2; } - b'0' => { out.push(0); i += 2; } - b'\\' => { out.push(b'\\'); i += 2; } - b'"' => { out.push(b'"'); i += 2; } + b'n' => { + out.push(b'\n'); + i += 2; + } + b'r' => { + out.push(b'\r'); + i += 2; + } + b't' => { + out.push(b'\t'); + i += 2; + } + b'0' => { + out.push(0); + i += 2; + } + b'\\' => { + out.push(b'\\'); + i += 2; + } + b'"' => { + out.push(b'"'); + i += 2; + } b'x' => { if i + 3 >= bytes.len() { return None; @@ -89,14 +160,53 @@ fn parse_byte_string_literal(s: &str) -> Option> { mod tests { use super::*; - /// The exact apply_state error the sweep wedge produced. It does NOT - /// contain "does not exist", so it classifies as `error_kind = "other"`. - const HEIGHT_MISMATCH: &str = "unexpected block height: expected 2666, got 2668"; - #[test] fn height_mismatch_classifies_as_other_with_no_key() { - let (kind, key) = classify_apply_state_error(HEIGHT_MISMATCH); + // The exact apply_state error the sweep wedge produced. Its own + // variant carries no prose at all, so there is nothing to grep + // even if someone were inclined to. + let err = ValidationError::HeightMismatch { + expected: 2666, + got: 2668, + }; + let (kind, key) = classify_apply_state_error(&err); + assert_eq!(kind, "other"); + assert!(key.is_none()); + } + + #[test] + fn transaction_invalid_classifies_on_the_variant() { + // Not keyed on the reason text: a preservation failure and a + // script failure are the same fact to the consumer, and neither + // renders a phrase this classifier looks for. + let err = ValidationError::TransactionInvalid { + index: 3, + reason: "ERG preservation failed".to_string(), + }; + let (kind, key) = classify_apply_state_error(&err); + assert_eq!(kind, "transaction_invalid"); + assert!(key.is_none()); + } + + #[test] + fn state_operation_failure_without_the_marker_is_other() { + let err = ValidationError::StateOperationFailed("persist failed: disk full".to_string()); + let (kind, key) = classify_apply_state_error(&err); assert_eq!(kind, "other"); assert!(key.is_none()); } + + #[test] + fn transaction_invalid_wins_over_a_reason_that_mentions_the_marker() { + // The old string-grep classified on the rendered whole, so a + // transaction rejection whose reason happened to say "does not + // exist" would have come back `missing_key`. Variant-first + // ordering makes that impossible. + let err = ValidationError::TransactionInvalid { + index: 0, + reason: "input box does not exist in the proof".to_string(), + }; + let (kind, _) = classify_apply_state_error(&err); + assert_eq!(kind, "transaction_invalid"); + } } diff --git a/sync/src/catchup_progress.rs b/sync/src/catchup_progress.rs new file mode 100644 index 0000000..c22f21c --- /dev/null +++ b/sync/src/catchup_progress.rs @@ -0,0 +1,313 @@ +//! Periodic heap/height record emitted while the node is catching up. +//! +//! What survives of `eval_backlog.rs`. That module reported the depth of the +//! deferred script-eval queue against the 2026-08-12 field OOM — a 4-thread +//! 1.8 GHz node killed mid-sweep at **anon-rss 10.62 GiB, file-rss 2.4 MB**, +//! heap rather than page cache, after 26h of steady cadence. Four of its six +//! fields (`evals_in_flight`, `eval_bytes_in_flight`, `script_verified_height`, +//! `eval_lag`) described that queue. The queue is gone in v0.8.0 — +//! `apply_state` evaluates before it persists — so those four describe nothing. +//! +//! The two that remain describe the sweep itself and are the pair an operator +//! actually reads together: how far state has been applied, and what the +//! allocator is holding while it gets there. Nothing else emits a heap reading +//! on a fixed grid during catch-up; `block applied` fires per block and carries +//! no heap, and the sweep summary fires twice per sweep. +//! +//! See `../facts/sync.md` § "Catch-up progress instrumentation". +//! +//! ## Policy +//! +//! Unchanged from the record this replaces, because none of it was about the +//! queue: +//! +//! - **Time-based, not per-block.** Through the empty early chain the sweep +//! clears ~192 blocks/second; a per-block record is unreadable. The gate is +//! [`REPORT_INTERVAL`] of elapsed wall time. +//! - **Catch-up only.** At tip `sweep_size <= 1` and the record would be one +//! line per block interval forever. A tip sweep is suppressed *without* +//! consuming the interval, so the first catch-up sweep after one still +//! reports. +//! - **Does not perturb what it measures.** The heap probe is a closure called +//! only once the gate has passed — never per block — and the record +//! allocates nothing beyond itself. + +use tokio::time::{Duration, Instant}; + +/// Elapsed wall time between records during catch-up. +/// +/// Matches `SyncConfig::delivery_check_interval`, the cadence at which the +/// rest of the sync machine's periodic work already runs, so an operator +/// reading the journal sees this on the same grid as delivery activity. Cheap +/// relative to what it sits beside: catch-up emits `block applied` at INFO +/// *per block*, so this record is a rounding error in the journal. +const REPORT_INTERVAL: Duration = Duration::from_secs(5); + +/// Interval gate for the catch-up progress record. +/// +/// `Default` is "never reported yet", which makes the first eligible block of +/// the first catch-up sweep emit immediately — a baseline record at the start +/// of the run rather than 5 seconds into it. In-memory; a restart legitimately +/// re-baselines. +#[derive(Debug, Default, Clone)] +pub(crate) struct CatchUpProgressReporter { + last_emit: Option, +} + +impl CatchUpProgressReporter { + /// Emit a progress record if this is a catch-up sweep and + /// [`REPORT_INTERVAL`] has elapsed since the last one. Returns whether it + /// emitted. + /// + /// `state_applied_height` MUST be the validator's own `validated_height()`, + /// not `HeaderSync::state_applied_height` — that struct field is a cache + /// reconciled at sweep *end*, so mid-sweep it is frozen at the pre-sweep + /// tip and this record would report a height the node passed some time ago. + /// + /// `heap_probe` is called only when the record actually fires — it is + /// `SyncConfig::flush_probe`, a `mallctl` round-trip into jemalloc, and + /// running it per block would be exactly the kind of overhead an instrument + /// has no business adding. Returns `None` when no probe is wired (the build + /// has no jemalloc feature), in which case the field is absent. + pub(crate) fn maybe_emit( + &mut self, + now: Instant, + sweep_size: u32, + state_applied_height: u32, + heap_probe: impl FnOnce() -> Option, + ) -> bool { + if sweep_size <= 1 { + // Not catch-up. Returns before touching `last_emit`, so a dip to + // tip does not consume the interval. + return false; + } + if let Some(last) = self.last_emit { + // Saturating: a non-monotonic clock reading must not panic here. + if now.saturating_duration_since(last) < REPORT_INTERVAL { + return false; + } + } + self.last_emit = Some(now); + emit(state_applied_height, heap_probe()); + true + } +} + +/// Emit the record. `jemalloc_allocated` is omitted entirely when no probe is +/// wired, matching how `validation_stuck` handles its optional `missing_key` — +/// an absent field rather than a rendered `None`. +fn emit(state_applied_height: u32, jemalloc_allocated: Option) { + match jemalloc_allocated { + Some(bytes) => tracing::info!( + state_applied_height = state_applied_height as u64, + jemalloc_allocated = bytes, + "catch-up progress" + ), + None => tracing::info!( + state_applied_height = state_applied_height as u64, + "catch-up progress" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::capture; + use std::cell::Cell; + + fn now() -> Instant { + Instant::from_std(std::time::Instant::now()) + } + + /// A catch-up sweep: anything above the tip's single block. + const CATCH_UP: u32 = 192; + + // ---- the interval gate ---- + + #[test] + fn first_catch_up_report_emits_immediately() { + // A baseline record at the start of the run, not one interval into it. + let mut r = CatchUpProgressReporter::default(); + let output = capture(|| { + assert!( + r.maybe_emit(now(), CATCH_UP, 1_000, || None), + "a fresh reporter has nothing to wait for" + ); + }); + assert!( + output.contains("catch-up progress"), + "missing marker: {output}" + ); + } + + #[test] + fn suppressed_until_the_interval_elapses() { + let mut r = CatchUpProgressReporter::default(); + let t0 = now(); + let output = capture(|| { + assert!(r.maybe_emit(t0, CATCH_UP, 1_000, || None)); + assert!( + !r.maybe_emit(t0, CATCH_UP, 1_001, || None), + "same instant — inside the window" + ); + assert!( + !r.maybe_emit( + t0 + REPORT_INTERVAL - Duration::from_millis(1), + CATCH_UP, + 1_002, + || None + ), + "one millisecond short of the window" + ); + assert!( + r.maybe_emit(t0 + REPORT_INTERVAL, CATCH_UP, 1_003, || None), + "window elapsed → report" + ); + }); + assert_eq!( + output.matches("catch-up progress").count(), + 2, + "exactly the first and the post-interval call emitted: {output}" + ); + } + + #[test] + fn interval_is_measured_from_the_last_emit_not_the_last_call() { + // Two intervals of steady sweeping produce records on the grid, not one + // per block and not one per sweep. + let mut r = CatchUpProgressReporter::default(); + let t0 = now(); + let output = capture(|| { + for tick in 0..=20u32 { + // A block every second of sweeping. + let at = t0 + Duration::from_secs(tick as u64); + r.maybe_emit(at, CATCH_UP, 1_000 + tick, || None); + } + }); + // t=0, 5, 10, 15, 20. + assert_eq!( + output.matches("catch-up progress").count(), + 5, + "one record per elapsed interval: {output}" + ); + } + + // ---- catch-up only ---- + + #[test] + fn tip_sweep_never_reports() { + let mut r = CatchUpProgressReporter::default(); + let t0 = now(); + let output = capture(|| { + assert!(!r.maybe_emit(t0, 1, 1_785_000, || None)); + assert!( + !r.maybe_emit(t0 + Duration::from_secs(3600), 1, 1_785_001, || None), + "no amount of elapsed time makes a tip sweep report" + ); + assert!( + !r.maybe_emit(t0, 0, 1_785_000, || None), + "a zero-block sweep is not catch-up either" + ); + }); + assert!( + !output.contains("catch-up progress"), + "tip sweeps emitted: {output}" + ); + } + + #[test] + fn a_tip_sweep_does_not_consume_the_interval() { + // Suppression is not an emission. A node that dips to tip and falls + // behind again reports on its first catch-up sweep. + let mut r = CatchUpProgressReporter::default(); + let t0 = now(); + let output = capture(|| { + assert!(!r.maybe_emit(t0, 1, 1_000, || None)); + assert!( + r.maybe_emit(t0, CATCH_UP, 1_000, || None), + "the suppressed tip sweep must not have armed the gate" + ); + }); + assert_eq!(output.matches("catch-up progress").count(), 1, "{output}"); + } + + // ---- the fields ---- + + #[test] + fn record_carries_the_applied_height() { + let mut r = CatchUpProgressReporter::default(); + let output = capture(|| { + r.maybe_emit(now(), CATCH_UP, 1_779_387, || None); + }); + assert!( + output.contains("state_applied_height=1779387"), + "missing applied height: {output}" + ); + } + + #[test] + fn record_carries_jemalloc_allocated_when_a_probe_is_wired() { + // The 2026-08-12 field OOM: killed at validation height 1,779,387 with + // anon-rss 10.62 GiB. The queue that caused it is gone, but the pairing + // of applied height against allocator heap is what made the shape of + // that run legible in the first place, and it is what would make the + // next one legible too. + let mut r = CatchUpProgressReporter::default(); + let output = capture(|| { + r.maybe_emit(now(), CATCH_UP, 1_779_387, || Some(11_402_000_000)); + }); + assert!( + output.contains("jemalloc_allocated=11402000000"), + "missing heap field: {output}" + ); + } + + #[test] + fn record_omits_jemalloc_allocated_without_a_probe() { + let mut r = CatchUpProgressReporter::default(); + let output = capture(|| { + r.maybe_emit(now(), CATCH_UP, 1_000, || None); + }); + assert!( + output.contains("catch-up progress"), + "record should still emit: {output}" + ); + assert!( + !output.contains("jemalloc_allocated"), + "field should be absent, not rendered as None: {output}" + ); + } + + #[test] + fn heap_probe_is_not_called_while_suppressed() { + // Do not perturb what you are measuring. The probe is a mallctl + // round-trip; per-block it would be overhead the instrument invented. + let calls = Cell::new(0u32); + let probe = || { + calls.set(calls.get() + 1); + Some(1_024u64) + }; + let mut r = CatchUpProgressReporter::default(); + let t0 = now(); + capture(|| { + r.maybe_emit(t0, CATCH_UP, 1_000, probe); + assert_eq!(calls.get(), 1, "the emitted record reads the probe once"); + for tick in 1..5u32 { + r.maybe_emit( + t0 + Duration::from_millis(tick as u64 * 100), + CATCH_UP, + 1_000 + tick, + probe, + ); + } + assert_eq!(calls.get(), 1, "gated calls must not touch the probe"); + r.maybe_emit(t0, 1, 1_000, probe); + assert_eq!( + calls.get(), + 1, + "a tip sweep must not touch the probe either" + ); + }); + } +} diff --git a/sync/src/delivery.rs b/sync/src/delivery.rs index 09dff04..aa165b8 100644 --- a/sync/src/delivery.rs +++ b/sync/src/delivery.rs @@ -115,12 +115,15 @@ impl DeliveryTracker { pub fn mark_requested(&mut self, ids: &[[u8; 32]], peer: PeerId, type_id: u8) { let now = Instant::now(); for id in ids { - self.pending.insert(*id, PendingRequest { - peer, - type_id, - requested_at: now, - checks: 0, - }); + self.pending.insert( + *id, + PendingRequest { + peer, + type_id, + requested_at: now, + checks: 0, + }, + ); } } @@ -171,12 +174,18 @@ impl DeliveryTracker { let fresh = std::mem::take(&mut self.evicted); - CheckResult { retries, fresh, abandoned } + CheckResult { + retries, + fresh, + abandoned, + } } /// Remove all pending requests for a peer. Returns their IDs for re-request. pub fn purge_peer(&mut self, peer: PeerId) -> Vec<[u8; 32]> { - let orphaned: Vec<[u8; 32]> = self.pending.iter() + let orphaned: Vec<[u8; 32]> = self + .pending + .iter() .filter(|(_, req)| req.peer == peer) .map(|(id, _)| *id) .collect(); @@ -207,6 +216,37 @@ impl DeliveryTracker { pub fn evicted_count(&self) -> usize { self.evicted.len() } + + /// Heap bytes occupied by the entries live in the tracker's two + /// collections. + /// + /// Counted from the entries themselves: `len()` times the exact size of + /// what one entry stores, never a guessed per-modifier constant and never + /// a block count. `PendingRequest` and `[u8; 32]` own no further heap, so + /// every byte an entry occupies is counted here. + /// + /// ⚠ Deliberately excludes the tables' unused slots, and `capacity()` is + /// the reason. It looks like the way to include them and is not: it + /// returns `items + growth_left`, and erasing an entry only returns its + /// slot to `growth_left` when the probe sequence lets the slot be marked + /// EMPTY rather than DELETED. That depends on `RandomState`'s per-process + /// hash seed, so after a burst of deliveries the same table reports + /// anything from a fraction of its real size up to the true value — the + /// first draft of this method was caught by its own test halving between + /// runs. A figure built on it would be wrong by a factor that changes run + /// to run, and nothing downstream could tell. + /// + /// The uncounted slack is bounded and one-directional: hashbrown rounds to + /// a power-of-two bucket count at a 7/8 load factor and adds one control + /// byte per bucket, so the real allocation is at most ~2.3× this figure + /// and never below it. + pub fn memory_bytes(&self) -> u64 { + let pending = (self.pending.len() as u64) + .saturating_mul(std::mem::size_of::<([u8; 32], PendingRequest)>() as u64); + let evicted = + (self.evicted.len() as u64).saturating_mul(std::mem::size_of::<[u8; 32]>() as u64); + pending.saturating_add(evicted) + } } #[cfg(test)] @@ -313,6 +353,91 @@ mod tests { assert_eq!(tracker.evicted_count(), 0); // drained } + #[test] + fn memory_bytes_rises_with_pending_and_falls_on_delivery() { + let mut tracker = DeliveryTracker::new(); + assert_eq!(tracker.memory_bytes(), 0, "an empty tracker holds nothing"); + + let ids: Vec<[u8; 32]> = (0u8..200).map(|n| [n; 32]).collect(); + tracker.mark_requested(&ids, peer(1), 101); + let loaded = tracker.memory_bytes(); + assert!(loaded > 0, "200 in-flight requests must report bytes"); + + // Scales with the number in flight, not with a fixed constant. + let mut small = DeliveryTracker::new(); + small.mark_requested(&ids[..10], peer(1), 101); + assert_eq!( + small.memory_bytes() * 20, + loaded, + "10 in flight ({}) must be exactly a twentieth of 200 in flight ({loaded})", + small.memory_bytes() + ); + + // Delivery brings it back down. A figure that only ever rises is + // measuring the wrong thing — that is the symptom under investigation. + for id in &ids[..100] { + tracker.mark_received(id); + } + assert_eq!( + tracker.memory_bytes(), + loaded / 2, + "half delivered ⇒ half the bytes" + ); + + for id in &ids[100..] { + tracker.mark_received(id); + } + assert_eq!(tracker.pending_count(), 0); + assert_eq!(tracker.memory_bytes(), 0, "an emptied window holds nothing"); + } + + /// The figure must depend on how much is in flight and on nothing else. + /// + /// This is the guard against reintroducing `HashMap::capacity()`. That + /// version reported a table emptied by `remove` as anywhere between a + /// fraction of its allocation and all of it, depending on `RandomState`'s + /// per-process seed — so a churned tracker and a fresh one at identical + /// occupancy disagreed, and the same node reported different window + /// memory on each restart for the same workload. + #[test] + fn memory_bytes_is_deterministic_for_a_given_occupancy() { + let ids: Vec<[u8; 32]> = (0u8..120).map(|n| [n; 32]).collect(); + + // Built by insertion only. + let mut fresh = DeliveryTracker::new(); + fresh.mark_requested(&ids[..60], peer(1), 101); + + // Same occupancy reached via a load-then-drain cycle. + let mut churned = DeliveryTracker::new(); + churned.mark_requested(&ids, peer(1), 101); + for id in &ids[60..] { + churned.mark_received(id); + } + + assert_eq!(fresh.pending_count(), churned.pending_count()); + assert_eq!( + fresh.memory_bytes(), + churned.memory_bytes(), + "history must not change the figure — only occupancy may" + ); + } + + #[test] + fn memory_bytes_counts_the_evicted_queue() { + let mut tracker = DeliveryTracker::new(); + let ids: Vec<[u8; 32]> = (0u8..50).map(|n| [n; 32]).collect(); + tracker.schedule_rerequest(&ids); + assert_eq!( + tracker.memory_bytes(), + 50 * 32, + "50 evicted 32-byte ids and nothing pending" + ); + + // Draining the queue via check_timeouts returns the bytes. + let _ = tracker.check_timeouts(); + assert_eq!(tracker.memory_bytes(), 0); + } + #[test] fn schedule_rerequest_clears_pending() { let mut tracker = DeliveryTracker::new(); diff --git a/sync/src/lib.rs b/sync/src/lib.rs index d0dfdc0..a9627a9 100644 --- a/sync/src/lib.rs +++ b/sync/src/lib.rs @@ -4,13 +4,16 @@ //! header chain from genesis to the network tip. pub mod apply_state_error; +mod catchup_progress; pub mod delivery; pub mod light_bootstrap; mod retention; pub mod snapshot; mod state; mod sweep_backoff; +#[cfg(test)] +mod test_support; mod traits; -pub use state::{HeaderSync, SyncConfig}; +pub use state::{HeaderSync, SyncConfig, SyncWindowEstimate, WINDOW_BYTES_UNSET}; pub use traits::{SyncChain, SyncStore, SyncTransport}; diff --git a/sync/src/light_bootstrap.rs b/sync/src/light_bootstrap.rs index b58fcff..521554a 100644 --- a/sync/src/light_bootstrap.rs +++ b/sync/src/light_bootstrap.rs @@ -424,7 +424,10 @@ mod tests { } fn add_compare(&self, this: Vec, than: Vec, result: CompareResult) { - self.compare_results.lock().unwrap().push((this, than, result)); + self.compare_results + .lock() + .unwrap() + .push((this, than, result)); } fn installed(&self) -> Option<(Header, Vec
)> { @@ -433,10 +436,18 @@ mod tests { } impl crate::traits::SyncChain for MockChain { - async fn chain_height(&self) -> u32 { 0 } - async fn build_sync_info(&self) -> Vec { vec![] } - async fn header_at(&self, _h: u32) -> Option
{ None } - async fn header_state_root(&self, _h: u32) -> Option<[u8; 33]> { None } + async fn chain_height(&self) -> u32 { + 0 + } + async fn build_sync_info(&self) -> Vec { + vec![] + } + async fn header_at(&self, _h: u32) -> Option
{ + None + } + async fn header_state_root(&self, _h: u32) -> Option<[u8; 33]> { + None + } fn parse_sync_info(&self, _b: &[u8]) -> Result { unimplemented!() } @@ -451,16 +462,25 @@ mod tests { async fn active_parameters(&self) -> ergo_validation::Parameters { unimplemented!() } - async fn is_epoch_boundary(&self, _h: u32) -> bool { false } + async fn is_epoch_boundary(&self, _h: u32) -> bool { + false + } async fn compute_expected_parameters( - &self, _h: u32, _pu: &[u8], + &self, + _h: u32, + _pu: &[u8], ) -> Result { unimplemented!() } async fn apply_epoch_boundary_parameters( - &self, _p: ergo_validation::Parameters, _pu: Vec, - ) {} - async fn active_proposed_update_bytes(&self) -> Vec { Vec::new() } + &self, + _p: ergo_validation::Parameters, + _pu: Vec, + ) { + } + async fn active_proposed_update_bytes(&self) -> Vec { + Vec::new() + } async fn verify_nipopow_envelope( &self, @@ -475,7 +495,9 @@ mod tests { }; } } - Err(ChainError::Nipopow("unexpected envelope body in mock".into())) + Err(ChainError::Nipopow( + "unexpected envelope body in mock".into(), + )) } async fn is_better_nipopow( @@ -544,13 +566,13 @@ mod tests { let chain = MockChain::new(); chain.add_verify(body_a.clone(), VerifyResult::Err("bad proof".into())); - let mut transport = MockTransport::new( - vec![peer_a], - vec![proof_event(peer_a, body_a)], - ); + let mut transport = MockTransport::new(vec![peer_a], vec![proof_event(peer_a, body_a)]); let result = run_light_bootstrap(&mut transport, &chain).await; - assert!(matches!(result, Err(LightBootstrapError::AllPeersHostile(_)))); + assert!(matches!( + result, + Err(LightBootstrapError::AllPeersHostile(_)) + )); assert!(chain.installed().is_none()); } @@ -562,10 +584,7 @@ mod tests { let chain = MockChain::new(); chain.add_verify(body_a.clone(), VerifyResult::Ok(headers.clone())); - let mut transport = MockTransport::new( - vec![peer_a], - vec![proof_event(peer_a, body_a)], - ); + let mut transport = MockTransport::new(vec![peer_a], vec![proof_event(peer_a, body_a)]); let result = run_light_bootstrap(&mut transport, &chain).await; assert!(result.is_ok()); @@ -588,10 +607,7 @@ mod tests { let mut transport = MockTransport::new( vec![peer_a, peer_b], - vec![ - proof_event(peer_a, body_a), - proof_event(peer_b, body_b), - ], + vec![proof_event(peer_a, body_a), proof_event(peer_b, body_b)], ); let result = run_light_bootstrap(&mut transport, &chain).await; @@ -615,10 +631,7 @@ mod tests { let mut transport = MockTransport::new( vec![peer_a, peer_b], - vec![ - proof_event(peer_a, body_a), - proof_event(peer_b, body_b), - ], + vec![proof_event(peer_a, body_a), proof_event(peer_b, body_b)], ); let result = run_light_bootstrap(&mut transport, &chain).await; @@ -639,14 +652,15 @@ mod tests { chain.add_verify(body_a.clone(), VerifyResult::Ok(headers_a)); chain.add_verify(body_b.clone(), VerifyResult::Ok(headers_b)); // Comparison fails — should fall back to incumbent (peer A, first valid). - chain.add_compare(body_b.clone(), body_a.clone(), CompareResult::Err("parse failed".into())); + chain.add_compare( + body_b.clone(), + body_a.clone(), + CompareResult::Err("parse failed".into()), + ); let mut transport = MockTransport::new( vec![peer_a, peer_b], - vec![ - proof_event(peer_a, body_a), - proof_event(peer_b, body_b), - ], + vec![proof_event(peer_a, body_a), proof_event(peer_b, body_b)], ); let result = run_light_bootstrap(&mut transport, &chain).await; @@ -742,14 +756,14 @@ mod tests { let chain = MockChain::new(); chain.add_verify(body_a.clone(), VerifyResult::Ok(headers)); - let mut transport = MockTransport::new( - vec![peer_a], - vec![proof_event(peer_a, body_a)], - ); + let mut transport = MockTransport::new(vec![peer_a], vec![proof_event(peer_a, body_a)]); let result = run_light_bootstrap(&mut transport, &chain).await; // Proof with fewer headers than k is treated as hostile. - assert!(matches!(result, Err(LightBootstrapError::AllPeersHostile(_)))); + assert!(matches!( + result, + Err(LightBootstrapError::AllPeersHostile(_)) + )); assert!(chain.installed().is_none()); } @@ -761,28 +775,47 @@ mod tests { // Chain already has headers — MockChain returns 0, we need a non-zero one. struct PopulatedChain; impl crate::traits::SyncChain for PopulatedChain { - async fn chain_height(&self) -> u32 { 100 } - async fn build_sync_info(&self) -> Vec { vec![] } - async fn header_at(&self, _h: u32) -> Option
{ None } - async fn header_state_root(&self, _h: u32) -> Option<[u8; 33]> { None } + async fn chain_height(&self) -> u32 { + 100 + } + async fn build_sync_info(&self) -> Vec { + vec![] + } + async fn header_at(&self, _h: u32) -> Option
{ + None + } + async fn header_state_root(&self, _h: u32) -> Option<[u8; 33]> { + None + } fn parse_sync_info(&self, _b: &[u8]) -> Result { unimplemented!() } - async fn continuation_ids( - &self, _ids: &[BlockId], _limit: usize, - ) -> Vec<[u8; 32]> { + async fn continuation_ids(&self, _ids: &[BlockId], _limit: usize) -> Vec<[u8; 32]> { // Bootstrap-skip test — no SyncInfo traffic. Vec::new() } - async fn active_parameters(&self) -> ergo_validation::Parameters { unimplemented!() } - async fn is_epoch_boundary(&self, _h: u32) -> bool { false } + async fn active_parameters(&self) -> ergo_validation::Parameters { + unimplemented!() + } + async fn is_epoch_boundary(&self, _h: u32) -> bool { + false + } async fn compute_expected_parameters( - &self, _h: u32, _pu: &[u8], - ) -> Result { unimplemented!() } + &self, + _h: u32, + _pu: &[u8], + ) -> Result { + unimplemented!() + } async fn apply_epoch_boundary_parameters( - &self, _p: ergo_validation::Parameters, _pu: Vec, - ) {} - async fn active_proposed_update_bytes(&self) -> Vec { Vec::new() } + &self, + _p: ergo_validation::Parameters, + _pu: Vec, + ) { + } + async fn active_proposed_update_bytes(&self) -> Vec { + Vec::new() + } async fn verify_nipopow_envelope(&self, _b: &[u8]) -> Result, ChainError> { unimplemented!() } @@ -790,9 +823,15 @@ mod tests { unimplemented!() } async fn install_nipopow_suffix( - &self, _h: Header, _t: Vec
, - ) -> Result<(), ChainError> { unimplemented!() } - async fn voting_length(&self) -> u32 { 1024 } + &self, + _h: Header, + _t: Vec
, + ) -> Result<(), ChainError> { + unimplemented!() + } + async fn voting_length(&self) -> u32 { + 1024 + } } let result = run_light_bootstrap(&mut transport, &PopulatedChain).await; @@ -807,10 +846,7 @@ mod tests { let chain = MockChain::new(); chain.add_verify(body.clone(), VerifyResult::Ok(headers)); - let mut transport = MockTransport::new( - peers.clone(), - vec![proof_event(PeerId(1), body)], - ); + let mut transport = MockTransport::new(peers.clone(), vec![proof_event(PeerId(1), body)]); let _ = run_light_bootstrap(&mut transport, &chain).await; diff --git a/sync/src/snapshot/download.rs b/sync/src/snapshot/download.rs index aa93ef1..986c1c7 100644 --- a/sync/src/snapshot/download.rs +++ b/sync/src/snapshot/download.rs @@ -62,7 +62,10 @@ impl ChunkDownloadStore { { let mut meta = txn.open_table(METADATA)?; meta.insert(META_MANIFEST_ID, manifest_id.as_slice())?; - meta.insert(META_SNAPSHOT_HEIGHT, &snapshot_height.to_be_bytes() as &[u8])?; + meta.insert( + META_SNAPSHOT_HEIGHT, + &snapshot_height.to_be_bytes() as &[u8], + )?; meta.insert(META_MANIFEST_BYTES, manifest_bytes)?; meta.insert(META_TOTAL_CHUNKS, &total_chunks.to_be_bytes() as &[u8])?; } @@ -86,7 +89,8 @@ impl ChunkDownloadStore { let meta = txn.open_table(METADATA)?; let manifest_id = read_meta_fixed::<32>(&meta, META_MANIFEST_ID)?; - let snapshot_height = u32::from_be_bytes(read_meta_fixed::<4>(&meta, META_SNAPSHOT_HEIGHT)?); + let snapshot_height = + u32::from_be_bytes(read_meta_fixed::<4>(&meta, META_SNAPSHOT_HEIGHT)?); let total_chunks = u32::from_be_bytes(read_meta_fixed::<4>(&meta, META_TOTAL_CHUNKS)?); drop(meta); diff --git a/sync/src/snapshot/mod.rs b/sync/src/snapshot/mod.rs index 6054e14..abfda0f 100644 --- a/sync/src/snapshot/mod.rs +++ b/sync/src/snapshot/mod.rs @@ -92,7 +92,13 @@ async fn discover_snapshot( let (code, body) = SnapshotMessage::GetSnapshotsInfo.encode(); for &peer in &peers { if let Err(e) = transport - .send_to(peer, ProtocolMessage::Unknown { code, body: body.clone() }) + .send_to( + peer, + ProtocolMessage::Unknown { + code, + body: body.clone(), + }, + ) .await { tracing::warn!(%peer, "failed to send GetSnapshotsInfo: {e}"); @@ -115,7 +121,11 @@ async fn discover_snapshot( if let ProtocolEvent::Message { peer_id, - message: ProtocolMessage::Unknown { code: SNAPSHOTS_INFO, body }, + message: + ProtocolMessage::Unknown { + code: SNAPSHOTS_INFO, + body, + }, } = event { if let Ok(SnapshotMessage::SnapshotsInfo(entries)) = @@ -167,7 +177,13 @@ async fn download_manifest( for &peer in &snapshot.peers { if let Err(e) = transport - .send_to(peer, ProtocolMessage::Unknown { code, body: body.clone() }) + .send_to( + peer, + ProtocolMessage::Unknown { + code, + body: body.clone(), + }, + ) .await { tracing::warn!(%peer, "failed to send GetManifest: {e}"); @@ -180,7 +196,11 @@ async fn download_manifest( match event { Ok(Some(ProtocolEvent::Message { - message: ProtocolMessage::Unknown { code: MANIFEST, body }, + message: + ProtocolMessage::Unknown { + code: MANIFEST, + body, + }, .. })) => { if let Ok(SnapshotMessage::Manifest(data)) = @@ -286,7 +306,7 @@ async fn download_chunks( } Ok(Some(_)) => {} // other message type, ignore Ok(None) => return Err(SnapshotError::StreamClosed), - Err(_) => {} // poll timeout, check for stale requests + Err(_) => {} // poll timeout, check for stale requests } // Re-queue timed-out requests @@ -354,8 +374,7 @@ pub async fn run_snapshot_sync( config: &SnapshotConfig, ) -> Result { let download_path = config.data_dir.join("snapshot_download.redb"); - let chunk_timeout = - Duration::from_secs(config.chunk_timeout_multiplier as u64 * 10); + let chunk_timeout = Duration::from_secs(config.chunk_timeout_multiplier as u64 * 10); // ── Crash recovery: check for interrupted download ────────────────── if download_path.exists() { @@ -385,8 +404,7 @@ pub async fn run_snapshot_sync( store.total_chunks() ); let peers = transport.outbound_peers().await; - download_chunks(transport, &store, &subtree_ids, &peers, chunk_timeout) - .await?; + download_chunks(transport, &store, &subtree_ids, &peers, chunk_timeout).await?; let data = assemble_snapshot(&manifest_bytes, &store, height)?; ChunkDownloadStore::cleanup(&download_path).ok(); return Ok(data); @@ -413,8 +431,7 @@ pub async fn run_snapshot_sync( ); // ── Download manifest ─────────────────────────────────────────────── - let manifest_bytes = - download_manifest(transport, &snapshot, Duration::from_secs(30)).await?; + let manifest_bytes = download_manifest(transport, &snapshot, Duration::from_secs(30)).await?; tracing::info!("manifest downloaded: {} bytes", manifest_bytes.len()); let subtree_ids = extract_subtree_ids(&manifest_bytes, 32)?; diff --git a/sync/src/snapshot/parser.rs b/sync/src/snapshot/parser.rs index e4a18b6..d68428d 100644 --- a/sync/src/snapshot/parser.rs +++ b/sync/src/snapshot/parser.rs @@ -177,10 +177,7 @@ pub fn parse_node(data: &[u8], key_length: usize) -> Result<(ParsedNode, usize), /// Parse a complete DFS byte stream into a sequence of parsed nodes. /// Used for both manifest and chunk reconstruction. -pub fn parse_dfs_stream( - data: &[u8], - key_length: usize, -) -> Result, ParseError> { +pub fn parse_dfs_stream(data: &[u8], key_length: usize) -> Result, ParseError> { let mut nodes = Vec::new(); let mut offset = 0; diff --git a/sync/src/snapshot/protocol.rs b/sync/src/snapshot/protocol.rs index 1dc87db..4f1e5ce 100644 --- a/sync/src/snapshot/protocol.rs +++ b/sync/src/snapshot/protocol.rs @@ -140,7 +140,10 @@ impl SnapshotMessage { let mut manifest_id = [0u8; 32]; manifest_id.copy_from_slice(&body[offset..offset + 32]); offset += 32; - entries.push(SnapshotEntry { height, manifest_id }); + entries.push(SnapshotEntry { + height, + manifest_id, + }); } Ok(SnapshotMessage::SnapshotsInfo(entries)) } @@ -164,7 +167,9 @@ impl SnapshotMessage { if body.len() < offset + len { return Err(ProtocolError::TooShort); } - Ok(SnapshotMessage::Manifest(body[offset..offset + len].to_vec())) + Ok(SnapshotMessage::Manifest( + body[offset..offset + len].to_vec(), + )) } GET_UTXO_SNAPSHOT_CHUNK => { @@ -186,7 +191,9 @@ impl SnapshotMessage { if body.len() < offset + len { return Err(ProtocolError::TooShort); } - Ok(SnapshotMessage::UtxoSnapshotChunk(body[offset..offset + len].to_vec())) + Ok(SnapshotMessage::UtxoSnapshotChunk( + body[offset..offset + len].to_vec(), + )) } _ => Err(ProtocolError::UnknownCode(code)), @@ -217,9 +224,7 @@ impl SnapshotMessage { (MANIFEST, body) } - SnapshotMessage::GetUtxoSnapshotChunk(id) => { - (GET_UTXO_SNAPSHOT_CHUNK, id.to_vec()) - } + SnapshotMessage::GetUtxoSnapshotChunk(id) => (GET_UTXO_SNAPSHOT_CHUNK, id.to_vec()), SnapshotMessage::UtxoSnapshotChunk(data) => { let mut body = Vec::with_capacity(5 + data.len()); diff --git a/sync/src/state.rs b/sync/src/state.rs index b38fb89..875d718 100644 --- a/sync/src/state.rs +++ b/sync/src/state.rs @@ -1,8 +1,6 @@ -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use crossbeam_channel::{Receiver as CrossbeamReceiver, Sender as CrossbeamSender}; - use enr_p2p::protocol::messages::ProtocolMessage; use enr_p2p::protocol::peer::ProtocolEvent; use enr_p2p::types::PeerId; @@ -13,8 +11,8 @@ use crate::apply_state_error::classify_apply_state_error; use crate::delivery::{DeliveryControl, DeliveryData, DeliveryTracker}; use crate::sweep_backoff::StallDetail; use enr_chain::{ - BlockId, StateType, SyncInfo, HEADER_TYPE_ID, BLOCK_TRANSACTIONS_TYPE_ID, AD_PROOFS_TYPE_ID, - EXTENSION_TYPE_ID, TRANSACTION_TYPE_ID, + BlockId, StateType, SyncInfo, AD_PROOFS_TYPE_ID, BLOCK_TRANSACTIONS_TYPE_ID, EXTENSION_TYPE_ID, + HEADER_TYPE_ID, TRANSACTION_TYPE_ID, }; use crate::traits::{SyncChain, SyncStore, SyncTransport}; @@ -25,7 +23,10 @@ use ergo_validation::BlockValidator; /// backpressure, so this is capped below the JVM's `desiredInvObjects` (400). /// 64 per type × 2 types = 128 sections per cycle = 64 blocks/cycle. fn is_block_section_type(type_id: u8) -> bool { - matches!(type_id, BLOCK_TRANSACTIONS_TYPE_ID | AD_PROOFS_TYPE_ID | EXTENSION_TYPE_ID) + matches!( + type_id, + BLOCK_TRANSACTIONS_TYPE_ID | AD_PROOFS_TYPE_ID | EXTENSION_TYPE_ID + ) } /// Max continuation header ids served to a behind/forked peer per incoming @@ -45,26 +46,65 @@ fn sync_info_anchor_ids(info: &SyncInfo) -> Vec { /// Result of a paired state/store flush. /// -/// Discriminates three cases: +/// Discriminates four cases: /// - `Flushed(M)`: validator flushed successfully at height `M`; modifier /// store's `validated_height` was advanced to `M`. -/// - `NoValidator`: light-mode path; there is no state to flush, and +/// - `NothingToPersist(M)`: the validator owns no persistent state +/// (`state_persistence()` is `None` — digest mode). Nothing was fsynced, +/// but the validator has a real `validated_height()` and the store side of +/// the pair runs exactly as it does after a successful flush. +/// - `NoValidator`: light-mode path; there is no validator at all, and /// `validated_height` is not recorded. /// - `Failed`: validator's `flush()` returned an error; modifier store's /// `validated_height` was NOT advanced. +/// +/// ⚠ `NothingToPersist` and `NoValidator` are NOT interchangeable, and folding +/// the first into the second is a behaviour change, not a simplification. +/// Digest mode has a validator and a real height; before the v0.8.0 trait split +/// it reached `Flushed(M)` through `BlockValidator`'s defaulted `flush` — +/// returning `Ok(())` for work it never did — so the `set_validated_height(M)` +/// write and the prune both happened. They must keep happening. Light mode +/// deliberately skips both. See `../facts/sync.md` § "Flushing a validator that +/// owns no state". #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FlushOutcome { Flushed(u32), + NothingToPersist(u32), NoValidator, Failed, } impl FlushOutcome { /// Whether the caller should advance its `last_flush_height` bookkeeping. - /// Advances on either a successful flush or no-validator (light mode); - /// stays put on flush failure so the next `should_flush()` retries. + /// Advances on a successful flush, on nothing-to-persist (digest mode), + /// and on no-validator (light mode); stays put on flush failure so the + /// next `should_flush()` retries. pub(crate) fn advances_last_flush(self) -> bool { - matches!(self, FlushOutcome::Flushed(_) | FlushOutcome::NoValidator) + matches!( + self, + FlushOutcome::Flushed(_) + | FlushOutcome::NothingToPersist(_) + | FlushOutcome::NoValidator + ) + } + + /// The height the cross-DB pair may record and prune against, if any. + /// + /// `Some(M)` for both a real fsync and the nothing-to-persist case: the + /// store side of the handshake does not distinguish them, because step (1) + /// is either satisfied or vacuous. `None` for light mode (no height exists) + /// and for a failed flush (durability is UNKNOWN — advancing anything on + /// the strength of it is exactly the bug the split exists to prevent). + /// + /// Single decision point on purpose: the prune call sites match on the + /// outcome with `if let`, which the compiler does not check for + /// exhaustiveness, so a future variant must be considered here rather than + /// silently falling out of three separate patterns. + pub(crate) fn committed_height(self) -> Option { + match self { + FlushOutcome::Flushed(m) | FlushOutcome::NothingToPersist(m) => Some(m), + FlushOutcome::NoValidator | FlushOutcome::Failed => None, + } } } @@ -89,12 +129,18 @@ pub(crate) fn try_flush_validator( ) -> FlushOutcome { match validator { None => FlushOutcome::NoValidator, - Some(v) => match v.flush() { - Ok(()) => FlushOutcome::Flushed(v.validated_height()), - Err(e) => { - tracing::warn!(height = height_context, error = %e, "validator flush failed"); - FlushOutcome::Failed - } + // No `StatePersistence` = digest mode: nothing to fsync, which is not + // a failure and not the same as having no validator. See + // `FlushOutcome::NothingToPersist`. + Some(v) => match v.state_persistence() { + None => FlushOutcome::NothingToPersist(v.validated_height()), + Some(p) => match p.flush() { + Ok(()) => FlushOutcome::Flushed(v.validated_height()), + Err(e) => { + tracing::warn!(height = height_context, error = %e, "validator flush failed"); + FlushOutcome::Failed + } + }, }, } } @@ -103,16 +149,14 @@ pub(crate) fn try_flush_validator( /// /// Takes the outcome of [`try_flush_validator`] (validator already flushed) /// and finishes the durability handshake on the store side. Order is -/// load-bearing: `set_validated_height(M)` (only on `Flushed`) then -/// `store.flush()`. A failed validator flush MUST NOT advance the modifier -/// store's recorded `validated_height`; light mode (no validator) skips the -/// `set_validated_height` write entirely. The store flush always runs to -/// fsync any accumulated section writes. -pub(crate) async fn complete_store_flush_pair( - outcome: FlushOutcome, - store: &S, -) { - if let FlushOutcome::Flushed(m) = outcome { +/// load-bearing: `set_validated_height(M)` (only when the outcome carries a +/// height) then `store.flush()`. A failed validator flush MUST NOT advance the +/// modifier store's recorded `validated_height`; light mode (no validator) +/// skips the `set_validated_height` write entirely. Digest mode +/// (`NothingToPersist`) does write it — see that variant's doc. The store flush +/// always runs to fsync any accumulated section writes. +pub(crate) async fn complete_store_flush_pair(outcome: FlushOutcome, store: &S) { + if let Some(m) = outcome.committed_height() { store.set_validated_height(m).await; } store.flush().await; @@ -154,11 +198,7 @@ pub(crate) async fn maybe_prune_at_horizon( ) .await { - Ok(n) => tracing::debug!( - pruned = n, - horizon, - "pruned modifier rows" - ), + Ok(n) => tracing::debug!(pruned = n, horizon, "pruned modifier rows"), Err(e) => tracing::warn!( error = %e, horizon, @@ -238,6 +278,17 @@ pub struct SyncConfig { /// /// See ../facts/sync.md § "Block Body Retention". pub blocks_to_keep: i32, + // `eval_backlog_max_mb`, `eval_backlog_max_blocks`, `checkpoint_height` + // and `script_eval_inline` were removed in v0.8.0 with deferred + // evaluation. The first two bounded a queue that no longer exists; the + // last two existed only to tell sync where `script_verified_height` came + // from and how far down it had to be floored, and that watermark is gone. + // + // The checkpoint itself still matters — heights at or below it skip + // evaluation — but that is entirely `validation/`'s business now, fed + // straight from the node config to the validator. Sync never had an + // opinion about it beyond the floor. See `../facts/sync.md` + // § "Block Assembly (state_applied_height)". } impl Default for SyncConfig { @@ -274,6 +325,44 @@ impl Default for SyncConfig { } } +/// What the sync machine's in-flight window holds right now. +/// +/// [`crate::delivery::DeliveryTracker`] bookkeeping and nothing else — this +/// crate holds no section payloads. See +/// [`HeaderSync::window_memory_estimate`] for what is and is not counted, and +/// `../facts/sync.md` § "Memory attribution". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SyncWindowEstimate { + /// Heap bytes held by [`crate::delivery::DeliveryTracker`]: its + /// pending-request map and its evicted-id vec. Id-keyed bookkeeping only — + /// downloaded sections go straight from the p2p pipeline into the modifier + /// store, and the validation sweep reads each block back out, applies it, + /// and drops it inside one loop iteration. Nothing between "downloaded" + /// and "applied" lives on sync's heap. + /// + /// A lower bound, by a bounded and one-directional margin: it counts the + /// live entries exactly and not the hash table's unused slots. See + /// [`crate::delivery::DeliveryTracker::memory_bytes`] for why the slack + /// cannot be counted honestly and how large it can get (≈2.3×). + pub tracker_bytes: u64, + /// Live entries behind that figure: in-flight requests in the pending map + /// plus ids in the evicted vec awaiting re-request. + /// + /// There is no separate download queue to count. `request_next_sections` + /// recomputes the window's missing sections into a local map on every + /// cycle and drops it before returning. + pub tracker_entries: u64, +} + +/// Sentinel in the published window atomic meaning "sync has never written it". +/// +/// Zero is a legitimate reading — an idle tracker really has allocated +/// nothing — so absence needs a value no byte count can take. `u64::MAX` is +/// 16 EiB. Readers MUST map this to absent rather than reporting 0, per +/// `../facts/api.md` § "Component memory attribution": a node that has applied +/// no blocks must not claim an empty window. +pub const WINDOW_BYTES_UNSET: u64 = u64::MAX; + /// Header chain sync state machine. /// /// Event-driven loop matching the JVM's sync exchange pattern: @@ -310,16 +399,18 @@ pub struct HeaderSync)>, - /// Channel for receiving eval results. - eval_rx: CrossbeamReceiver<(u32, Result<(), ergo_validation::ValidationError>)>, + /// Interval gate for the periodic catch-up progress record. Diagnostic + /// only. In-memory: a restart re-baselines. See + /// [`crate::catchup_progress`]. + catchup_progress: crate::catchup_progress::CatchUpProgressReporter, /// Shared downloaded_height for the API (read by fastsync to avoid redundant work). shared_downloaded_height: std::sync::Arc, /// Gate controlling whether block/header/tx ModifierRequest sends actually fire. @@ -329,6 +420,14 @@ pub struct HeaderSync, + /// Published window memory estimate, written after each applied block. + /// + /// Publish, don't expose: `sync/` owns the validator and does not share it + /// (`../facts/validation.md`), so no HTTP path can call + /// [`Self::window_memory_estimate`] synchronously. The main crate hands in + /// this atomic and passes a clone to the API. Holds [`WINDOW_BYTES_UNSET`] + /// until the first block is applied. + shared_window_bytes: std::sync::Arc, /// Height of the most recent `validator.flush()` call. Used by the /// memory-aware flush policy to enforce `flush_min_blocks` spacing and /// `flush_max_blocks` upper bound. @@ -343,10 +442,11 @@ pub struct HeaderSync, /// Exponential backoff gating the validation sweep when the applied - /// tip fails to advance — a deterministic block failure (apply_state - /// error OR a deferred-eval rollback; both leave `validated_height()` - /// pinned). Derived purely from the validator's applied tip, so its - /// stall detection is blind to which subsystem rejected the block. Also + /// tip fails to advance — a deterministic `apply_state` failure, which + /// since v0.8.0 includes the script rejections that used to come back + /// asynchronously and roll the tip back from a drain. Derived purely from + /// the validator's applied tip, so its stall detection is blind to which + /// subsystem rejected the block. Also /// the single emitter of the contract `validation_stuck` event, fired /// once a frontier has stalled 5 sweeps in a row (the caller hands in /// the `error_kind`/`missing_key` label). Resets on real progress or a @@ -364,10 +464,10 @@ pub struct HeaderSync HeaderSync { - // 13-arg constructor reflects the dependency-inversion surface (4 trait - // objects + 6 channels/atomics + 3 config-ish args). Bundling these would - // hide the wiring without simplifying it. A builder is reasonable future - // work but not justified for one caller (`src/main.rs`). + // 15-arg constructor reflects the dependency-inversion surface (4 trait + // objects + 7 channels/atomics + 3 config-ish args + shutdown). Bundling + // these would hide the wiring without simplifying it. A builder is + // reasonable future work but not justified for one caller (`src/main.rs`). #[allow(clippy::too_many_arguments)] pub fn new( config: SyncConfig, @@ -383,12 +483,17 @@ impl HeaderSync shared_downloaded_height: std::sync::Arc, block_request_gate: std::sync::Arc, peer_chain_tip: std::sync::Arc, + shared_window_bytes: std::sync::Arc, shutdown_rx: tokio::sync::oneshot::Receiver<()>, ) -> Self { - let tracker = DeliveryTracker::with_config(config.delivery_timeout, config.max_delivery_checks); + let tracker = + DeliveryTracker::with_config(config.delivery_timeout, config.max_delivery_checks); let initial_validated = validator.as_ref().map_or(0, |v| v.validated_height()); - let (eval_tx, eval_rx) = crossbeam_channel::unbounded(); let last_flush_height = initial_validated; + // Stamp "never written" here rather than trusting the caller's initial + // value. Whatever the host constructed the atomic with, a reader that + // polls before the first applied block must see absent, not zero. + shared_window_bytes.store(WINDOW_BYTES_UNSET, std::sync::atomic::Ordering::Relaxed); Self { config, transport, @@ -409,13 +514,11 @@ impl HeaderSync sync_sent_count: 0, downloaded_height: initial_validated, state_applied_height: initial_validated, - script_verified_height: initial_validated, - evals_in_flight: 0, - eval_tx, - eval_rx, + catchup_progress: crate::catchup_progress::CatchUpProgressReporter::default(), shared_downloaded_height, block_request_gate, peer_chain_tip, + shared_window_bytes, last_flush_height, at_tip_request_tx: None, at_tip_validator_rx: None, @@ -425,13 +528,57 @@ impl HeaderSync } } + /// Bytes held by the in-flight delivery bookkeeping. `None` if not + /// computable. + /// + /// [`crate::delivery::DeliveryTracker`] is the whole of it: `sync/` holds + /// no section payloads and no persistent download queue. + /// + /// - Sections never pass through sync's heap. `ModifierResponse` is not a + /// message this state machine handles at all — the p2p pipeline writes + /// the bytes to the modifier store and notifies sync with ids only + /// ([`crate::delivery::DeliveryData::Received`]). The validation sweep + /// reads each block back out of the store, applies it, and drops it + /// within one iteration of the loop in `advance_state_applied_height`. + /// Downloaded-but-unapplied section bytes live in redb, and redb's cache + /// is already attributed as `storeCacheBytes`. + /// - There is no download queue to size. `request_next_sections` + /// recomputes the missing sections in the 192-block window into a local + /// map every cycle and drops it before returning. + /// - What is left is the [`crate::delivery::DeliveryTracker`]: the + /// pending-request map and the evicted-id queue, both id-keyed. Their + /// footprint is counted from the live entries, so it rises when requests + /// go in flight and falls as they are delivered — never derived from a + /// per-block constant. + /// + /// Currently always `Some`: both structures are always present and always + /// measurable. The `Option` is in the contract so that a future in-flight + /// structure sync cannot size reports absence rather than quietly + /// publishing a total that omits it. + pub fn window_memory_estimate(&self) -> Option { + Some(SyncWindowEstimate { + tracker_bytes: self.tracker.memory_bytes(), + tracker_entries: (self.tracker.pending_count() + self.tracker.evicted_count()) as u64, + }) + } + + /// Publish the current window estimate to the shared atomic. + /// + /// Called after each applied block. Not `async` and takes `&self`: the + /// borrow never spans an `.await`, so the enclosing future stays `Send` + /// even though `V` is `!Sync`. + fn publish_window_estimate(&self) { + let bytes = self + .window_memory_estimate() + .map_or(WINDOW_BYTES_UNSET, |e| e.tracker_bytes); + self.shared_window_bytes + .store(bytes, std::sync::atomic::Ordering::Relaxed); + } + /// Configure the at-tip transition. When `synced()` is first entered, /// `resize_cache()` is called on the validator with `synced_cache_bytes` /// to shrink the read cache without reopening the database. - pub fn set_at_tip_cache( - &mut self, - synced_cache_bytes: usize, - ) { + pub fn set_at_tip_cache(&mut self, synced_cache_bytes: usize) { self.synced_cache_bytes = Some(synced_cache_bytes); } @@ -581,11 +728,8 @@ impl HeaderSync // Idempotent: skipped on restart when the chain is non-empty. if self.config.state_type == StateType::Light && self.chain.chain_height().await == 0 { tracing::info!("light-client mode: running NiPoPoW bootstrap"); - match crate::light_bootstrap::run_light_bootstrap( - &mut self.transport, - &self.chain, - ) - .await + match crate::light_bootstrap::run_light_bootstrap(&mut self.transport, &self.chain) + .await { Ok(()) => { let height = self.chain.chain_height().await; @@ -594,7 +738,10 @@ impl HeaderSync // no validator running and no block sections to download. self.downloaded_height = height; self.state_applied_height = height; - tracing::info!(height, "light bootstrap installed, entering tip-following sync"); + tracing::info!( + height, + "light bootstrap installed, entering tip-following sync" + ); } Err(e) => { tracing::error!("light bootstrap failed: {e}"); @@ -609,32 +756,6 @@ impl HeaderSync self.advance_downloaded_height().await; } - // Startup: load persisted script_verified_height. - // If there's a gap (state applied but scripts not verified from a - // previous unclean shutdown), accept it — the AVL digest already - // proved state correctness. Proof boxes aren't available without - // re-running apply_state, so re-evaluation isn't feasible here. - if let Some(persisted_svh) = self.store.script_verified_height().await { - self.script_verified_height = persisted_svh.min(self.state_applied_height); - if persisted_svh < self.state_applied_height { - let gap = self.state_applied_height - persisted_svh; - tracing::info!( - persisted_svh, - state_applied_height = self.state_applied_height, - gap, - "startup: accepting script verification gap (AVL digest verified)" - ); - // Advance to match — the gap blocks' state transitions are - // already proven correct by the AVL digest check in apply_state. - self.script_verified_height = self.state_applied_height; - // Lock in the new floor durably so a restart before the next - // flush doesn't re-discover the same gap. Without this the - // persisted SVH stays stuck at the old value across restarts. - self.store.set_script_verified_height(self.script_verified_height).await; - self.store.flush().await; - } - } - // Startup WARN: surface bodies older than the configured retention // horizon so the operator knows to run `sharpen prune` to reclaim // disk. Self-correcting: after the first prune sweep the gap @@ -753,7 +874,7 @@ impl HeaderSync tracing::info!(height, "header sync exiting — flushing state"); let outcome = try_flush_validator(self.validator.as_ref(), height); complete_store_flush_pair(outcome, &self.store).await; - if let FlushOutcome::Flushed(m) = outcome { + if let Some(m) = outcome.committed_height() { maybe_prune_at_horizon(&self.store, &self.chain, m, self.config.blocks_to_keep).await; } if outcome.advances_last_flush() { @@ -820,9 +941,13 @@ impl HeaderSync let mut sweep_ticker = tokio::time::interval(Duration::from_secs(10)); loop { - let until_next = self.config.sync_interval + let until_next = self + .config + .sync_interval .saturating_sub(self.last_scheduled_sync.elapsed()); - let until_delivery = self.config.delivery_check_interval + let until_delivery = self + .config + .delivery_check_interval .saturating_sub(last_delivery_check.elapsed()); tokio::select! { @@ -924,7 +1049,10 @@ impl HeaderSync /// tracker before sending. Chunks into messages of at most 400 IDs /// to stay within the JVM's `desiredInvObjects` limit. async fn request_announced(&mut self, peer: PeerId, modifier_type: u8, ids: Vec<[u8; 32]>) { - if !self.block_request_gate.load(std::sync::atomic::Ordering::Relaxed) { + if !self + .block_request_gate + .load(std::sync::atomic::Ordering::Relaxed) + { return; } // Filter out already-known and already-in-flight IDs @@ -1009,7 +1137,8 @@ impl HeaderSync if new_height > self.downloaded_height { let advanced = new_height - self.downloaded_height; self.downloaded_height = new_height; - self.shared_downloaded_height.store(new_height, std::sync::atomic::Ordering::Relaxed); + self.shared_downloaded_height + .store(new_height, std::sync::atomic::Ordering::Relaxed); tracing::debug!( downloaded_height = new_height, advanced, @@ -1028,8 +1157,8 @@ impl HeaderSync // atomically with the AVL prover, so it is the single source of // truth for what state has actually been applied. The sweep MUST // resume from there, never from `state_applied_height`, which is a - // cache that a mid-sweep deferred-eval rollback can leave stale - // (ahead of the real tip). Deriving the start from the prover tip + // cache a reorg rollback can leave stale (ahead of the real tip). + // Deriving the start from the prover tip // makes it impossible to feed `apply_state` a non-consecutive block // — that was the wedge where the sweep skipped on-disk blocks and // looped forever on a `HeightMismatch`. See `../facts/sync.md`. @@ -1133,34 +1262,34 @@ impl HeaderSync // is sync and stateless w.r.t. chain state, so all chain queries // happen out-of-band before/after the call. let active_params = self.chain.active_parameters().await; - let (expected_boundary_params, expected_proposed_update) = - if self.chain.is_epoch_boundary(height).await { - let block_proposed_update = - match enr_chain::parse_extension_bytes(&extension) { - Ok((_header_id, fields)) => { - enr_chain::extract_disabling_rules_from_kv(&fields) - } - Err(e) => { - tracing::error!(height, error = %e, "extension parse for proposed update failed"); - break; - } - }; - let params = match self - .chain - .compute_expected_parameters(height, &block_proposed_update) - .await - { - Ok(p) => p, - Err(e) => { - tracing::error!(height, error = %e, "compute_expected_parameters failed"); - break; - } - }; - let expected_pu = self.chain.active_proposed_update_bytes().await; - (Some(params), Some(expected_pu)) - } else { - (None, None) + let (expected_boundary_params, expected_proposed_update) = if self + .chain + .is_epoch_boundary(height) + .await + { + let block_proposed_update = match enr_chain::parse_extension_bytes(&extension) { + Ok((_header_id, fields)) => enr_chain::extract_disabling_rules_from_kv(&fields), + Err(e) => { + tracing::error!(height, error = %e, "extension parse for proposed update failed"); + break; + } + }; + let params = match self + .chain + .compute_expected_parameters(height, &block_proposed_update) + .await + { + Ok(p) => p, + Err(e) => { + tracing::error!(height, error = %e, "compute_expected_parameters failed"); + break; + } }; + let expected_pu = self.chain.active_proposed_update_bytes().await; + (Some(params), Some(expected_pu)) + } else { + (None, None) + }; let result = self.validator.as_mut().unwrap().apply_state( &header, @@ -1194,47 +1323,31 @@ impl HeaderSync "block applied" ); - // Spawn deferred script evaluation on rayon pool - if let Some(eval) = outcome.deferred_eval { - let tx = self.eval_tx.clone(); - self.evals_in_flight += 1; - rayon::spawn(move || { - let h = eval.height; - let result = ergo_validation::evaluate_scripts(&eval).map(|_cost| ()); - let _ = tx.send((h, result)); + // Publish the window estimate after each applied block — + // the cadence the contract specifies, and the one that + // matters: the window is at its widest during exactly the + // catch-up sync this figure exists to characterise. + self.publish_window_estimate(); + + // Periodic catch-up record. Placed before the flush below so + // the heap reading is the pre-flush peak. Time-gated and + // catch-up-only inside `maybe_emit`; the probe closure runs + // only when a record actually fires. See + // [`crate::catchup_progress`] and `../facts/sync.md`. + // + // The height is read from the validator tip, not + // `self.state_applied_height`: that field is a cache + // reconciled after the loop, so mid-sweep it is frozen at + // the pre-sweep tip and this record would report a height + // the sweep passed some time ago. + let applied = self + .validator + .as_ref() + .map_or(validated_to, |v| v.validated_height()); + self.catchup_progress + .maybe_emit(Instant::now(), sweep_size, applied, || { + self.config.flush_probe.as_ref().map(|probe| probe()) }); - } - - // Non-blocking drain of completed eval results. A - // deferred-eval failure inside the drain rolls the - // validator back (and lowers `state_applied_height`). - // Detect it by the validator's own tip moving backwards - // across the drain — NOT by `state_applied_height` dipping - // below its pre-sweep value, which misses a rollback that - // lands exactly on the pre-sweep tip (the common case: the - // failing block was applied earlier in THIS sweep). On any - // backwards move, abort: continuing would feed `apply_state` - // a block past the rolled-back tip and surface a misleading - // `HeightMismatch`. - let pre_drain_tip = - self.validator.as_ref().map_or(validated_to, |v| v.validated_height()); - self.drain_eval_results(false).await; - let post_drain_tip = - self.validator.as_ref().map_or(pre_drain_tip, |v| v.validated_height()); - - if post_drain_tip < pre_drain_tip { - tracing::warn!( - validated_to, - rolled_back_to = post_drain_tip, - "eval failure rolled back state during sweep — aborting" - ); - validated_to = post_drain_tip; - // If this rollback pins the frontier, the backoff's - // validation_stuck must name the deferred-eval mode — - // the path the retired apply_state tracker never saw. - stall_detail = StallDetail::script_eval(); - break; - } // Progress report every 1000 blocks during large sweeps let done = height - applied_tip; @@ -1242,7 +1355,11 @@ impl HeaderSync let elapsed = sweep_start.elapsed().as_secs().max(1); let rate = done as f64 / elapsed as f64; let remaining = sweep_to - height; - let eta_secs = if rate > 0.0 { remaining as f64 / rate } else { 0.0 }; + let eta_secs = if rate > 0.0 { + remaining as f64 / rate + } else { + 0.0 + }; tracing::debug!( height, done, @@ -1262,7 +1379,7 @@ impl HeaderSync if self.should_flush(height) { let outcome = try_flush_validator(self.validator.as_ref(), height); complete_store_flush_pair(outcome, &self.store).await; - if let FlushOutcome::Flushed(m) = outcome { + if let Some(m) = outcome.committed_height() { maybe_prune_at_horizon( &self.store, &self.chain, @@ -1277,13 +1394,21 @@ impl HeaderSync } } Err(e) => { - let err_msg = e.to_string(); // Label the stall so the backoff's validation_stuck — if // this frontier wedges for 5 sweeps — names the apply_state // error kind and, for the AVL missing-key case, the key. - let (error_kind, missing_key) = classify_apply_state_error(&err_msg); - stall_detail = StallDetail { error_kind, missing_key }; - tracing::error!(height, error = %err_msg, "apply_state failed"); + // + // Classified from the typed error, never from the rendered + // string below. The string is for the human reading the log; + // routing a contract field through it is how the + // script-failure kind went missing without a compile error + // when deferred evaluation was removed. + let (error_kind, missing_key) = classify_apply_state_error(&e); + stall_detail = StallDetail { + error_kind, + missing_key, + }; + tracing::error!(height, error = %e, "apply_state failed"); break; } } @@ -1298,18 +1423,19 @@ impl HeaderSync // what the prover actually holds; the cache must mirror it in both // directions. Persistence is handled atomically inside state.redb by // `UtxoValidator::apply_state` — no separate hint write needed. - let true_tip = self.validator.as_ref().map_or(validated_to, |v| v.validated_height()); + let true_tip = self + .validator + .as_ref() + .map_or(validated_to, |v| v.validated_height()); // Drive the sweep backoff off the authoritative applied tip. If the // sweep moved the tip past where it started, that is real progress — // clear any backoff. If it did not, the frontier is failing - // deterministically (apply_state error, or an eval rollback that - // pulled the tip back to where it began); arm/escalate the - // exponential delay so the next retry is throttled, and — once the + // deterministically on `apply_state` — which since v0.8.0 includes the + // script failures that used to come back asynchronously; arm/escalate + // the exponential delay so the next retry is throttled, and — once the // frontier has stalled 5 sweeps in a row — emit the contract - // `validation_stuck` event, labelled by `stall_detail`. The backoff - // owns that emission for BOTH modes now (apply_state and the - // deferred-eval rollback the retired tracker never saw). This + // `validation_stuck` event, labelled by `stall_detail`. This // per-engage WARN is the finer-grained signal — once per actual retry, // distinct from `validation_stuck` and deliberately not doubling it. if let Some(stall) = @@ -1351,8 +1477,9 @@ impl HeaderSync ); } } else { - // Cache was stale-ahead of the prover (e.g. a mid-sweep eval - // rollback) — pulled back down to the truth. + // Cache was stale-ahead of the prover (a reorg rollback that + // landed after the cache was last written) — pulled back down + // to the truth. tracing::debug!( state_applied_height = true_tip, previous = prev, @@ -1361,19 +1488,12 @@ impl HeaderSync } } - // After sweep: drain remaining eval results. - // At chain tip (single block), drain synchronously to ensure - // scripts are verified before accepting the next peer block. - let blocking = sweep_size <= 1; - self.drain_eval_results(blocking).await; - // Durable flush at sweep end — catches the tail of blocks that // didn't hit a heap-threshold or max-blocks trigger mid-sweep. The // tip-following path (single block per sweep) always reaches this. - let outcome = - try_flush_validator(self.validator.as_ref(), self.state_applied_height); + let outcome = try_flush_validator(self.validator.as_ref(), self.state_applied_height); complete_store_flush_pair(outcome, &self.store).await; - if let FlushOutcome::Flushed(m) = outcome { + if let Some(m) = outcome.committed_height() { maybe_prune_at_horizon(&self.store, &self.chain, m, self.config.blocks_to_keep).await; } if outcome.advances_last_flush() { @@ -1381,102 +1501,6 @@ impl HeaderSync } } - /// Drain completed script evaluation results from the channel. - /// - /// `blocking`: if true, block until all in-flight evals complete. - /// If false, drain only what's immediately available (non-blocking). - async fn drain_eval_results(&mut self, blocking: bool) { - let mut verified: BTreeSet = BTreeSet::new(); - - while self.evals_in_flight > 0 { - let msg = if blocking { - match self.eval_rx.recv() { - Ok(msg) => msg, - Err(_) => break, - } - } else { - match self.eval_rx.try_recv() { - Ok(msg) => msg, - Err(_) => break, - } - }; - - self.evals_in_flight -= 1; - let (height, result) = msg; - match result { - Ok(()) => { - verified.insert(height); - } - Err(e) => { - tracing::error!( - height, error = %e, - "script evaluation failed — rolling back" - ); - self.handle_eval_failure(height).await; - return; - } - } - } - - // Advance script_verified_height sequentially - let prev = self.script_verified_height; - while verified.contains(&(self.script_verified_height + 1)) { - self.script_verified_height += 1; - verified.remove(&self.script_verified_height); - } - // Persist on every advance. With Durability::None the write hits the - // redb WAL only and gets fsynced when the paired store.flush() runs at - // the next state-flush point — so persisted SVH always tracks - // persisted state without an extra fsync per block. - if self.script_verified_height > prev { - self.store.set_script_verified_height(self.script_verified_height).await; - } - } - - /// Handle a deferred script evaluation failure by rolling back state. - async fn handle_eval_failure(&mut self, failed_height: u32) { - // Drain and discard remaining results - while self.eval_rx.try_recv().is_ok() {} - self.evals_in_flight = 0; - - let rollback_to = failed_height - 1; - - if let Some(v) = self.validator.as_mut() { - if let Some(header) = self.chain.header_at(rollback_to).await { - if let Err(e) = v.reset_to(rollback_to, header.state_root) { - // The rollback failed — the validator did NOT move - // (contract: validated_height/digest/prover unchanged on - // Err). Every watermark stays in place: retreating them - // onto un-rolled state is the gap-wedge hole. The sweep - // backoff retries the resulting stall; no separate retry - // mechanism here. See `../facts/sync.md`. - tracing::error!( - height = rollback_to as u64, - path = "eval_failure", - error = %e, - "validation rollback failed" - ); - return; - } - } else { - tracing::error!(rollback_to, "cannot find header for rollback"); - return; - } - } - - self.state_applied_height = rollback_to; - self.script_verified_height = rollback_to; - self.store.set_script_verified_height(self.script_verified_height).await; - if self.downloaded_height > rollback_to { - self.downloaded_height = rollback_to; - } - - tracing::warn!( - rollback_to, - "state rolled back due to script eval failure" - ); - } - /// Handle a control-plane event (Reorg or NeedModifier). async fn handle_control_event(&mut self, ctrl: DeliveryControl, peer: PeerId) { match ctrl { @@ -1484,12 +1508,24 @@ impl HeaderSync tracing::info!(type_id, "pipeline needs modifier for reorg"); self.request_announced(peer, type_id, vec![id]).await; } - DeliveryControl::Reorg { fork_point, old_tip, new_tip } => { - tracing::info!(fork_point, old_tip, new_tip, "reorg: adjusting section queue and watermark"); + DeliveryControl::Reorg { + fork_point, + old_tip, + new_tip, + } => { + tracing::info!( + fork_point, + old_tip, + new_tip, + "reorg: adjusting section queue and watermark" + ); - // Drain and discard in-flight eval results - while self.eval_rx.try_recv().is_ok() {} - self.evals_in_flight = 0; + // Nothing to drain. Since v0.8.0 a reorg cannot arrive between + // a block's application and its verification, because there is + // no gap between them — `apply_state` evaluates before it + // persists. The generation tagging, the reorder-buffer purge + // and the conditional invalidation this replaces all existed to + // decide which in-flight results a rollback had made wrong. // Reset watermarks if they were above the fork point if self.downloaded_height > fork_point { @@ -1501,12 +1537,12 @@ impl HeaderSync self.downloaded_height = fork_point; } if self.state_applied_height > fork_point { - // The state watermarks may only retreat if the validator + // The state watermark may only retreat if the validator // actually rolled back — on a failed rollback the state // genuinely sits where it was, and retreating the - // watermarks onto un-rolled state is the gap-wedge hole + // watermark onto un-rolled state is the gap-wedge hole // (see `../facts/sync.md`). No validator (light mode) = - // nothing to roll, watermarks are sync-local. + // nothing to roll, the watermark is sync-local. let rolled_back = match self.validator.as_mut() { Some(v) => match self.chain.header_at(fork_point).await { Some(fork_header) => { @@ -1535,8 +1571,6 @@ impl HeaderSync }; if rolled_back { self.state_applied_height = fork_point; - self.script_verified_height = fork_point; - self.store.set_script_verified_height(self.script_verified_height).await; } } @@ -1564,9 +1598,7 @@ impl HeaderSync } // Empty Inv: peer has no more headers - ProtocolMessage::Inv { modifier_type, .. } - if modifier_type == HEADER_TYPE_ID => - { + ProtocolMessage::Inv { modifier_type, .. } if modifier_type == HEADER_TYPE_ID => { let height = self.chain.chain_height().await; tracing::debug!(height, "peer reports no more headers"); EventResult::Synced @@ -1576,7 +1608,11 @@ impl HeaderSync ProtocolMessage::Inv { modifier_type, ids } if is_block_section_type(modifier_type) && !ids.is_empty() => { - tracing::debug!(type_id = modifier_type, count = ids.len(), "block section Inv → requesting"); + tracing::debug!( + type_id = modifier_type, + count = ids.len(), + "block section Inv → requesting" + ); self.request_announced(peer_id, modifier_type, ids).await; EventResult::Continue } @@ -1600,7 +1636,8 @@ impl HeaderSync if let Some(peer_tip) = peer_tip { // Publish the max peer tip we've seen — main crate // reads this for the bootstrap gap decision. - let prev = self.peer_chain_tip + let prev = self + .peer_chain_tip .load(std::sync::atomic::Ordering::Relaxed); if peer_tip > prev { self.peer_chain_tip @@ -1626,8 +1663,7 @@ impl HeaderSync // peer = served from height 1. An empty own chain has // nothing to serve. Runs even when `result` flips to // Synced — a behind sync-peer still gets its Inv. - let peer_strictly_ahead = - peer_tip.is_some_and(|t| t > our_height); + let peer_strictly_ahead = peer_tip.is_some_and(|t| t > our_height); if our_height > 0 && !peer_strictly_ahead { let anchors = sync_info_anchor_ids(&info); let ids = self @@ -1679,7 +1715,10 @@ impl HeaderSync EventResult::Continue } - ProtocolMessage::Inv { modifier_type, ref ids } => { + ProtocolMessage::Inv { + modifier_type, + ref ids, + } => { tracing::debug!( peer = %peer_id, modifier_type, @@ -1716,25 +1755,35 @@ impl HeaderSync ) { // Re-request timed-out modifiers from a different peer if !result.retries.is_empty() - && self.block_request_gate.load(std::sync::atomic::Ordering::Relaxed) + && self + .block_request_gate + .load(std::sync::atomic::Ordering::Relaxed) { let peers = self.transport.outbound_peers().await; for retry in &result.retries { - let target = peers.iter() + let target = peers + .iter() .find(|&&p| p != retry.failed_peer) .copied() .unwrap_or(retry.failed_peer); - self.tracker.mark_requested(&[retry.id], target, retry.type_id); - let _ = self.transport.send_to( - target, - ProtocolMessage::ModifierRequest { - modifier_type: retry.type_id, - ids: vec![retry.id], - }, - ).await; + self.tracker + .mark_requested(&[retry.id], target, retry.type_id); + let _ = self + .transport + .send_to( + target, + ProtocolMessage::ModifierRequest { + modifier_type: retry.type_id, + ids: vec![retry.id], + }, + ) + .await; } - tracing::debug!(count = result.retries.len(), "re-requested timed-out modifiers"); + tracing::debug!( + count = result.retries.len(), + "re-requested timed-out modifiers" + ); } // Re-request evicted modifiers (LRU buffer evictions — headers only) @@ -1759,7 +1808,8 @@ impl HeaderSync async fn rerequest_from_any(&mut self, modifier_type: u8, ids: &[[u8; 32]]) { let peers = self.transport.outbound_peers().await; if let Some(&target) = peers.first() { - self.request_announced(target, modifier_type, ids.to_vec()).await; + self.request_announced(target, modifier_type, ids.to_vec()) + .await; tracing::debug!(count = ids.len(), peer = %target, "re-requested modifiers"); } } @@ -1835,26 +1885,39 @@ impl HeaderSync // ── In-place cache resize (no reopen, no second mmap) ── if let Some(cache_bytes) = self.synced_cache_bytes.take() { - if let Some(ref validator) = self.validator { - if let Err(e) = validator.resize_cache(cache_bytes) { - tracing::error!( - cache_bytes, - error = ?e, - "at-tip: resize_cache failed; continuing with cold-sync cache size" - ); - } else { - tracing::info!( - cache_bytes, - "at-tip: cache resized in-place on existing storage handle" - ); + // Only a validator that owns storage has a cache to resize. Digest + // mode has none, and must NOT reach the success log below — before + // the v0.8.0 trait split the defaulted `resize_cache` returned + // `Ok(())` there and this logged "cache resized in-place" for a + // resize that never happened. That false success is the reason the + // split exists (facts/validation.md). + match self.validator.as_ref().and_then(|v| v.state_persistence()) { + Some(persistence) => { + if let Err(e) = persistence.resize_cache(cache_bytes) { + tracing::error!( + cache_bytes, + error = ?e, + "at-tip: resize_cache failed; continuing with cold-sync cache size" + ); + } else { + tracing::info!( + cache_bytes, + "at-tip: cache resized in-place on existing storage handle" + ); + } } + None => tracing::debug!( + cache_bytes, + "at-tip: validator owns no persistent state; no cache to resize" + ), } } // ── Storage reopen handshake (legacy, kept for digest-mode) ── - if let (Some(req_tx), Some(val_rx)) = - (self.at_tip_request_tx.take(), self.at_tip_validator_rx.take()) - { + if let (Some(req_tx), Some(val_rx)) = ( + self.at_tip_request_tx.take(), + self.at_tip_validator_rx.take(), + ) { if let Some(validator) = self.validator.take() { let height = validator.validated_height(); // Persist any pending in-memory state BEFORE dropping. Without @@ -1864,7 +1927,12 @@ impl HeaderSync // (and the new validator's height field) believe state is at // `height`, leaving an N-block gap where any later block that // spends an output from the gap fails with "Key does not exist". - if let Err(e) = validator.flush() { + // + // A validator that owns no persistent state (digest mode) has + // no write tx to lose, so the absence of `StatePersistence` + // proceeds to the rebuild; only a real `Err` aborts it + // (../facts/sync.md § "At-tip Storage Reopen", step 1). + if let Some(Err(e)) = validator.state_persistence().map(|p| p.flush()) { tracing::error!( height, error = ?e, @@ -1874,7 +1942,10 @@ impl HeaderSync return; } drop(validator); // releases AVL storage so main can reopen it - tracing::info!(height, "at-tip: requesting validator rebuild with synced cache"); + tracing::info!( + height, + "at-tip: requesting validator rebuild with synced cache" + ); if req_tx.send(height).is_err() { tracing::error!("at-tip: failed to signal main; continuing without rebuild"); return; @@ -1890,7 +1961,9 @@ impl HeaderSync self.validator = Some(new_validator); } Err(_) => { - tracing::error!("at-tip: validator rebuild channel closed; sync cannot proceed"); + tracing::error!( + "at-tip: validator rebuild channel closed; sync cannot proceed" + ); } } } @@ -2035,10 +2108,8 @@ impl HeaderSync return; } - let window_end = std::cmp::min( - self.downloaded_height + Self::DOWNLOAD_WINDOW, - chain_height, - ); + let window_end = + std::cmp::min(self.downloaded_height + Self::DOWNLOAD_WINDOW, chain_height); let mut by_type: HashMap> = HashMap::new(); let mut total = 0usize; @@ -2106,7 +2177,9 @@ impl HeaderSync // Diagnostic: log SyncInfo content + first 40 hex bytes for wire analysis if let Ok(info) = self.chain.parse_sync_info(&body) { let heights = C::sync_info_heights(&info); - let hex_prefix: String = body.iter().take(40) + let hex_prefix: String = body + .iter() + .take(40) .map(|b| format!("{:02x}", b)) .collect::>() .join(" "); @@ -2186,7 +2259,7 @@ mod cross_db_flush_tests { use super::*; use ergo_chain_types::{ADDigest, Header}; use ergo_validation::{ - ApplyStateOutcome, BlockValidator, Parameters, ValidationError, + ApplyStateOutcome, BlockValidator, Parameters, StatePersistence, ValidationError, }; use std::sync::Mutex; @@ -2226,14 +2299,6 @@ mod cross_db_flush_tests { unimplemented!("not called in flush-ordering tests") } - async fn script_verified_height(&self) -> Option { - unimplemented!("not called in flush-ordering tests") - } - - async fn set_script_verified_height(&self, _height: u32) { - unimplemented!("not called in flush-ordering tests") - } - async fn validated_height(&self) -> Option { *self.validated_height.lock().unwrap() } @@ -2314,18 +2379,69 @@ mod cross_db_flush_tests { &self.digest } - fn reset_to( - &mut self, - _height: u32, - _digest: ADDigest, - ) -> Result<(), ValidationError> { + fn reset_to(&mut self, _height: u32, _digest: ADDigest) -> Result<(), ValidationError> { unimplemented!("not called in flush-ordering tests") } + fn state_persistence(&self) -> Option<&dyn StatePersistence> { + Some(self) + } + } + + impl StatePersistence for FakeValidator { fn flush(&self) -> Result<(), ValidationError> { self.flush_result .map_err(|s| ValidationError::ProofVerificationFailed(s.to_string())) } + + fn resize_cache(&self, _cache_bytes: usize) -> Result<(), ValidationError> { + unimplemented!("not called in flush-ordering tests") + } + + /// No prover behind this double, so nothing to size. + fn prover_memory_estimate(&self) -> Option { + None + } + } + + /// Digest-mode shape: a validator that owns no persistent state. Its + /// `state_persistence()` is `None`, which is NOT the light-mode + /// no-validator case — the store side of the pair still runs. + struct NoPersistenceValidator { + validated_height: u32, + digest: ADDigest, + } + + impl BlockValidator for NoPersistenceValidator { + fn apply_state( + &mut self, + _header: &Header, + _block_txs: &[u8], + _ad_proofs: Option<&[u8]>, + _extension: &[u8], + _preceding_headers: &[Header], + _active_params: &Parameters, + _expected_boundary_params: Option<&Parameters>, + _expected_proposed_update: Option<&[u8]>, + ) -> Result { + unimplemented!("not called in flush-ordering tests") + } + + fn validated_height(&self) -> u32 { + self.validated_height + } + + fn current_digest(&self) -> &ADDigest { + &self.digest + } + + fn reset_to(&mut self, _height: u32, _digest: ADDigest) -> Result<(), ValidationError> { + unimplemented!("not called in flush-ordering tests") + } + + fn state_persistence(&self) -> Option<&dyn StatePersistence> { + None + } } #[tokio::test] @@ -2339,10 +2455,7 @@ mod cross_db_flush_tests { assert_eq!(outcome, FlushOutcome::Flushed(1_785_000)); assert_eq!( store.calls(), - vec![ - StoreCall::SetValidatedHeight(1_785_000), - StoreCall::Flush, - ], + vec![StoreCall::SetValidatedHeight(1_785_000), StoreCall::Flush,], "set_validated_height MUST precede store.flush() (cross-DB handshake ordering)" ); } @@ -2380,12 +2493,52 @@ mod cross_db_flush_tests { ); } + #[tokio::test] + async fn validator_without_persistence_still_records_validated_height() { + let store = MockStore::new(); + let validator = NoPersistenceValidator { + validated_height: 1_785_000, + digest: ADDigest::zero(), + }; + + // Digest mode: `state_persistence()` is None, so nothing is fsynced — + // but the validator has a real height and the store side of the pair + // runs exactly as after a successful flush. Before the v0.8.0 trait + // split this height reached the store via a defaulted `flush` that + // returned Ok(()) without doing anything; the write must survive the + // split. See ../facts/sync.md § "Flushing a validator that owns no + // state". + let outcome = try_flush_validator(Some(&validator), 1_785_000); + complete_store_flush_pair(outcome, &store).await; + + assert_eq!(outcome, FlushOutcome::NothingToPersist(1_785_000)); + assert_eq!( + store.calls(), + vec![StoreCall::SetValidatedHeight(1_785_000), StoreCall::Flush,], + "nothing to persist is NOT the light-mode case — validated_height \ + MUST still be recorded, before store.flush()" + ); + } + #[test] - fn flush_outcome_advances_last_flush_only_on_success_or_no_validator() { + fn flush_outcome_advances_last_flush_unless_the_flush_failed() { assert!(FlushOutcome::Flushed(42).advances_last_flush()); + assert!(FlushOutcome::NothingToPersist(42).advances_last_flush()); assert!(FlushOutcome::NoValidator.advances_last_flush()); assert!(!FlushOutcome::Failed.advances_last_flush()); } + + #[test] + fn committed_height_covers_flushed_and_nothing_to_persist_only() { + assert_eq!(FlushOutcome::Flushed(42).committed_height(), Some(42)); + assert_eq!( + FlushOutcome::NothingToPersist(42).committed_height(), + Some(42), + "digest mode records and prunes at M exactly as a real flush does" + ); + assert_eq!(FlushOutcome::NoValidator.committed_height(), None); + assert_eq!(FlushOutcome::Failed.committed_height(), None); + } } #[cfg(test)] @@ -2402,9 +2555,9 @@ mod shutdown_flush_tests { use enr_chain::{ChainError, SyncInfo}; use ergo_chain_types::{ADDigest, Header}; use ergo_validation::{ - ApplyStateOutcome, BlockValidator, Parameters, ValidationError, + ApplyStateOutcome, BlockValidator, Parameters, StatePersistence, ValidationError, }; - use std::sync::atomic::{AtomicBool, AtomicU32}; + use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64}; use std::sync::Mutex; #[derive(Debug, Clone, PartialEq, Eq)] @@ -2419,7 +2572,9 @@ mod shutdown_flush_tests { impl MockStore { fn new() -> Self { - Self { calls: Mutex::new(Vec::new()) } + Self { + calls: Mutex::new(Vec::new()), + } } fn calls(&self) -> Vec { @@ -2436,14 +2591,6 @@ mod shutdown_flush_tests { unreachable!("not called in shutdown-flush tests") } - async fn script_verified_height(&self) -> Option { - None - } - - async fn set_script_verified_height(&self, _height: u32) { - unreachable!("not called when script_verified_height returns None") - } - async fn validated_height(&self) -> Option { None } @@ -2529,19 +2676,30 @@ mod shutdown_flush_tests { &self.digest } - fn reset_to( - &mut self, - _height: u32, - _digest: ADDigest, - ) -> Result<(), ValidationError> { + fn reset_to(&mut self, _height: u32, _digest: ADDigest) -> Result<(), ValidationError> { unreachable!("not called in shutdown-flush tests") } + fn state_persistence(&self) -> Option<&dyn StatePersistence> { + Some(self) + } + } + + impl StatePersistence for FakeValidator { fn flush(&self) -> Result<(), ValidationError> { *self.flush_count.lock().unwrap() += 1; self.flush_result .map_err(|s| ValidationError::ProofVerificationFailed(s.to_string())) } + + fn resize_cache(&self, _cache_bytes: usize) -> Result<(), ValidationError> { + unreachable!("not called in shutdown-flush tests") + } + + /// No prover behind this double, so nothing to size. + fn prover_memory_estimate(&self) -> Option { + None + } } /// Transport that models production behavior: `outbound_peers` @@ -2640,11 +2798,7 @@ mod shutdown_flush_tests { unreachable!("not called when state_type=Utxo") } - async fn is_better_nipopow( - &self, - _this: &[u8], - _than: &[u8], - ) -> Result { + async fn is_better_nipopow(&self, _this: &[u8], _than: &[u8]) -> Result { unreachable!("not called in shutdown-flush tests") } @@ -2686,6 +2840,7 @@ mod shutdown_flush_tests { Arc::new(AtomicU32::new(0)), Arc::new(AtomicBool::new(false)), Arc::new(AtomicU32::new(0)), + Arc::new(AtomicU64::new(0)), shutdown_rx, ) } @@ -2699,7 +2854,9 @@ mod shutdown_flush_tests { shutdown_rx, ); - shutdown_tx.send(()).expect("sync must be polling shutdown_rx"); + shutdown_tx + .send(()) + .expect("sync must be polling shutdown_rx"); sync.run().await; assert_eq!( @@ -2709,10 +2866,7 @@ mod shutdown_flush_tests { ); assert_eq!( sync.store.calls(), - vec![ - StoreCall::SetValidatedHeight(1_785_000), - StoreCall::Flush, - ], + vec![StoreCall::SetValidatedHeight(1_785_000), StoreCall::Flush,], "shutdown_flush must mirror the end-of-sweep ordering: \ set_validated_height(M) precedes store.flush(), where \ M = validator.validated_height()" @@ -2783,15 +2937,14 @@ mod blocks_to_keep_tests { //! //! See `../facts/sync.md` § "Block Body Retention". use super::*; + use crate::test_support::capture_warn; use enr_chain::{ChainError, SyncInfo}; use ergo_chain_types::{ADDigest, Header}; use ergo_validation::{ - ApplyStateOutcome, BlockValidator, Parameters, ValidationError, + ApplyStateOutcome, BlockValidator, Parameters, StatePersistence, ValidationError, }; - use std::io; + use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64}; use std::sync::{Arc, Mutex}; - use std::sync::atomic::{AtomicBool, AtomicU32}; - use tracing_subscriber::fmt::MakeWriter; /// Mock store recording prune + min_height calls in addition to the /// flush-pair calls. Tests assert on the recorded sequence. @@ -2836,14 +2989,6 @@ mod blocks_to_keep_tests { unreachable!("not called in blocks_to_keep tests") } - async fn script_verified_height(&self) -> Option { - None - } - - async fn set_script_verified_height(&self, _height: u32) { - unreachable!("not called when script_verified_height returns None") - } - async fn validated_height(&self) -> Option { None } @@ -2859,22 +3004,18 @@ mod blocks_to_keep_tests { self.calls.lock().unwrap().push(StoreCall::Flush); } - async fn prune_below_height( - &self, - horizon: u32, - type_ids: &[u8], - ) -> Result { - self.calls.lock().unwrap().push(StoreCall::PruneBelowHeight { - horizon, - type_ids: type_ids.to_vec(), - }); + async fn prune_below_height(&self, horizon: u32, type_ids: &[u8]) -> Result { + self.calls + .lock() + .unwrap() + .push(StoreCall::PruneBelowHeight { + horizon, + type_ids: type_ids.to_vec(), + }); Ok(0) } - async fn min_height_present( - &self, - type_id: u8, - ) -> Result, String> { + async fn min_height_present(&self, type_id: u8) -> Result, String> { if type_id == BLOCK_TRANSACTIONS_TYPE_ID { Ok(*self.min_block_txs_height.lock().unwrap()) } else { @@ -2920,22 +3061,65 @@ mod blocks_to_keep_tests { &self.digest } - fn reset_to( - &mut self, - _height: u32, - _digest: ADDigest, - ) -> Result<(), ValidationError> { + fn reset_to(&mut self, _height: u32, _digest: ADDigest) -> Result<(), ValidationError> { unreachable!("not called in blocks_to_keep tests") } + fn state_persistence(&self) -> Option<&dyn StatePersistence> { + Some(self) + } + } + + impl StatePersistence for FakeValidator { fn flush(&self) -> Result<(), ValidationError> { Ok(()) } + + fn resize_cache(&self, _cache_bytes: usize) -> Result<(), ValidationError> { + unreachable!("not called in blocks_to_keep tests") + } + + /// No prover behind this double, so nothing to size. + fn prover_memory_estimate(&self) -> Option { + None + } } - struct HangingTransport; + /// Digest-mode shape: no persistent state, so nothing to fsync — but the + /// flush pair still records and prunes at the validator's height. + struct NoPersistenceValidator(FakeValidator); - impl SyncTransport for HangingTransport { + impl BlockValidator for NoPersistenceValidator { + fn apply_state( + &mut self, + _h: &Header, + _bt: &[u8], + _ap: Option<&[u8]>, + _e: &[u8], + _ph: &[Header], + _p: &Parameters, + _bp: Option<&Parameters>, + _pu: Option<&[u8]>, + ) -> Result { + unreachable!("not called in blocks_to_keep tests") + } + fn validated_height(&self) -> u32 { + self.0.validated_height + } + fn current_digest(&self) -> &ADDigest { + &self.0.digest + } + fn reset_to(&mut self, _h: u32, _d: ADDigest) -> Result<(), ValidationError> { + unreachable!("not called in blocks_to_keep tests") + } + fn state_persistence(&self) -> Option<&dyn StatePersistence> { + None + } + } + + struct HangingTransport; + + impl SyncTransport for HangingTransport { async fn send_to( &self, _peer: PeerId, @@ -3025,11 +3209,7 @@ mod blocks_to_keep_tests { unreachable!() } - async fn is_better_nipopow( - &self, - _this: &[u8], - _than: &[u8], - ) -> Result { + async fn is_better_nipopow(&self, _this: &[u8], _than: &[u8]) -> Result { unreachable!() } @@ -3070,6 +3250,7 @@ mod blocks_to_keep_tests { Arc::new(AtomicU32::new(0)), Arc::new(AtomicBool::new(false)), Arc::new(AtomicU32::new(0)), + Arc::new(AtomicU64::new(0)), shutdown_rx, ) } @@ -3083,9 +3264,7 @@ mod blocks_to_keep_tests { fn last_prune_call(calls: &[StoreCall]) -> Option<(u32, Vec)> { calls.iter().rev().find_map(|c| match c { - StoreCall::PruneBelowHeight { horizon, type_ids } => { - Some((*horizon, type_ids.clone())) - } + StoreCall::PruneBelowHeight { horizon, type_ids } => Some((*horizon, type_ids.clone())), _ => None, }) } @@ -3101,7 +3280,9 @@ mod blocks_to_keep_tests { config_with_keep(50), Some(FakeValidator::at(1000)), MockStore::new(), - FixedVotingLengthChain { voting_length: 1024 }, + FixedVotingLengthChain { + voting_length: 1024, + }, shutdown_rx, ); @@ -3133,7 +3314,9 @@ mod blocks_to_keep_tests { config_with_keep(50), Some(FakeValidator::at(2500)), MockStore::new(), - FixedVotingLengthChain { voting_length: 1024 }, + FixedVotingLengthChain { + voting_length: 1024, + }, shutdown_rx, ); @@ -3157,7 +3340,9 @@ mod blocks_to_keep_tests { config_with_keep(-1), Some(FakeValidator::at(1000)), MockStore::new(), - FixedVotingLengthChain { voting_length: 1024 }, + FixedVotingLengthChain { + voting_length: 1024, + }, shutdown_rx, ); @@ -3201,9 +3386,23 @@ mod blocks_to_keep_tests { fn reset_to(&mut self, _h: u32, _d: ADDigest) -> Result<(), ValidationError> { unreachable!() } + fn state_persistence(&self) -> Option<&dyn StatePersistence> { + Some(self) + } + } + + impl StatePersistence for FailingValidator { fn flush(&self) -> Result<(), ValidationError> { Err(ValidationError::ProofVerificationFailed("nope".into())) } + fn resize_cache(&self, _cache_bytes: usize) -> Result<(), ValidationError> { + unreachable!() + } + + /// No prover behind this double, so nothing to size. + fn prover_memory_estimate(&self) -> Option { + None + } } let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); @@ -3218,7 +3417,9 @@ mod blocks_to_keep_tests { >::new( config_with_keep(50), HangingTransport, - FixedVotingLengthChain { voting_length: 1024 }, + FixedVotingLengthChain { + voting_length: 1024, + }, MockStore::new(), Some(FailingValidator(FakeValidator::at(1000))), progress_rx, @@ -3229,6 +3430,7 @@ mod blocks_to_keep_tests { Arc::new(AtomicU32::new(0)), Arc::new(AtomicBool::new(false)), Arc::new(AtomicU32::new(0)), + Arc::new(AtomicU64::new(0)), shutdown_rx, ); @@ -3241,58 +3443,55 @@ mod blocks_to_keep_tests { ); } - // ----- startup WARN capture ----- - - #[derive(Clone, Default)] - struct CaptureWriter { - buf: Arc>>, - } - - impl CaptureWriter { - fn captured(&self) -> String { - String::from_utf8(self.buf.lock().unwrap().clone()).unwrap() - } - } + #[tokio::test] + async fn prune_still_runs_when_validator_owns_no_persistent_state() { + // Digest mode: state_persistence() is None → FlushOutcome:: + // NothingToPersist(M) → prune at the same horizon a real flush would + // use. Before the v0.8.0 trait split this path went through the + // defaulted `flush` returning Ok(()) and reached Flushed(M), so + // pruning happened; folding the case into NoValidator would silently + // stop a digest node from pruning. The prune call sites use `if let`, + // which the compiler does not exhaustiveness-check — this test is the + // guard the compiler cannot be. + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let (_progress_tx, progress_rx) = mpsc::channel(1); + let (_dc_tx, delivery_control_rx) = mpsc::unbounded_channel::(); + let (_dd_tx, delivery_data_rx) = mpsc::channel::(1); + let mut sync = HeaderSync::< + HangingTransport, + FixedVotingLengthChain, + MockStore, + NoPersistenceValidator, + >::new( + config_with_keep(50), + HangingTransport, + FixedVotingLengthChain { + voting_length: 1024, + }, + MockStore::new(), + Some(NoPersistenceValidator(FakeValidator::at(1000))), + progress_rx, + delivery_control_rx, + delivery_data_rx, + None, + None, + Arc::new(AtomicU32::new(0)), + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicU32::new(0)), + Arc::new(AtomicU64::new(0)), + shutdown_rx, + ); - impl io::Write for CaptureWriter { - fn write(&mut self, b: &[u8]) -> io::Result { - self.buf.lock().unwrap().extend_from_slice(b); - Ok(b.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } + shutdown_tx.send(()).unwrap(); + sync.run().await; - impl<'a> MakeWriter<'a> for CaptureWriter { - type Writer = CaptureWriter; - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } + let calls = sync.store.calls(); + let (horizon, _type_ids) = last_prune_call(&calls) + .expect("nothing-to-persist MUST still prune — same horizon as a real flush"); + assert_eq!(horizon, 951, "raw horizon = validated_height - keep + 1"); } - // Takes the future directly rather than a closure so the caller can - // pass `sync.maybe_warn_reclaimable_bodies()` — a future borrowing - // `&mut sync` — without wrapping it in `move || async move {…}` to - // satisfy capture rules. The subscriber's `_guard` outlives the - // `await`, so emits from the future are captured. - async fn capture_warn(f: Fut) -> String - where - Fut: std::future::Future, - { - let writer = CaptureWriter::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(writer.clone()) - .without_time() - .with_ansi(false) - .with_target(false) - .with_max_level(tracing::Level::WARN) - .finish(); - let _guard = tracing::subscriber::set_default(subscriber); - f.await; - drop(_guard); - writer.captured() - } + // ----- startup WARN capture ----- #[tokio::test] async fn startup_warn_fires_when_archive_predates_horizon() { @@ -3306,7 +3505,9 @@ mod blocks_to_keep_tests { config_with_keep(100), Some(FakeValidator::at(1000)), MockStore::with_min_block_txs(1), - FixedVotingLengthChain { voting_length: 1024 }, + FixedVotingLengthChain { + voting_length: 1024, + }, shutdown_rx, ); @@ -3331,7 +3532,9 @@ mod blocks_to_keep_tests { config_with_keep(100), Some(FakeValidator::at(1000)), MockStore::with_min_block_txs(901), - FixedVotingLengthChain { voting_length: 1024 }, + FixedVotingLengthChain { + voting_length: 1024, + }, shutdown_rx, ); @@ -3352,7 +3555,9 @@ mod blocks_to_keep_tests { config_with_keep(-1), Some(FakeValidator::at(1000)), MockStore::with_min_block_txs(1), - FixedVotingLengthChain { voting_length: 1024 }, + FixedVotingLengthChain { + voting_length: 1024, + }, shutdown_rx, ); @@ -3374,7 +3579,9 @@ mod blocks_to_keep_tests { config_with_keep(100), None, MockStore::with_min_block_txs(1), - FixedVotingLengthChain { voting_length: 1024 }, + FixedVotingLengthChain { + voting_length: 1024, + }, shutdown_rx, ); @@ -3393,23 +3600,32 @@ mod sweep_resume_tests { //! //! The bug: the sweep derived its start height from //! `state_applied_height` (a cache) instead of the validator's true - //! applied tip (`validated_height()`). A mid-sweep deferred-eval - //! rollback could leave the cache ahead of the prover; every later - //! sweep then fed `apply_state` a non-consecutive block, which the - //! validator's consecutiveness guard correctly rejected - //! (`expected N, got N+k`), looping forever even though the skipped - //! blocks were on disk. + //! applied tip (`validated_height()`). A rollback could leave the cache + //! ahead of the prover; every later sweep then fed `apply_state` a + //! non-consecutive block, which the validator's consecutiveness guard + //! correctly rejected (`expected N, got N+k`), looping forever even + //! though the skipped blocks were on disk. + //! + //! The rollback in question was a mid-sweep deferred-eval one when this + //! wedge was found. That path is gone as of v0.8.0, but the reorg + //! rollback reaches the same state and the anchoring is what prevents it + //! either way, so these tests outlive the mechanism that first exposed + //! them. //! //! The fix anchors both the sweep start AND the post-sweep cache on //! `validated_height()`. These tests construct the desynced state //! directly and assert the sweep resumes at `applied_tip + 1` and //! applies the intervening on-disk blocks instead of wedging. use super::*; + use crate::test_support::capture_async; use enr_chain::{ChainError, SyncInfo}; use ergo_chain_types::{ADDigest, Header}; - use ergo_validation::{ApplyStateOutcome, BlockValidator, Parameters, ValidationError}; - use std::sync::atomic::{AtomicBool, AtomicU32}; + use ergo_validation::{ + ApplyStateOutcome, BlockValidator, Parameters, StatePersistence, ValidationError, + }; + use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64}; use std::sync::Arc; + use tracing::level_filters::LevelFilter; fn fake_header(height: u32) -> Header { use ergo_chain_types::*; @@ -3511,14 +3727,16 @@ mod sweep_resume_tests { } let expected = self.validated_height + 1; if header.height != expected { - return Err(ValidationError::HeightMismatch { expected, got: header.height }); + return Err(ValidationError::HeightMismatch { + expected, + got: header.height, + }); } self.validated_height = header.height; self.applied.push(header.height); Ok(ApplyStateOutcome { epoch_boundary_params: None, epoch_boundary_proposed_update: None, - deferred_eval: None, }) } @@ -3540,6 +3758,12 @@ mod sweep_resume_tests { self.validated_height = height; Ok(()) } + + /// These tests assert sweep/resume behaviour, not durability; there is + /// no storage to flush and none of them look at the flush pair. + fn state_persistence(&self) -> Option<&dyn StatePersistence> { + None + } } /// Chain that hands out a header for every height up to `tip` and is @@ -3638,10 +3862,6 @@ mod sweep_resume_tests { async fn get_modifier(&self, _type_id: u8, _id: &[u8; 32]) -> Option> { Some(vec![0]) } - async fn script_verified_height(&self) -> Option { - None - } - async fn set_script_verified_height(&self, _height: u32) {} async fn validated_height(&self) -> Option { None } @@ -3677,22 +3897,46 @@ mod sweep_resume_tests { } } - fn build_sync( - validator: SweepValidator, - ) -> HeaderSync { + type SweepSync = HeaderSync; + + fn build_sync(validator: SweepValidator) -> SweepSync { build_sync_with_chain(validator, SweepChain::unbounded()) } - fn build_sync_with_chain( + fn build_sync_with_chain(validator: SweepValidator, chain: SweepChain) -> SweepSync { + build_sync_full( + validator, + chain, + SyncConfig::default(), + Arc::new(AtomicU64::new(0)), + ) + } + + /// Same harness, but hands back the published window atomic so a test can + /// observe what the sweep wrote into it. + fn build_sync_with_window_sink(validator: SweepValidator) -> (SweepSync, Arc) { + let sink = Arc::new(AtomicU64::new(0)); + let sync = build_sync_full( + validator, + SweepChain::unbounded(), + SyncConfig::default(), + Arc::clone(&sink), + ); + (sync, sink) + } + + fn build_sync_full( validator: SweepValidator, chain: SweepChain, - ) -> HeaderSync { + config: SyncConfig, + window_sink: Arc, + ) -> SweepSync { let (_shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let (_progress_tx, progress_rx) = mpsc::channel(1); let (_dc_tx, delivery_control_rx) = mpsc::unbounded_channel::(); let (_dd_tx, delivery_data_rx) = mpsc::channel::(1); HeaderSync::new( - SyncConfig::default(), + config, SweepTransport, chain, SweepStore, @@ -3705,6 +3949,7 @@ mod sweep_resume_tests { Arc::new(AtomicU32::new(0)), Arc::new(AtomicBool::new(false)), Arc::new(AtomicU32::new(0)), + window_sink, shutdown_rx, ) } @@ -3730,7 +3975,11 @@ mod sweep_resume_tests { "sweep MUST resume at applied_tip+1 (2666) and apply consecutively, \ not skip to the stale cache height" ); - assert_eq!(v.validated_height(), 2670, "validator advanced to the downloaded tip"); + assert_eq!( + v.validated_height(), + 2670, + "validator advanced to the downloaded tip" + ); assert_eq!( sync.state_applied_height, 2670, "cache reconciled to the validator's true tip — no lingering desync" @@ -3749,7 +3998,10 @@ mod sweep_resume_tests { sync.advance_state_applied_height().await; let v = sync.validator.as_ref().unwrap(); - assert!(v.applied.is_empty(), "no blocks past the applied tip — nothing to apply"); + assert!( + v.applied.is_empty(), + "no blocks past the applied tip — nothing to apply" + ); assert_eq!( sync.state_applied_height, 2665, "stale-ahead cache reconciled down to the validator tip" @@ -3781,101 +4033,69 @@ mod sweep_resume_tests { vec![2668, 2669, 2670], "sweep re-applies from the rolled-back tip + 1, not from the stale cache" ); - assert_eq!(sync.state_applied_height, 2670, "cache back in sync with the prover"); - } - - #[tokio::test] - async fn eval_failure_rollback_ok_retreats_watermarks() { - // The normal eval-failure path: scripts failed at 2669, the storage - // rollback succeeds, and all three watermarks retreat to 2668 with - // the validator. - let mut sync = build_sync(SweepValidator::at(2670)); - sync.state_applied_height = 2670; - sync.script_verified_height = 2670; - sync.downloaded_height = 2670; - - sync.handle_eval_failure(2669).await; - - let v = sync.validator.as_ref().unwrap(); - assert_eq!(v.resets, vec![2668], "rollback targeted failed_height - 1"); - assert_eq!(v.validated_height(), 2668, "validator rolled back"); - assert_eq!(sync.state_applied_height, 2668); - assert_eq!(sync.script_verified_height, 2668); - assert_eq!(sync.downloaded_height, 2668); - } - - #[tokio::test] - async fn eval_failure_rollback_err_leaves_watermarks_untouched() { - // The gap-wedge hole, closed: the storage rollback FAILS, so the - // validator did not move — every watermark must stay exactly where - // it was. Retreating them would make sync believe state sits at - // 2668 while the prover still holds 2670 (the old logged-and- - // swallowed behavior). The sweep backoff owns the retry; this - // handler just logs loud and returns. - let mut sync = build_sync(SweepValidator::at(2670).failing_reset()); - sync.state_applied_height = 2670; - sync.script_verified_height = 2670; - sync.downloaded_height = 2670; - - sync.handle_eval_failure(2669).await; - - let v = sync.validator.as_ref().unwrap(); - assert_eq!(v.resets, vec![2668], "rollback was attempted"); - assert_eq!(v.validated_height(), 2670, "validator unmoved on Err"); - assert_eq!(sync.state_applied_height, 2670, "state watermark NOT retreated"); - assert_eq!(sync.script_verified_height, 2670, "script watermark NOT retreated"); - assert_eq!(sync.downloaded_height, 2670, "download watermark NOT retreated"); + assert_eq!( + sync.state_applied_height, 2670, + "cache back in sync with the prover" + ); } #[tokio::test] async fn reorg_rollback_ok_resets_then_revalidates_new_branch() { // Successful reorg rollback: validator rolls to the fork point, the - // state watermarks follow, and the handler's immediate rescan + // state watermark follows, and the handler's immediate rescan // re-applies the new branch's on-disk blocks from fork_point + 1. - let mut sync = - build_sync_with_chain(SweepValidator::at(2670), SweepChain::at_tip(2670)); + let mut sync = build_sync_with_chain(SweepValidator::at(2670), SweepChain::at_tip(2670)); sync.state_applied_height = 2670; - sync.script_verified_height = 2670; sync.downloaded_height = 2670; sync.handle_control_event( - DeliveryControl::Reorg { fork_point: 2667, old_tip: 2670, new_tip: 2670 }, + DeliveryControl::Reorg { + fork_point: 2667, + old_tip: 2670, + new_tip: 2670, + }, PeerId(0), ) .await; let v = sync.validator.as_ref().unwrap(); - assert_eq!(v.resets, vec![2667], "validator rolled back to the fork point"); + assert_eq!( + v.resets, + vec![2667], + "validator rolled back to the fork point" + ); assert_eq!( v.applied, vec![2668, 2669, 2670], "rescan re-applied the new branch from fork_point + 1" ); - assert_eq!(sync.state_applied_height, 2670, "sweep re-advanced after the rollback"); assert_eq!( - sync.script_verified_height, 2667, - "scripts above the fork point re-verify only via real evals" + sync.state_applied_height, 2670, + "sweep re-advanced after the rollback" ); } #[tokio::test] async fn reorg_rollback_err_keeps_state_watermarks() { // Same reorg, but the storage rollback fails: the validator stays - // at 2670, so state_applied/script_verified MUST NOT retreat to the - // fork point — and nothing may be re-applied onto the un-rolled - // state. (downloaded_height legitimately resets and rescans: it - // tracks the store against the already-switched header chain, not - // the prover — facts/validation.md reorg steps 2 vs 5.) + // at 2670, so state_applied_height MUST NOT retreat to the fork + // point — and nothing may be re-applied onto the un-rolled state. + // (downloaded_height legitimately resets and rescans: it tracks the + // store against the already-switched header chain, not the prover — + // facts/validation.md reorg steps 2 vs 5.) let mut sync = build_sync_with_chain( SweepValidator::at(2670).failing_reset(), SweepChain::at_tip(2670), ); sync.state_applied_height = 2670; - sync.script_verified_height = 2670; sync.downloaded_height = 2670; sync.handle_control_event( - DeliveryControl::Reorg { fork_point: 2667, old_tip: 2670, new_tip: 2670 }, + DeliveryControl::Reorg { + fork_point: 2667, + old_tip: 2670, + new_tip: 2670, + }, PeerId(0), ) .await; @@ -3883,31 +4103,44 @@ mod sweep_resume_tests { let v = sync.validator.as_ref().unwrap(); assert_eq!(v.resets, vec![2667], "rollback was attempted"); assert_eq!(v.validated_height(), 2670, "validator unmoved on Err"); - assert!(v.applied.is_empty(), "nothing re-applied onto un-rolled state"); - assert_eq!(sync.state_applied_height, 2670, "state watermark NOT retreated"); - assert_eq!(sync.script_verified_height, 2670, "script watermark NOT retreated"); + assert!( + v.applied.is_empty(), + "nothing re-applied onto un-rolled state" + ); + assert_eq!( + sync.state_applied_height, 2670, + "state watermark NOT retreated" + ); } #[tokio::test] async fn stalled_sweep_arms_backoff_and_gate_suppresses_retry() { // Frontier 2665 is wedged: block 2666 fails apply_state every attempt - // (modelling a deterministic divergence — equally an eval rollback, - // since the backoff keys on the tip stall, not the error). Blocks - // 2666..=2670 are all on disk, so without the gate the sweep would - // re-run at full tilt. + // (modelling a deterministic divergence — since v0.8.0 that includes a + // script rejection, which now comes back as an `apply_state` Err like + // any other). Blocks 2666..=2670 are all on disk, so without the gate + // the sweep would re-run at full tilt. let mut sync = build_sync(SweepValidator::at(2665).failing_at(2666)); sync.downloaded_height = 2670; // First sweep: one attempt at 2666, it stalls, the tip stays pinned, // and the backoff arms at attempt 1. sync.advance_state_applied_height().await; - assert_eq!(sync.validator.as_ref().unwrap().attempts, 1, "sweep made one attempt"); + assert_eq!( + sync.validator.as_ref().unwrap().attempts, + 1, + "sweep made one attempt" + ); assert_eq!( sync.validator.as_ref().unwrap().validated_height(), 2665, "deterministic failure left the tip pinned" ); - assert_eq!(sync.sweep_backoff.consecutive(), 1, "non-advancing sweep armed the backoff"); + assert_eq!( + sync.sweep_backoff.consecutive(), + 1, + "non-advancing sweep armed the backoff" + ); // Immediate re-entry, well inside the backoff window: the gate must // short-circuit the sweep — no second apply_state attempt, no @@ -3919,7 +4152,11 @@ mod sweep_resume_tests { 1, "gate suppressed the retry inside the backoff window" ); - assert_eq!(sync.sweep_backoff.consecutive(), 1, "still attempt 1 — no fresh stall recorded"); + assert_eq!( + sync.sweep_backoff.consecutive(), + 1, + "still attempt 1 — no fresh stall recorded" + ); } #[tokio::test] @@ -3937,7 +4174,286 @@ mod sweep_resume_tests { 2670, "healthy sweep caught up to the downloaded tip" ); - assert_eq!(sync.sweep_backoff.consecutive(), 0, "progress arms no backoff"); + assert_eq!( + sync.sweep_backoff.consecutive(), + 0, + "progress arms no backoff" + ); + } + + // ---- journal-event conformance (`../facts/journal-events.md`) ---- + // + // Every assertion below runs the REAL emit site through this module's + // harness and reads the rendered line. That distinction is the whole point + // of the section: the conformance tests these replace called `info!()` with + // their own copy of the marker and then asserted the output contained that + // copy, which tests `tracing`'s formatter against itself and cannot fail + // when an emit site drifts. Two of the events below shipped not matching + // the contract under a green suite for exactly that reason. + // + // Break a marker or drop a field in `src/state.rs` and these go red. + + /// Assert `output` renders `key=value` as a **whole field**. + /// + /// `contains("height=1785500")` is not sufficient and the difference is not + /// pedantic: it also matches `tip_height=1785500` — a renamed field, which + /// breaks a consumer exactly as hard as a dropped one — and it matches + /// `height=17855001`. Fields in the default formatter follow the message + /// and are space-separated, so a real field is a whole whitespace token, + /// which is also how a consumer's parser has to find it. Values containing + /// spaces (a Display-formatted error) need a substring assertion instead. + fn has_field(output: &str, key: &str, value: &str) -> bool { + let want = format!("{key}={value}"); + output.split_whitespace().any(|token| token == want) + } + + #[track_caller] + fn assert_field(output: &str, key: &str, value: &str) { + assert!( + has_field(output, key, value), + "missing field {key}={value}: {output}" + ); + } + + #[test] + fn has_field_rejects_a_renamed_or_truncated_field() { + // The self-check for the assertion helper: without it these three all + // look like a present `height=1785500`. + assert!(has_field( + " INFO chain tip reached height=1785500", + "height", + "1785500" + )); + assert!( + !has_field( + " INFO chain tip reached tip_height=1785500", + "height", + "1785500" + ), + "a renamed field must not satisfy the assertion" + ); + assert!( + !has_field( + " INFO chain tip reached height=17855001", + "height", + "1785500" + ), + "a different value must not satisfy the assertion" + ); + } + + /// `validation_rollback_failed` — ERROR, `path="reorg"`. + /// + /// ⚠ The contract lists `path` as `eval_failure|reorg`. The + /// `eval_failure` emit site went with deferred evaluation in v0.8.0 — + /// `handle_eval_failure` was the only producer of that value — so the + /// reorg path is now the event's sole emitter. `facts/journal-events.md` + /// still documents both values and needs the narrowing; flagged to main. + #[tokio::test] + async fn journal_validation_rollback_failed_conforms() { + let mut sync = build_sync_with_chain( + SweepValidator::at(2670).failing_reset(), + SweepChain::at_tip(2670), + ); + sync.state_applied_height = 2670; + sync.downloaded_height = 2670; + + let output = capture_async( + LevelFilter::ERROR, + sync.handle_control_event( + DeliveryControl::Reorg { + fork_point: 2668, + old_tip: 2670, + new_tip: 2671, + }, + PeerId(0), + ), + ) + .await; + + assert!( + output.contains("validation rollback failed"), + "missing marker: {output}" + ); + // The rollback TARGET (the fork point), not the tip it failed to leave. + assert_field(&output, "height", "2668"); + assert_field(&output, "path", "\"reorg\""); + // `ValidationError`'s Display, not the bare message the validator + // constructed — the mirror this replaces asserted the bare string and + // passed, because it was matching its own `info!()` rather than the + // emit. The contract says "Display-formatted"; this is what that is. + assert!( + output.contains("error=UTXO state operation failed: rollback to height 2668 failed"), + "missing the Display-formatted underlying error: {output}" + ); + } + + /// `validation_sweep_started` / `block_applied` / `validation_sweep_complete` + /// in one pass — they are emitted by one sweep and a test that drives the + /// sweep gets all three. + #[tokio::test] + async fn journal_sweep_and_block_applied_conform() { + // The sweep markers are gated on `sweep_size > 100`, so the window has + // to be a real catch-up sweep rather than a tip follow. + let mut sync = build_sync(SweepValidator::at(1000)); + sync.downloaded_height = 1150; + + let output = capture_async(LevelFilter::INFO, sync.advance_state_applied_height()).await; + + assert!( + output.contains("VALIDATION SWEEP STARTED"), + "missing started marker: {output}" + ); + assert!( + output.contains("VALIDATION SWEEP COMPLETE"), + "missing complete marker: {output}" + ); + // The marker is the literal prefix. The emit shape was once + // "=== VALIDATION SWEEP STARTED ===", which prefix-matches nothing. + assert!( + !output.contains("==="), + "markers are plain text, not decorated: {output}" + ); + assert_field(&output, "from", "1001"); + assert_field(&output, "to", "1150"); + assert_field(&output, "blocks", "150"); + + // `block applied` once per block that advanced the tip, carrying the + // height and the 32-byte hex id. `fake_header` derives the id from the + // height, so this pins the Display rendering of a real `BlockId`. + assert_eq!( + output.matches("block applied").count(), + 150, + "one per applied block: {output}" + ); + let applied = output + .lines() + .find(|l| l.contains("block applied")) + .expect("counted above"); + assert_field(applied, "height", "1001"); + // fake_header(1001): BlockId(Digest32::from([1001 as u8; 32])) → 0xe9. + assert_field(applied, "id", &"e9".repeat(32)); + } + + /// `chain_tip_reached` — INFO, emitted on entry to `synced()`. + /// + /// `synced()` is a `select!` loop, but it is driveable here: the first + /// `ticker.tick()` completes immediately, finds `outbound_peers()` empty, + /// and returns. The emit is above the loop, so it has already fired. + #[tokio::test] + async fn journal_chain_tip_reached_conforms() { + let mut sync = + build_sync_with_chain(SweepValidator::at(1_785_500), SweepChain::at_tip(1_785_500)); + sync.downloaded_height = 1_785_500; + + let output = capture_async(LevelFilter::INFO, sync.synced()).await; + + assert!( + output.contains("chain tip reached"), + "missing marker: {output}" + ); + assert_field(&output, "height", "1785500"); + } + + // ---- Window memory attribution (`facts/sync.md` § "Memory attribution") + // + // These live here rather than in a module of their own because the thing + // under test fires after an applied block, and this is the harness that + // applies blocks. + + #[tokio::test] + async fn window_estimate_tracks_the_delivery_tracker_and_falls_back_down() { + // The symptom under investigation is heap that only ever rises, so the + // figure has to be shown to come back down. It counts the tracker's + // live entries, so it rises as requests go in flight and falls as they + // are delivered. + let (mut sync, _sink) = build_sync_with_window_sink(SweepValidator::at(100)); + + let idle = sync.window_memory_estimate().expect("always computable"); + assert_eq!(idle.tracker_bytes, 0); + assert_eq!(idle.tracker_entries, 0); + + let ids: Vec<[u8; 32]> = (0u8..150).map(|n| [n; 32]).collect(); + sync.tracker.mark_requested(&ids, PeerId(1), 102); + + let loaded = sync.window_memory_estimate().unwrap(); + assert_eq!(loaded.tracker_entries, 150, "150 sections in flight"); + assert!( + loaded.tracker_bytes > 0, + "in-flight requests must report bytes" + ); + + // Fewer in flight ⇒ proportionally fewer bytes. Proves the figure is + // derived from the structure and not from a constant. + let (mut small, _) = build_sync_with_window_sink(SweepValidator::at(100)); + small.tracker.mark_requested(&ids[..15], PeerId(1), 102); + let small_est = small.window_memory_estimate().unwrap(); + assert_eq!( + small_est.tracker_bytes * 10, + loaded.tracker_bytes, + "15 in flight ({}) must be exactly a tenth of 150 in flight ({})", + small_est.tracker_bytes, + loaded.tracker_bytes + ); + + // Delivery drains the tracker and both fields come back down. A figure + // that only ever rises is measuring the wrong thing — that is the + // symptom under investigation. + for id in &ids { + sync.tracker.mark_received(id); + } + let drained = sync.window_memory_estimate().unwrap(); + assert_eq!(drained.tracker_entries, 0, "the window emptied"); + assert_eq!( + drained.tracker_bytes, 0, + "and the bytes it was holding came back" + ); + } + + #[tokio::test] + async fn window_atomic_reads_never_written_until_a_block_is_applied() { + let (mut sync, sink) = build_sync_with_window_sink(SweepValidator::at(2665)); + assert_eq!( + sink.load(std::sync::atomic::Ordering::Relaxed), + WINDOW_BYTES_UNSET, + "constructed but nothing applied — the reader must see absent, \ + not an assertion that the window is empty" + ); + + // A sweep that applies nothing must not stamp the atomic either. + sync.downloaded_height = 2665; + sync.advance_state_applied_height().await; + assert_eq!( + sink.load(std::sync::atomic::Ordering::Relaxed), + WINDOW_BYTES_UNSET, + "no block applied ⇒ still never-written" + ); + } + + #[tokio::test] + async fn window_atomic_is_written_after_an_applied_block() { + let (mut sync, sink) = build_sync_with_window_sink(SweepValidator::at(2665)); + sync.downloaded_height = 2670; + + // Put sections in flight so the published figure is non-zero and a + // written zero cannot be mistaken for the sentinel or the reverse. + let ids: Vec<[u8; 32]> = (0u8..64).map(|n| [n; 32]).collect(); + sync.tracker.mark_requested(&ids, PeerId(1), 102); + let expected = sync.window_memory_estimate().unwrap().tracker_bytes; + assert!(expected > 0); + + sync.advance_state_applied_height().await; + + assert_eq!( + sync.validator.as_ref().unwrap().applied, + vec![2666, 2667, 2668, 2669, 2670], + "harness sanity: blocks really were applied" + ); + assert_eq!( + sink.load(std::sync::atomic::Ordering::Relaxed), + expected, + "the estimate must be published after an applied block" + ); } } @@ -3958,9 +4474,9 @@ mod serve_continuation_tests { use enr_chain::{ChainError, SyncInfo}; use ergo_chain_types::{ADDigest, Header}; use ergo_validation::{ - ApplyStateOutcome, BlockValidator, Parameters, ValidationError, + ApplyStateOutcome, BlockValidator, Parameters, StatePersistence, ValidationError, }; - use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; /// Test chain tip. Above the 400-id continuation cap so the fresh-peer @@ -4028,7 +4544,9 @@ mod serve_continuation_tests { impl ServeChain { fn new() -> Self { - Self { continuation_calls: AtomicU32::new(0) } + Self { + continuation_calls: AtomicU32::new(0), + } } } @@ -4045,7 +4563,9 @@ mod serve_continuation_tests { } async fn header_at(&self, height: u32) -> Option
{ - (1..=TIP).contains(&height).then(|| test_header(height, block_id_at(height))) + (1..=TIP) + .contains(&height) + .then(|| test_header(height, block_id_at(height))) } async fn header_state_root(&self, _height: u32) -> Option<[u8; 33]> { @@ -4072,11 +4592,7 @@ mod serve_continuation_tests { Ok(SyncInfo::V2 { headers }) } - async fn continuation_ids( - &self, - peer_last_ids: &[BlockId], - limit: usize, - ) -> Vec<[u8; 32]> { + async fn continuation_ids(&self, peer_last_ids: &[BlockId], limit: usize) -> Vec<[u8; 32]> { self.continuation_calls.fetch_add(1, Ordering::Relaxed); let common = if peer_last_ids.is_empty() { 0 // fresh peer — serve from height 1 @@ -4125,11 +4641,7 @@ mod serve_continuation_tests { unreachable!("not called in serve tests") } - async fn is_better_nipopow( - &self, - _this: &[u8], - _than: &[u8], - ) -> Result { + async fn is_better_nipopow(&self, _this: &[u8], _than: &[u8]) -> Result { unreachable!("not called in serve tests") } @@ -4180,10 +4692,6 @@ mod serve_continuation_tests { async fn get_modifier(&self, _type_id: u8, _id: &[u8; 32]) -> Option> { unreachable!("not called in serve tests") } - async fn script_verified_height(&self) -> Option { - None - } - async fn set_script_verified_height(&self, _height: u32) {} async fn validated_height(&self) -> Option { None } @@ -4224,13 +4732,12 @@ mod serve_continuation_tests { fn current_digest(&self) -> &ADDigest { unreachable!("not called in serve tests") } - fn reset_to( - &mut self, - _height: u32, - _digest: ADDigest, - ) -> Result<(), ValidationError> { + fn reset_to(&mut self, _height: u32, _digest: ADDigest) -> Result<(), ValidationError> { unreachable!("not called in serve tests") } + fn state_persistence(&self) -> Option<&dyn StatePersistence> { + None + } } /// Messages recorded by [`RecordingTransport`], shared with the test. @@ -4247,7 +4754,9 @@ mod serve_continuation_tests { let (_dd_tx, delivery_data_rx) = mpsc::channel::(1); let sync = HeaderSync::new( SyncConfig::default(), - RecordingTransport { sent: Arc::clone(&sent) }, + RecordingTransport { + sent: Arc::clone(&sent), + }, ServeChain::new(), NoopStore, None, @@ -4259,6 +4768,7 @@ mod serve_continuation_tests { Arc::new(AtomicU32::new(0)), Arc::new(AtomicBool::new(false)), Arc::new(AtomicU32::new(0)), + Arc::new(AtomicU64::new(0)), shutdown_rx, ); (sync, sent) @@ -4283,9 +4793,7 @@ mod serve_continuation_tests { .unwrap() .iter() .filter_map(|(p, m)| match m { - ProtocolMessage::Inv { modifier_type, ids } - if *modifier_type == HEADER_TYPE_ID => - { + ProtocolMessage::Inv { modifier_type, ids } if *modifier_type == HEADER_TYPE_ID => { Some((*p, ids.clone())) } _ => None, @@ -4294,15 +4802,11 @@ mod serve_continuation_tests { } /// Recipients of every SyncInfo recorded by the transport, in send order. - fn sent_sync_info_peers( - sent: &Mutex>, - ) -> Vec { + fn sent_sync_info_peers(sent: &Mutex>) -> Vec { sent.lock() .unwrap() .iter() - .filter_map(|(p, m)| { - matches!(m, ProtocolMessage::SyncInfo { .. }).then_some(*p) - }) + .filter_map(|(p, m)| matches!(m, ProtocolMessage::SyncInfo { .. }).then_some(*p)) .collect() } @@ -4320,7 +4824,11 @@ mod serve_continuation_tests { assert!(matches!(result, EventResult::Continue)); let invs = sent_header_invs(&sent); - assert_eq!(invs.len(), 1, "fresh peer must receive exactly one header Inv"); + assert_eq!( + invs.len(), + 1, + "fresh peer must receive exactly one header Inv" + ); let (to, ids) = &invs[0]; assert_eq!(*to, fresh_peer, "Inv goes to the SyncInfo sender"); assert_eq!(ids.len(), 400, "continuation capped at 400 (JVM size)"); @@ -4349,7 +4857,10 @@ mod serve_continuation_tests { let (to, ids) = &invs[0]; assert_eq!(*to, SYNC_PEER); let expected: Vec<[u8; 32]> = (451..=TIP).map(id_at).collect(); - assert_eq!(*ids, expected, "ids ascend from the newest anchor + 1 to our tip"); + assert_eq!( + *ids, expected, + "ids ascend from the newest anchor + 1 to our tip" + ); } #[tokio::test] diff --git a/sync/src/sweep_backoff.rs b/sync/src/sweep_backoff.rs index c60aea0..2fe0e1f 100644 --- a/sync/src/sweep_backoff.rs +++ b/sync/src/sweep_backoff.rs @@ -6,19 +6,18 @@ //! *deterministically* — a consensus divergence, a corrupt-but-on-disk //! block, or (the motivating case) a script the evaluator wrongly //! rejects. Left ungated, the sweep re-runs every couple of seconds: the -//! deferred-eval-failure path rolls the validator back and pulls -//! `downloaded_height` down, then the section ticker re-advances it over -//! the still-on-disk sections and re-runs the sweep. That pegs a core and -//! floods the journal for as long as the condition holds — which, being -//! deterministic, is until the binary, the data, or the chain changes. A -//! testnet node spun this way ~every 10s for 13h on a since-fixed -//! `tree_version` script bug. +//! tip stays pinned, the section ticker re-advances `downloaded_height` +//! over the still-on-disk sections, and the sweep runs again. That pegs a +//! core and floods the journal for as long as the condition holds — which, +//! being deterministic, is until the binary, the data, or the chain +//! changes. A testnet node spun this way ~every 10s for 13h on a +//! since-fixed `tree_version` script bug. //! //! The gate derives entirely from authoritative state — the applied tip — //! not from a parallel failure counter. A sweep that does not move -//! `validated_height()` past `frontier` is a stall, full stop, whether the -//! failure surfaced as an `apply_state` error or a deferred-eval rollback; -//! the stall *detection* is deliberately blind to which. Consecutive stalls +//! `validated_height()` past `frontier` is a stall, full stop, whatever +//! rejected the block; the stall *detection* is deliberately blind to +//! which. Consecutive stalls //! at the same frontier ramp an exponential delay ([`BASE_DELAY`], doubling, //! capped at [`MAX_DELAY`]). The instant the tip advances (real progress) //! or the frontier changes (reorg / rollback to a different height), the @@ -42,7 +41,10 @@ //! retired per-`apply_state` tracker owned this emission and the //! deferred-eval wedge bypassed it entirely (it never fired during the //! 13h stall above); folding it onto the mode-agnostic frontier count -//! closes that gap, so eval-failure stalls now alarm too. +//! closed that gap. Deferred evaluation itself is gone as of v0.8.0 — a +//! script rejection is an `apply_state` `Err` now — but the frontier-keyed +//! detection is what makes this robust to the next mechanism too, which is +//! why it is written that way rather than against a list of error kinds. //! //! Backoff state is in-memory and resets on restart; a restart is a //! legitimate reset (the operator may have swapped the binary or the data @@ -68,12 +70,20 @@ const STUCK_THRESHOLD: u32 = 5; /// inspects this — it is purely the mode label the caller, which caught the /// failure, hands in so the emitted contract event names the mode. See /// [`crate::apply_state_error::classify_apply_state_error`] for the -/// `apply_state` path that produces `error_kind` / `missing_key`. +/// `apply_state` path that produces `error_kind` / `missing_key`, and for +/// why it classifies on the error variant and not on its Display string. #[derive(Debug, Clone)] pub(crate) struct StallDetail { /// `validation_stuck.error_kind`: an `apply_state` error kind - /// (`"missing_key"` / `"other"`), or `"script_eval"` for a - /// deferred-eval rejection. + /// (`"missing_key"` / `"transaction_invalid"` / `"other"`). + /// + /// The `"script_eval"` value went with deferred evaluation in v0.8.0 and + /// `"transaction_invalid"` is not a rename of it — the old value named + /// the deferred eval-failure path, the new one names + /// `ValidationError::TransactionInvalid`, which covers a failed script + /// and equally a failed ERG or token preservation check. Produced by + /// [`crate::apply_state_error::classify_apply_state_error`], which + /// matches on the error variant rather than on its Display string. pub error_kind: &'static str, /// `validation_stuck.missing_key` — present only for the `apply_state` /// `missing_key` case (a 32-byte AVL key, hex). @@ -81,17 +91,15 @@ pub(crate) struct StallDetail { } impl StallDetail { - /// A deferred script-eval rejection rolled the tip back. - pub(crate) fn script_eval() -> Self { - Self { error_kind: "script_eval", missing_key: None } - } - /// A non-specific stall: the sweep broke before/around `apply_state` /// for a reason without a richer label (a header/section gap, an /// epoch-boundary processing error). Reuses the `apply_state` /// catch-all kind. pub(crate) fn other() -> Self { - Self { error_kind: "other", missing_key: None } + Self { + error_kind: "other", + missing_key: None, + } } } @@ -184,7 +192,11 @@ impl SweepBackoff { next_allowed: now + delay, stuck_emitted: stuck_emitted || stuck_fired, }); - Some(Stall { attempt: consecutive, delay, stuck_fired }) + Some(Stall { + attempt: consecutive, + delay, + stuck_fired, + }) } /// Current consecutive stall count (0 when idle). @@ -225,48 +237,14 @@ fn delay_for(consecutive: u32) -> Duration { #[cfg(test)] mod tests { use super::*; + // Captures the rendered `validation_stuck` line. WARN is above the INFO + // default, so the shared INFO capture sees it. + use crate::test_support::capture; fn now() -> Instant { Instant::from_std(std::time::Instant::now()) } - /// Capture the default tracing fmt output produced by `f`, so a test - /// can assert the rendered `validation_stuck` line. - fn capture(f: F) -> String { - use std::io; - use std::sync::{Arc, Mutex}; - use tracing_subscriber::fmt::MakeWriter; - - #[derive(Clone, Default)] - struct W(Arc>>); - impl io::Write for W { - fn write(&mut self, b: &[u8]) -> io::Result { - self.0.lock().unwrap().extend_from_slice(b); - Ok(b.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - impl<'a> MakeWriter<'a> for W { - type Writer = W; - fn make_writer(&'a self) -> W { - self.clone() - } - } - - let w = W::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(w.clone()) - .without_time() - .with_ansi(false) - .with_target(false) - .finish(); - tracing::subscriber::with_default(subscriber, f); - let bytes = w.0.lock().unwrap().clone(); - String::from_utf8(bytes).unwrap() - } - #[test] fn idle_never_defers() { let b = SweepBackoff::default(); @@ -284,7 +262,11 @@ mod tests { let stall = b .record(100, 100, now, StallDetail::other()) .expect("a non-advancing sweep arms a stall"); - assert_eq!(stall.attempt, (i + 1) as u32, "attempt counts consecutive stalls"); + assert_eq!( + stall.attempt, + (i + 1) as u32, + "attempt counts consecutive stalls" + ); assert_eq!( stall.delay, Duration::from_secs(*exp), @@ -377,30 +359,35 @@ mod tests { assert!(output.contains("height=100"), "{output}"); assert!(output.contains("attempts=5"), "{output}"); assert!(output.contains("error_kind=\"missing_key\""), "{output}"); - assert!(output.contains(&format!("missing_key={key_hex}")), "{output}"); + assert!( + output.contains(&format!("missing_key={key_hex}")), + "{output}" + ); } #[test] - fn validation_stuck_fires_on_fifth_eval_failure_stall() { - // The path the retired apply_state tracker missed: a deferred-eval - // rollback pins the frontier. error_kind is `script_eval`, no key. + fn validation_stuck_fires_on_fifth_keyless_stall() { + // The other emission arm: a stall whose detail carries no key — + // every kind but the AVL missing-key case, `transaction_invalid` + // included. `missing_key` must be absent from the record entirely, + // not rendered as `None`. let mut b = SweepBackoff::default(); let now = now(); let mut fired = Vec::new(); let output = capture(|| { for _ in 0..5 { let s = b - .record(100, 100, now, StallDetail::script_eval()) + .record(100, 100, now, StallDetail::other()) .expect("stall"); fired.push(s.stuck_fired); } }); assert_eq!(fired, vec![false, false, false, false, true]); assert_eq!(output.matches("validation stuck").count(), 1, "{output}"); - assert!(output.contains("error_kind=\"script_eval\""), "{output}"); + assert!(output.contains("error_kind=\"other\""), "{output}"); assert!( !output.contains("missing_key"), - "eval stalls carry no key: {output}" + "keyless stalls carry no key: {output}" ); } @@ -411,8 +398,7 @@ mod tests { let mut fires = 0; let output = capture(|| { for _ in 0..10 { - if b - .record(100, 100, now, StallDetail::script_eval()) + if b.record(100, 100, now, StallDetail::other()) .unwrap() .stuck_fired { @@ -420,7 +406,10 @@ mod tests { } } }); - assert_eq!(fires, 1, "exactly one fire across 10 stalls at one frontier"); + assert_eq!( + fires, 1, + "exactly one fire across 10 stalls at one frontier" + ); assert_eq!(output.matches("validation stuck").count(), 1, "{output}"); } @@ -433,15 +422,15 @@ mod tests { let now = now(); let output = capture(|| { for _ in 0..5 { - b.record(100, 100, now, StallDetail::script_eval()); + b.record(100, 100, now, StallDetail::other()); } assert!( - b.record(100, 150, now, StallDetail::script_eval()).is_none(), + b.record(100, 150, now, StallDetail::other()).is_none(), "advancing tip clears the backoff" ); assert_eq!(b.consecutive(), 0); for _ in 0..5 { - b.record(150, 150, now, StallDetail::script_eval()); + b.record(150, 150, now, StallDetail::other()); } }); assert_eq!(output.matches("validation stuck").count(), 2, "{output}"); diff --git a/sync/src/test_support.rs b/sync/src/test_support.rs new file mode 100644 index 0000000..a931492 --- /dev/null +++ b/sync/src/test_support.rs @@ -0,0 +1,105 @@ +//! Test-only tracing capture. +//! +//! Journal-event conformance tests (`../facts/journal-events.md`) assert on the +//! *rendered* line, which means every one of them needs a subscriber writing +//! into a buffer. Four near-identical copies of that scaffolding had grown +//! across `state.rs`, `sweep_backoff.rs`, `eval_backlog.rs` (since deleted) and +//! the integration test before this module existed. +//! +//! Duplication is not the only reason to have one copy. The max level lives +//! here, and it is the thing that silently decides whether an event is +//! capturable at all: `tracing_subscriber::fmt()` defaults to INFO, so an event +//! emitted below that renders to nothing under a copy that took the default, +//! and looks exactly like an event that was never emitted. That is not +//! hypothetical — it is how `deferred_eval_gate_engaged` (DEBUG, removed in +//! v0.8.0 with the queue it described) shipped unverified. Callers name the +//! level explicitly. + +use std::io; +use std::sync::{Arc, Mutex}; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::fmt::MakeWriter; + +#[derive(Clone, Default)] +pub(crate) struct CaptureWriter { + buf: Arc>>, +} + +impl CaptureWriter { + fn captured(&self) -> String { + String::from_utf8(self.buf.lock().unwrap().clone()).unwrap() + } +} + +impl io::Write for CaptureWriter { + fn write(&mut self, b: &[u8]) -> io::Result { + self.buf.lock().unwrap().extend_from_slice(b); + Ok(b.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl<'a> MakeWriter<'a> for CaptureWriter { + type Writer = CaptureWriter; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} + +/// The subscriber every capture below installs: no time, no ANSI, no target, +/// so an assertion sees `marker field=value` and nothing else. +fn subscriber(writer: CaptureWriter, max: LevelFilter) -> impl tracing::Subscriber + Send + Sync { + tracing_subscriber::fmt() + .with_writer(writer) + .without_time() + .with_ansi(false) + .with_target(false) + .with_max_level(max) + .finish() +} + +/// Capture everything at or above `max` that `f` emits on this thread. +pub(crate) fn capture_at(max: LevelFilter, f: F) -> String { + let writer = CaptureWriter::default(); + tracing::subscriber::with_default(subscriber(writer.clone(), max), f); + writer.captured() +} + +/// [`capture_at`] at INFO, the level of most contract events. +pub(crate) fn capture(f: F) -> String { + capture_at(LevelFilter::INFO, f) +} + +/// Async [`capture_at`]. +/// +/// Takes the future directly rather than a closure so the caller can pass +/// `sync.some_method()` — a future borrowing `&mut sync` — without wrapping it +/// in `move || async move {…}` to satisfy capture rules. The subscriber guard +/// outlives the `await`, so emits from the future are captured. Valid only on a +/// current-thread runtime (`#[tokio::test]`'s default): the dispatcher is a +/// thread-local, and a future migrating to another worker would leave it +/// behind. +/// +/// The future's own output is discarded — the rendered line is what a +/// journal-event test is asserting on. A test that needs both wraps the call +/// itself in an `async { … }` block and keeps the value there. +pub(crate) async fn capture_async(max: LevelFilter, f: Fut) -> String +where + Fut: std::future::Future, +{ + let writer = CaptureWriter::default(); + let guard = tracing::subscriber::set_default(subscriber(writer.clone(), max)); + let _ = f.await; + drop(guard); + writer.captured() +} + +/// [`capture_async`] at WARN. +pub(crate) async fn capture_warn(f: Fut) -> String +where + Fut: std::future::Future, +{ + capture_async(LevelFilter::WARN, f).await +} diff --git a/sync/src/traits.rs b/sync/src/traits.rs index cdba2a3..21fb10a 100644 --- a/sync/src/traits.rs +++ b/sync/src/traits.rs @@ -21,14 +21,12 @@ pub trait SyncStore { id: &[u8; 32], ) -> impl std::future::Future>> + Send; - /// Read the persisted script_verified_height. Returns None if not set. - fn script_verified_height(&self) -> impl std::future::Future> + Send; - - /// Persist the script_verified_height. - fn set_script_verified_height( - &self, - height: u32, - ) -> impl std::future::Future + Send; + // `script_verified_height()` / `set_script_verified_height()` were removed + // here in v0.8.0 along with deferred evaluation. `apply_state` evaluates + // before it persists, so a second watermark trailing the first could only + // ever disagree with it. The metadata key they wrote remains in + // `chain_meta` on existing nodes and is simply never read again — a few + // stale bytes, no migration. See `../facts/sync.md` § "SyncStore". /// Read the durably-recorded validated_height from /// `chain_meta[b"validated_height"]`. Returns None if absent. @@ -40,10 +38,7 @@ pub trait SyncStore { /// PRECONDITION: caller MUST have called `validator.flush()` before /// invoking this — the ordering is load-bearing for the cross-DB /// invariant. See ../facts/sync.md. - fn set_validated_height( - &self, - height: u32, - ) -> impl std::future::Future + Send; + fn set_validated_height(&self, height: u32) -> impl std::future::Future + Send; /// Fsync outstanding modifier writes. Paired with validator flushes so /// state and modifier stores advance durably together. @@ -93,9 +88,7 @@ pub trait SyncTransport { fn outbound_peers(&self) -> impl std::future::Future> + Send; /// Receive the next protocol event. Returns None if the event stream ends. - fn next_event( - &mut self, - ) -> impl std::future::Future> + Send; + fn next_event(&mut self) -> impl std::future::Future> + Send; } /// How the sync machine queries and updates chain state. @@ -144,10 +137,7 @@ pub trait SyncChain { ) -> impl std::future::Future + Send; /// `true` iff `height` is the start of a new voting epoch. - fn is_epoch_boundary( - &self, - height: u32, - ) -> impl std::future::Future + Send; + fn is_epoch_boundary(&self, height: u32) -> impl std::future::Future + Send; /// Voting epoch length for this network (mainnet: 1024, testnet: 128). /// Used by sync to align the `blocks_to_keep` prune horizon to voting- @@ -170,7 +160,7 @@ pub trait SyncChain { epoch_boundary_height: u32, block_proposed_update: &[u8], ) -> impl std::future::Future> - + Send; + + Send; /// Apply parameters from a successfully validated epoch-boundary block. /// Called by the validator's caller AFTER `validate_block` returns Ok with @@ -186,9 +176,7 @@ pub trait SyncChain { /// on the chain (JVM `Parameters.proposedUpdate`). Used by the /// validator at v4+ epoch boundaries to compare against the block's /// `[0x00, 124]` extension field — JVM `matchParameters60`. - fn active_proposed_update_bytes( - &self, - ) -> impl std::future::Future> + Send; + fn active_proposed_update_bytes(&self) -> impl std::future::Future> + Send; /// Strip a `NipopowProof` (P2P code 91) message envelope and verify the /// inner proof bytes via [`enr_chain::verify_nipopow_proof_bytes`]. diff --git a/sync/tests/journal_events_test.rs b/sync/tests/journal_events_test.rs index 4b1ab99..e8df9c5 100644 --- a/sync/tests/journal_events_test.rs +++ b/sync/tests/journal_events_test.rs @@ -1,195 +1,65 @@ -//! Verifies that the sync crate's journal-events emissions render with -//! the marker prefixes and named fields promised in -//! `facts/journal-events.md`. The Doctor adapter and other downstream -//! consumers parse on these strings — drift here is silent breakage +//! Journal-event conformance for the sync crate — `facts/journal-events.md`. +//! +//! The Doctor adapter and other downstream consumers parse on the marker +//! prefixes and field names this crate emits; drift here is silent breakage //! over there. //! -//! These tests assert the SHAPE of the emit, mirroring the per-event -//! tracing calls in `src/state.rs`. They don't drive the state machine -//! end-to-end — that's covered by the integration tests under `tests/` -//! and the live mainnet run. The contract anchor is the rendered line. - -use std::io; -use std::sync::{Arc, Mutex}; -use tracing::{error, info, warn}; -use tracing_subscriber::fmt::MakeWriter; - -#[derive(Clone, Default)] -struct CaptureWriter { - buf: Arc>>, -} - -impl CaptureWriter { - fn captured(&self) -> String { - String::from_utf8(self.buf.lock().unwrap().clone()).unwrap() - } -} - -impl io::Write for CaptureWriter { - fn write(&mut self, b: &[u8]) -> io::Result { - self.buf.lock().unwrap().extend_from_slice(b); - Ok(b.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -impl<'a> MakeWriter<'a> for CaptureWriter { - type Writer = CaptureWriter; - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } -} - -fn capture(f: F) -> String { - let writer = CaptureWriter::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(writer.clone()) - .without_time() - .with_ansi(false) - .with_target(false) - .finish(); - tracing::subscriber::with_default(subscriber, f); - writer.captured() -} - -#[test] -fn validation_sweep_started_has_marker_and_named_fields() { - let sweep_from: u64 = 1_000_000; - let sweep_to: u64 = 1_000_500; - let sweep_size: u64 = 500; - let output = capture(|| { - info!( - from = sweep_from, - to = sweep_to, - blocks = sweep_size, - "VALIDATION SWEEP STARTED" - ); - }); - assert!( - output.contains("VALIDATION SWEEP STARTED"), - "missing marker: {output}" - ); - // The contract pins the marker as the literal prefix — no decorative - // ===, no embedded values. The old emit shape was - // "=== VALIDATION SWEEP STARTED ===" which prefix-matches the - // contract regex but is noisier than necessary. - assert!( - !output.contains("==="), - "marker should be plain text, not decorated: {output}" - ); - assert!( - output.contains("from=1000000"), - "missing from field: {output}" - ); - assert!( - output.contains("to=1000500"), - "missing to field: {output}" - ); - assert!( - output.contains("blocks=500"), - "missing blocks field: {output}" - ); -} - -#[test] -fn validation_sweep_complete_has_marker_and_named_fields() { - let sweep_from: u64 = 1_000_000; - let validated_to: u64 = 1_000_500; - let advanced: u64 = 500; - let output = capture(|| { - info!( - from = sweep_from, - to = validated_to, - blocks = advanced, - elapsed = "0m42s", - rate = "12/s", - "VALIDATION SWEEP COMPLETE" - ); - }); - assert!( - output.contains("VALIDATION SWEEP COMPLETE"), - "missing marker: {output}" - ); - assert!( - !output.contains("==="), - "marker should be plain text, not decorated: {output}" - ); - assert!( - output.contains("from=1000000"), - "missing from field: {output}" - ); - assert!(output.contains("to=1000500"), "missing to field: {output}"); - assert!( - output.contains("blocks=500"), - "missing blocks field: {output}" - ); -} - -#[test] -fn block_applied_has_marker_and_named_fields() { - // Mirrors the emit at apply_state success in `src/state.rs`. The - // contract says `height` is u64 and `id` is a 32-byte hex string. - let height: u64 = 1_785_000; - // BlockId Display impl renders as lowercase hex. Synthesize a - // sentinel string so the test isn't dependent on the actual type. - let block_id = "0000abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345"; - let output = capture(|| { - info!( - height = height, - id = %block_id, - "block applied" - ); - }); - assert!( - output.contains("block applied"), - "missing marker: {output}" - ); - assert!( - output.contains("height=1785000"), - "missing height field: {output}" - ); - assert!( - output.contains(&format!("id={block_id}")), - "missing id field rendered via Display: {output}" - ); -} - -#[test] -fn chain_tip_reached_has_marker_and_named_fields() { - let tip_height: u64 = 1_785_500; - let output = capture(|| { - info!(height = tip_height, "chain tip reached"); - }); - assert!( - output.contains("chain tip reached"), - "missing marker: {output}" - ); - assert!( - output.contains("height=1785500"), - "missing height field: {output}" - ); -} - -// ------------------------------------------------------------------- -// `validation_stuck` (contract since 1.1, broadened to v1.3 — -// `facts/journal-events.md`) -// -// The emission now lives in the sweep backoff — the single emitter for -// BOTH the apply_state and deferred-eval stall modes. Its firing behavior -// (fires on the 5th consecutive frontier stall, once per frontier, re-arms -// on progress / frontier change) and the rendered shape are covered by -// `ergo_sync::sweep_backoff`'s own unit tests, which capture the REAL emit. -// -// Kept here, the contract-shape anchor file: the apply_state error -// classifier that feeds `error_kind`/`missing_key`, plus an inline mirror of -// the emitted line so the Doctor-adapter contract shape is pinned alongside -// the other journal events. -// ------------------------------------------------------------------- +//! ## Why this file is an index and not a test suite +//! +//! Every one of this crate's contract events is emitted from a private method +//! (`HeaderSync::handle_control_event`, the sweep loop) or a private module +//! (`sweep_backoff`, `catchup_progress`), and setting one up means writing the +//! struct's private watermark fields. None of that is reachable from an +//! integration test, which sees only `ergo_sync`'s public API. +//! +//! This file used to paper over that with **inline mirrors**: each test called +//! `info!(…)` with its own copy of the marker and fields, then asserted the +//! captured output contained that same copy. That tests `tracing`'s formatter +//! against itself. It cannot fail when an emit site drifts, because it never +//! invokes one — which is precisely how `deferred_eval_gate_engaged` and +//! `eval_frontier_hole` both shipped not matching the contract under a green +//! suite on 2026-08-12, and how the `validation_rollback_failed` mirror came to +//! assert an `error=` value the real emit never produced. +//! +//! The tests below are the same hazard one level down. They used to hand the +//! classifier **string literals** — a hand-written copy of what a +//! `ValidationError` renders to. That cannot fail when the error type changes, +//! which is exactly how `error_kind` lost its script-failure value in v0.8.0 +//! under a green suite. They now build the real `ValidationError` values, so +//! the classifier and its tests move together or neither compiles. +//! +//! The conformance tests therefore live beside the harness that can drive the +//! real emit, and this file records where. Each entry below names the test that +//! renders that event from its actual emit site: +//! +//! | Contract event | Driven by | +//! |---|---| +//! | `validation_sweep_started` | `state::sweep_resume_tests::journal_sweep_and_block_applied_conform` | +//! | `validation_sweep_complete` | ditto | +//! | `block_applied` | ditto | +//! | `chain_tip_reached` | `state::sweep_resume_tests::journal_chain_tip_reached_conforms` | +//! | `validation_stuck` | `sweep_backoff::tests::validation_stuck_fires_on_fifth_*_stall` | +//! | `validation_rollback_failed` | `state::sweep_resume_tests::journal_validation_rollback_failed_conforms` | +//! | catch-up progress | `catchup_progress::tests::*` (marker, both fields, both probe branches) | +//! +//! ## v0.8.0 — events that went with deferred evaluation +//! +//! `deferred_eval_backlog`, `deferred_eval_gate_engaged` and +//! `eval_frontier_hole` no longer exist; nothing emits them. The first was +//! reduced to a two-field `catch-up progress` record (the four dropped fields +//! all described the queue); the other two described the dispatch gate and the +//! script frontier, neither of which survives. `validation_rollback_failed` +//! keeps its `reorg` path and loses its `eval_failure` one. +//! `facts/journal-events.md` was updated to match in contract 2.0 (`5706fbf`, +//! `0477774`, `c3277d8`). +//! +//! What remains here is what an integration test *can* exercise for real: the +//! public classifier that produces `validation_stuck`'s `error_kind` and +//! `missing_key` field values. use bytes::Bytes; use ergo_sync::apply_state_error::classify_apply_state_error; +use ergo_validation::ValidationError; /// A realistic 32-byte AVL key with the three byte categories the /// `bytes::Bytes` Debug impl renders differently: printable ASCII @@ -201,85 +71,99 @@ const AVL_KEY: [u8; 32] = [ 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0, 0xff, ]; -#[test] -fn classify_extracts_hex_from_avl_missing_key_error() { +/// The AVL layer's own message, as `ergo_avltree_rust`'s `operation.rs` +/// raises it. `validation/` wraps this verbatim into whichever variant the +/// mode uses, so this is the real payload the classifier parses. +fn avl_missing_key_message() -> String { let key = Bytes::copy_from_slice(&AVL_KEY); - let err = format!("Key {key:?} does not exists"); + format!("operation 3 failed: Key {key:?} does not exists") +} + +fn avl_key_hex() -> String { + AVL_KEY.iter().map(|b| format!("{b:02x}")).collect() +} + +/// UTXO mode: the persistent prover's failure arrives as +/// `StateOperationFailed`. +#[test] +fn classify_extracts_hex_from_utxo_mode_missing_key_error() { + let err = ValidationError::StateOperationFailed(avl_missing_key_message()); let (kind, hex) = classify_apply_state_error(&err); assert_eq!(kind, "missing_key"); - let expected: String = AVL_KEY.iter().map(|b| format!("{b:02x}")).collect(); + let expected = avl_key_hex(); assert_eq!( hex.as_deref(), Some(expected.as_str()), - "expected hex {expected} from error {err:?}, got {hex:?}" + "expected hex {expected} from error {err}, got {hex:?}" ); } +/// Digest mode: the same `operation.rs` error, reached through the batch +/// verifier instead, arrives as `ProofVerificationFailed`. Both channels +/// must classify — a node running in digest mode is no less stuck. #[test] -fn classify_other_for_non_missing_key_error() { - let (kind, hex) = classify_apply_state_error("validator state root mismatch"); - assert_eq!(kind, "other"); - assert!(hex.is_none()); +fn classify_extracts_hex_from_digest_mode_missing_key_error() { + let err = ValidationError::ProofVerificationFailed(avl_missing_key_message()); + let (kind, hex) = classify_apply_state_error(&err); + assert_eq!(kind, "missing_key"); + assert_eq!(hex.as_deref(), Some(avl_key_hex().as_str())); } +/// The kind that went missing when deferred evaluation was removed. It is a +/// consensus signal, not a state-DB one, and it is keyed on the variant — +/// a preservation failure reads the same as a script failure here, which is +/// the point. #[test] -fn validation_stuck_renders_marker_and_named_fields() { - // Inline mirror of `sweep_backoff::emit_validation_stuck` (the missing_key - // branch). The contract pins marker "validation stuck" and fields height - // (u64), attempts (u64), error_kind (string), missing_key (hex, optional). - // The real emit is exercised end-to-end in the sweep_backoff unit tests; - // this is the contract-shape anchor beside the other journal events. - let key_hex: String = AVL_KEY.iter().map(|b| format!("{b:02x}")).collect(); - let output = capture(|| { - warn!( - height = 1_783_677u64, - attempts = 5u64, - error_kind = "missing_key", - missing_key = %key_hex, - "validation stuck" - ); - }); - assert!(output.contains("validation stuck"), "missing marker: {output}"); - assert!(output.contains("height=1783677"), "missing height: {output}"); - assert!(output.contains("attempts=5"), "missing attempts: {output}"); - assert!( - output.contains("error_kind=\"missing_key\""), - "missing error_kind: {output}" - ); - assert!( - output.contains(&format!("missing_key={key_hex}")), - "missing key hex: {output}" - ); +fn classify_transaction_invalid_for_a_rejected_transaction() { + let err = ValidationError::TransactionInvalid { + index: 7, + reason: "script evaluation returned false".to_string(), + }; + let (kind, hex) = classify_apply_state_error(&err); + assert_eq!(kind, "transaction_invalid"); + assert!(hex.is_none(), "only the missing-key case carries a key"); +} + +#[test] +fn classify_other_for_non_missing_key_error() { + let err = ValidationError::StateRootMismatch { + expected: vec![0u8; 33], + got: vec![1u8; 33], + }; + let (kind, hex) = classify_apply_state_error(&err); + assert_eq!(kind, "other"); + assert!(hex.is_none()); } +/// The contract's 2.0 domain is exactly three values, `script_eval` no longer +/// among them. A sampling guard, not a proof: it covers the variants an +/// `apply_state` failure realistically produces, so a fourth kind reachable +/// only from some variant not listed here would still slip past. #[test] -fn validation_rollback_failed_renders_marker_and_named_fields() { - // Inline mirror of the `validation rollback failed` ERROR emitted by - // `handle_eval_failure` and the reorg arm in `src/state.rs` when - // `BlockValidator::reset_to` returns Err (validator unmoved, watermarks - // held in place). Fields: height (u64, the rollback TARGET), path - // (string: "eval_failure" | "reorg"), error (Display). Not yet listed - // in `facts/journal-events.md` — contract addition owed by the main - // session; this pins the shape the entry must describe. - let output = capture(|| { - error!( - height = 2668u64, - path = "eval_failure", - error = %"rollback to height 2668 failed: simulated storage failure", - "validation rollback failed" +fn error_kind_domain_is_closed() { + let cases = [ + ValidationError::StateOperationFailed(avl_missing_key_message()), + ValidationError::ProofVerificationFailed(avl_missing_key_message()), + ValidationError::TransactionInvalid { + index: 0, + reason: "nope".to_string(), + }, + ValidationError::MissingProof, + ValidationError::HeightMismatch { + expected: 2666, + got: 2668, + }, + ValidationError::BlockCostExceeded { + cost: 2, + max_cost: 1, + }, + ValidationError::IntraBlockDoubleSpend("box".to_string()), + ]; + for err in &cases { + let (kind, _) = classify_apply_state_error(err); + assert!( + matches!(kind, "missing_key" | "transaction_invalid" | "other"), + "{err} produced out-of-domain error_kind {kind:?}" ); - }); - assert!( - output.contains("validation rollback failed"), - "missing marker: {output}" - ); - assert!(output.contains("height=2668"), "missing height: {output}"); - assert!( - output.contains("path=\"eval_failure\""), - "missing path: {output}" - ); - assert!( - output.contains("error=rollback to height 2668 failed"), - "missing error field: {output}" - ); + } } diff --git a/sync/tests/snapshot_download_test.rs b/sync/tests/snapshot_download_test.rs index 0499204..b980fd1 100644 --- a/sync/tests/snapshot_download_test.rs +++ b/sync/tests/snapshot_download_test.rs @@ -18,12 +18,13 @@ fn store_and_retrieve_chunks() { let path = dir.path().join("test.redb"); let manifest_id = [0x11; 32]; - let store = - ChunkDownloadStore::create(&path, manifest_id, 100_000, b"manifest", 5).unwrap(); + let store = ChunkDownloadStore::create(&path, manifest_id, 100_000, b"manifest", 5).unwrap(); // Store three chunks. for i in 0..3u8 { - store.store_chunk(&make_chunk_id(i), &make_chunk_data(i)).unwrap(); + store + .store_chunk(&make_chunk_id(i), &make_chunk_data(i)) + .unwrap(); } // Retrieve each and verify data. @@ -53,8 +54,12 @@ fn crash_recovery_preserves_state() { { let store = ChunkDownloadStore::create(&path, manifest_id, height, manifest_bytes, total).unwrap(); - store.store_chunk(&make_chunk_id(0), &make_chunk_data(0)).unwrap(); - store.store_chunk(&make_chunk_id(1), &make_chunk_data(1)).unwrap(); + store + .store_chunk(&make_chunk_id(0), &make_chunk_data(0)) + .unwrap(); + store + .store_chunk(&make_chunk_id(1), &make_chunk_data(1)) + .unwrap(); // store dropped here — simulates crash } @@ -84,19 +89,36 @@ fn is_complete_detection() { let path = dir.path().join("complete.redb"); let total = 3u32; - let store = - ChunkDownloadStore::create(&path, [0x33; 32], 300_000, b"m", total).unwrap(); + let store = ChunkDownloadStore::create(&path, [0x33; 32], 300_000, b"m", total).unwrap(); - assert!(!store.is_complete().unwrap(), "should not be complete with 0 chunks"); + assert!( + !store.is_complete().unwrap(), + "should not be complete with 0 chunks" + ); - store.store_chunk(&make_chunk_id(0), &make_chunk_data(0)).unwrap(); - assert!(!store.is_complete().unwrap(), "should not be complete with 1/3 chunks"); + store + .store_chunk(&make_chunk_id(0), &make_chunk_data(0)) + .unwrap(); + assert!( + !store.is_complete().unwrap(), + "should not be complete with 1/3 chunks" + ); - store.store_chunk(&make_chunk_id(1), &make_chunk_data(1)).unwrap(); - assert!(!store.is_complete().unwrap(), "should not be complete with 2/3 chunks"); + store + .store_chunk(&make_chunk_id(1), &make_chunk_data(1)) + .unwrap(); + assert!( + !store.is_complete().unwrap(), + "should not be complete with 2/3 chunks" + ); - store.store_chunk(&make_chunk_id(2), &make_chunk_data(2)).unwrap(); - assert!(store.is_complete().unwrap(), "should be complete with 3/3 chunks"); + store + .store_chunk(&make_chunk_id(2), &make_chunk_data(2)) + .unwrap(); + assert!( + store.is_complete().unwrap(), + "should be complete with 3/3 chunks" + ); } /// stored_chunk_ids returns the correct set of IDs. @@ -105,13 +127,14 @@ fn stored_chunk_ids_returns_correct_set() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ids.redb"); - let store = - ChunkDownloadStore::create(&path, [0x44; 32], 400_000, b"m", 5).unwrap(); + let store = ChunkDownloadStore::create(&path, [0x44; 32], 400_000, b"m", 5).unwrap(); let expected: HashSet<[u8; 32]> = (0..3u8).map(make_chunk_id).collect(); for i in 0..3u8 { - store.store_chunk(&make_chunk_id(i), &make_chunk_data(i)).unwrap(); + store + .store_chunk(&make_chunk_id(i), &make_chunk_data(i)) + .unwrap(); } let ids: HashSet<[u8; 32]> = store.stored_chunk_ids().unwrap().into_iter().collect(); @@ -124,11 +147,12 @@ fn iter_chunks_returns_all() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("iter.redb"); - let store = - ChunkDownloadStore::create(&path, [0x55; 32], 500_000, b"m", 4).unwrap(); + let store = ChunkDownloadStore::create(&path, [0x55; 32], 500_000, b"m", 4).unwrap(); for i in 0..4u8 { - store.store_chunk(&make_chunk_id(i), &make_chunk_data(i)).unwrap(); + store + .store_chunk(&make_chunk_id(i), &make_chunk_data(i)) + .unwrap(); } let chunks = store.iter_chunks().unwrap(); diff --git a/sync/tests/snapshot_manifest_test.rs b/sync/tests/snapshot_manifest_test.rs index 7aedcb6..d4766ba 100644 --- a/sync/tests/snapshot_manifest_test.rs +++ b/sync/tests/snapshot_manifest_test.rs @@ -4,7 +4,12 @@ use ergo_sync::snapshot::parser::{ }; /// Helper: serialize an internal node into packed bytes. -fn pack_internal(balance: i8, key: &[u8; 32], left_label: &[u8; 32], right_label: &[u8; 32]) -> Vec { +fn pack_internal( + balance: i8, + key: &[u8; 32], + left_label: &[u8; 32], + right_label: &[u8; 32], +) -> Vec { let mut buf = Vec::with_capacity(98); buf.push(PACKED_INTERNAL_PREFIX); buf.push(balance as u8); diff --git a/sync/tests/snapshot_parser_test.rs b/sync/tests/snapshot_parser_test.rs index 6d7165e..426867b 100644 --- a/sync/tests/snapshot_parser_test.rs +++ b/sync/tests/snapshot_parser_test.rs @@ -1,9 +1,9 @@ +use bytes::Bytes; use ergo_avltree_rust::batch_node::LeafNode; use ergo_sync::snapshot::parser::{ compute_internal_label, compute_leaf_label, parse_dfs_stream, parse_node, ParsedNode, PACKED_INTERNAL_PREFIX, PACKED_LEAF_PREFIX, }; -use bytes::Bytes; /// Verify our leaf label computation matches ergo_avltree_rust. #[test] @@ -23,7 +23,10 @@ fn leaf_label_matches_library() { // Compute via our function let our_label = compute_leaf_label(&key, value, &next_key); - assert_eq!(our_label, lib_label, "leaf label mismatch with ergo_avltree_rust"); + assert_eq!( + our_label, lib_label, + "leaf label mismatch with ergo_avltree_rust" + ); } /// Verify our internal label computation matches ergo_avltree_rust. @@ -56,7 +59,10 @@ fn internal_label_matches_library() { let our_label = compute_internal_label(0i8, &left_label, &right_label); - assert_eq!(our_label, lib_label, "internal label mismatch with ergo_avltree_rust"); + assert_eq!( + our_label, lib_label, + "internal label mismatch with ergo_avltree_rust" + ); } /// Verify internal label with non-zero balance. diff --git a/sync/tests/snapshot_protocol_test.rs b/sync/tests/snapshot_protocol_test.rs index e4953e2..1417d43 100644 --- a/sync/tests/snapshot_protocol_test.rs +++ b/sync/tests/snapshot_protocol_test.rs @@ -128,11 +128,10 @@ fn reject_oversized_messages() { #[test] fn parse_real_jvm_snapshots_info() { let body: Vec = vec![ - 2, 254, 231, 32, 58, 66, 224, 79, 217, 75, 51, 233, 158, 23, 236, 50, - 7, 157, 130, 232, 149, 102, 39, 253, 72, 215, 26, 124, 86, 25, 119, 45, - 215, 94, 159, 154, 254, 233, 32, 207, 233, 138, 191, 11, 147, 60, 90, 220, - 89, 176, 29, 171, 60, 132, 45, 179, 136, 137, 176, 241, 183, 140, 231, 224, - 63, 174, 232, 234, 185, 200, 41, + 2, 254, 231, 32, 58, 66, 224, 79, 217, 75, 51, 233, 158, 23, 236, 50, 7, 157, 130, 232, + 149, 102, 39, 253, 72, 215, 26, 124, 86, 25, 119, 45, 215, 94, 159, 154, 254, 233, 32, 207, + 233, 138, 191, 11, 147, 60, 90, 220, 89, 176, 29, 171, 60, 132, 45, 179, 136, 137, 176, + 241, 183, 140, 231, 224, 63, 174, 232, 234, 185, 200, 41, ]; let parsed = SnapshotMessage::parse(SNAPSHOTS_INFO, &body).unwrap(); @@ -169,12 +168,27 @@ fn parse_empty_snapshots_info() { /// Verify is_snapshot_code for codes 75-82. #[test] fn is_snapshot_code_range() { - assert!(!SnapshotMessage::is_snapshot_code(75), "75 should not be a snapshot code"); - assert!(SnapshotMessage::is_snapshot_code(76), "76 = GetSnapshotsInfo"); + assert!( + !SnapshotMessage::is_snapshot_code(75), + "75 should not be a snapshot code" + ); + assert!( + SnapshotMessage::is_snapshot_code(76), + "76 = GetSnapshotsInfo" + ); assert!(SnapshotMessage::is_snapshot_code(77), "77 = SnapshotsInfo"); assert!(SnapshotMessage::is_snapshot_code(78), "78 = GetManifest"); assert!(SnapshotMessage::is_snapshot_code(79), "79 = Manifest"); - assert!(SnapshotMessage::is_snapshot_code(80), "80 = GetUtxoSnapshotChunk"); - assert!(SnapshotMessage::is_snapshot_code(81), "81 = UtxoSnapshotChunk"); - assert!(!SnapshotMessage::is_snapshot_code(82), "82 should not be a snapshot code"); + assert!( + SnapshotMessage::is_snapshot_code(80), + "80 = GetUtxoSnapshotChunk" + ); + assert!( + SnapshotMessage::is_snapshot_code(81), + "81 = UtxoSnapshotChunk" + ); + assert!( + !SnapshotMessage::is_snapshot_code(82), + "82 should not be a snapshot code" + ); } diff --git a/tests/alloc_benchmark.rs b/tests/alloc_benchmark.rs index a182ec0..0b5326b 100644 --- a/tests/alloc_benchmark.rs +++ b/tests/alloc_benchmark.rs @@ -50,10 +50,18 @@ fn allocator_benchmark() { let start = Instant::now(); for i in 0..ITERS { match i % 4 { - 0 => { black_box(Box::new([0u8; 32])); } - 1 => { black_box(Box::new([0u8; 128])); } - 2 => { black_box(Vec::::with_capacity(256)); } - 3 => { black_box(String::with_capacity(64)); } + 0 => { + black_box(Box::new([0u8; 32])); + } + 1 => { + black_box(Box::new([0u8; 128])); + } + 2 => { + black_box(Vec::::with_capacity(256)); + } + 3 => { + black_box(String::with_capacity(64)); + } _ => unreachable!(), } } @@ -63,7 +71,9 @@ fn allocator_benchmark() { // 4. Concurrent alloc/dealloc — mimics rayon par_iter validation // (multiple threads allocating simultaneously) // ========================================================================= - let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4); + let threads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); let per_thread = ITERS / threads; let start = Instant::now(); std::thread::scope(|s| { @@ -101,10 +111,26 @@ fn allocator_benchmark() { println!("Allocator: {alloc_name} ({threads} threads available)"); println!("{}", "=".repeat(60)); println!(); - println!("Small Box (64B) alloc+dealloc: {:>6.1} ns/op ({ITERS} ops)", ns(small_box, ITERS)); - println!("Vec grow to 100: {:>6.1} ns/op ({} ops)", ns(vec_grow, ITERS / 10), ITERS / 10); - println!("Mixed sizes (32-256B): {:>6.1} ns/op ({ITERS} ops)", ns(mixed, ITERS)); - println!("Concurrent ({threads}T, Box+Vec): {:>6.1} ns/op ({ITERS} ops)", ns(concurrent, ITERS)); - println!("Churn (Box+Vec, build+discard): {:>6.1} ns/op ({ITERS} ops)", ns(churn, ITERS)); + println!( + "Small Box (64B) alloc+dealloc: {:>6.1} ns/op ({ITERS} ops)", + ns(small_box, ITERS) + ); + println!( + "Vec grow to 100: {:>6.1} ns/op ({} ops)", + ns(vec_grow, ITERS / 10), + ITERS / 10 + ); + println!( + "Mixed sizes (32-256B): {:>6.1} ns/op ({ITERS} ops)", + ns(mixed, ITERS) + ); + println!( + "Concurrent ({threads}T, Box+Vec): {:>6.1} ns/op ({ITERS} ops)", + ns(concurrent, ITERS) + ); + println!( + "Churn (Box+Vec, build+discard): {:>6.1} ns/op ({ITERS} ops)", + ns(churn, ITERS) + ); println!("{}", "=".repeat(60)); } diff --git a/tests/ec_benchmark.rs b/tests/ec_benchmark.rs index 4d965fa..6f5b541 100644 --- a/tests/ec_benchmark.rs +++ b/tests/ec_benchmark.rs @@ -36,7 +36,9 @@ fn ec_performance_comparison() { let start = Instant::now(); for _ in 0..N { use k256::elliptic_curve::ops::MulByGenerator; - black_box(k256::ProjectivePoint::mul_by_generator(black_box(&k_scalar_b))); + black_box(k256::ProjectivePoint::mul_by_generator(black_box( + &k_scalar_b, + ))); } let k_gen = start.elapsed(); @@ -106,9 +108,8 @@ fn ec_performance_comparison() { // ========================================================================= let us = |d: std::time::Duration| d.as_micros() as f64 / N as f64; - let ratio = |a: std::time::Duration, b: std::time::Duration| { - a.as_nanos() as f64 / b.as_nanos() as f64 - }; + let ratio = + |a: std::time::Duration, b: std::time::Duration| a.as_nanos() as f64 / b.as_nanos() as f64; println!("\n{}", "=".repeat(60)); println!("EC Performance: k256 vs libsecp256k1 ({N} iterations, --release)"); diff --git a/tests/journal_events_contract.rs b/tests/journal_events_contract.rs new file mode 100644 index 0000000..b339dde --- /dev/null +++ b/tests/journal_events_contract.rs @@ -0,0 +1,161 @@ +//! Mechanical checks on `facts/journal-events.md` itself. +//! +//! These assert the two Format-conventions rules that are properties of the +//! **document** and need no emit sites: no marker may be a prefix of another, +//! and every marker is ASCII. Both were broken on 2026-08-12, the same day the +//! conventions were written down, and the breakage reached a release because +//! nothing checked either one. +//! +//! Deliberately NOT here: pairing marker literals against the code. That would +//! mean re-exporting every marker from every crate as a `const` purely so this +//! file could import it, and it buys less than the per-crate real-emit tests +//! already provide (`sync/src/state.rs` `sweep_resume_tests`, +//! `sweep_backoff`, `eval_backlog`). Document invariants live here; emit-site +//! conformance lives next to the emit. +//! +//! This test lives at workspace root because the contract is the main +//! session's and spans every crate. Putting it in `api/` — the crate that +//! advertises `JOURNAL_EVENTS_VERSION` — would couple `cargo test -p ergo-api` +//! to the repo layout above it and invert the crate boundary. + +const CONTRACT: &str = include_str!("../facts/journal-events.md"); + +/// Every backtick-and-double-quote wrapped literal on a line, in order. +/// +/// A `- **Marker:**` line may name more than one — `shutdown_signal_received` +/// documents `"SIGINT received"` or `"SIGTERM received"` — so this returns all +/// of them rather than the first. +fn quoted_literals(line: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut rest = line; + while let Some(start) = rest.find("`\"") { + let after = &rest[start + 2..]; + match after.find("\"`") { + Some(end) => { + out.push(&after[..end]); + rest = &after[end + 2..]; + } + None => break, + } + } + out +} + +fn marker_lines() -> Vec<&'static str> { + CONTRACT + .lines() + .filter(|l| l.trim_start().starts_with("- **Marker:**")) + .collect() +} + +fn markers() -> Vec<&'static str> { + marker_lines() + .iter() + .flat_map(|l| quoted_literals(l)) + .collect() +} + +/// Guards every other test in this file against passing vacuously. +/// +/// If the document's formatting changes so the extraction stops matching, the +/// prefix and ASCII checks below would iterate an empty list and pass — which +/// is precisely the failure mode this whole file exists to correct. The mirror +/// tests it replaces looked like coverage for months while asserting against +/// their own copy of the string. +#[test] +fn extraction_finds_the_markers() { + let lines = marker_lines(); + assert!( + lines.len() > 20, + "found only {} `- **Marker:**` lines in facts/journal-events.md — the \ + document's formatting probably changed and every other check in this \ + file is now vacuous", + lines.len() + ); + + for line in &lines { + assert!( + !quoted_literals(line).is_empty(), + "a marker line yielded no literal, so extraction is silently \ + partial: {line}" + ); + } +} + +/// Parsers identify events by matching the marker **prefix**. If one marker +/// begins with the whole of another, a parser keyed on the shorter matches +/// both events and then fails looking for fields only the shorter carries. +/// +/// Shipped once: `"deferred eval backlog at bound — draining to half"` began +/// with the entirety of `"deferred eval backlog"`. Renamed to +/// `"eval dispatch gate engaged"`. +#[test] +fn no_marker_is_a_prefix_of_another() { + let markers = markers(); + let mut collisions = Vec::new(); + + for (i, a) in markers.iter().enumerate() { + for (j, b) in markers.iter().enumerate() { + if i != j && a != b && b.starts_with(*a) { + collisions.push(format!("{a:?} is a prefix of {b:?}")); + } + } + } + + assert!( + collisions.is_empty(), + "marker prefix collisions — a parser keyed on the shorter matches \ + both:\n {}", + collisions.join("\n ") + ); +} + +/// Two markers rendering identically make the events indistinguishable to a +/// parser, which is the prefix collision's degenerate case. +#[test] +fn markers_are_unique() { + let markers = markers(); + let mut sorted = markers.clone(); + sorted.sort_unstable(); + let mut dupes: Vec<_> = sorted + .windows(2) + .filter(|w| w[0] == w[1]) + .map(|w| w[0]) + .collect(); + dupes.dedup(); + + assert!( + dupes.is_empty(), + "duplicate markers, so these events cannot be told apart: {dupes:?}" + ); +} + +/// The matched portion of a marker must survive being retyped from a report, +/// a terminal, or a grep. The free-text suffix after it may contain anything +/// and several markers do carry an em dash there — that is why this checks the +/// documented marker literal and not the emitted line. +/// +/// Shipped once: the gate event carried U+2014 inside its marker while the +/// contract specified an ASCII hyphen, so the documented literal matched +/// nothing at all. +#[test] +fn markers_are_ascii() { + let offenders: Vec<_> = markers() + .into_iter() + .filter(|m| !m.is_ascii()) + .map(|m| { + let bad: Vec = m + .chars() + .filter(|c| !c.is_ascii()) + .map(|c| format!("{c:?} (U+{:04X})", c as u32)) + .collect(); + format!("{m:?} contains {}", bad.join(", ")) + }) + .collect(); + + assert!( + offenders.is_empty(), + "non-ASCII inside a marker's matched portion:\n {}", + offenders.join("\n ") + ); +} diff --git a/tests/nipopow_serve_integration.rs b/tests/nipopow_serve_integration.rs index 6e036e2..0fb995f 100644 --- a/tests/nipopow_serve_integration.rs +++ b/tests/nipopow_serve_integration.rs @@ -22,17 +22,15 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; -use ergo_chain_types::{BlockId, Digest32}; use enr_p2p::blacklist::Blacklist; use enr_p2p::protocol::counters::TrafficCounters; use enr_p2p::transport::connection::Connection; use enr_p2p::transport::frame::Frame; use enr_p2p::transport::handshake::{HandshakeConfig, ModeConfig}; use enr_p2p::types::{Network, ProxyMode, Version}; +use ergo_chain_types::{BlockId, Digest32}; -use ergo_node_rust::nipopow_serve::{ - parse_nipopow_proof, GET_NIPOPOW_PROOF, NIPOPOW_PROOF, -}; +use ergo_node_rust::nipopow_serve::{parse_nipopow_proof, GET_NIPOPOW_PROOF, NIPOPOW_PROOF}; use sigma_ser::vlq_encode::WriteSigmaVlqExt; @@ -68,10 +66,13 @@ async fn nipopow_serve_round_trip_against_running_node() { let network = Network::Testnet; eprintln!("connecting to {addr}"); - let stream = timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS), TcpStream::connect(addr)) - .await - .expect("connect timed out") - .expect("tcp connect failed"); + let stream = timeout( + Duration::from_secs(CONNECT_TIMEOUT_SECS), + TcpStream::connect(addr), + ) + .await + .expect("connect timed out") + .expect("tcp connect failed"); let hs_config = HandshakeConfig { agent_name: "ergo-rust-test-client".into(), @@ -115,10 +116,16 @@ async fn nipopow_serve_round_trip_against_running_node() { body.extend_from_slice(&anchor.0 .0); body.put_u16(0).expect("vec write"); // pad_len = 0 - eprintln!("sending GetNipopowProof code={GET_NIPOPOW_PROOF} body_len={}", body.len()); - conn.write_frame(&Frame { code: GET_NIPOPOW_PROOF, body }) - .await - .expect("write frame failed"); + eprintln!( + "sending GetNipopowProof code={GET_NIPOPOW_PROOF} body_len={}", + body.len() + ); + conn.write_frame(&Frame { + code: GET_NIPOPOW_PROOF, + body, + }) + .await + .expect("write frame failed"); // Read frames until we see code 91 NipopowProof or time out. The peer // may interleave SyncInfo, Inv, and other messages — ignore them. @@ -128,10 +135,13 @@ async fn nipopow_serve_round_trip_against_running_node() { .read_frame(&Blacklist::new()) .await .expect("read frame failed"); - eprintln!("received frame code={} body_len={}", frame.code, frame.body.len()); + eprintln!( + "received frame code={} body_len={}", + frame.code, + frame.body.len() + ); if frame.code == NIPOPOW_PROOF { - return parse_nipopow_proof(&frame.body) - .expect("parse_nipopow_proof failed"); + return parse_nipopow_proof(&frame.body).expect("parse_nipopow_proof failed"); } } }) @@ -190,10 +200,13 @@ async fn nipopow_serve_full_chain_round_trip() { let network = Network::Testnet; eprintln!("[full-chain] connecting to {addr}"); - let stream = timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS), TcpStream::connect(addr)) - .await - .expect("connect timed out") - .expect("tcp connect failed"); + let stream = timeout( + Duration::from_secs(CONNECT_TIMEOUT_SECS), + TcpStream::connect(addr), + ) + .await + .expect("connect timed out") + .expect("tcp connect failed"); let hs_config = HandshakeConfig { agent_name: "ergo-rust-test-client".into(), @@ -238,30 +251,29 @@ async fn nipopow_serve_full_chain_round_trip() { body.len() ); let send_at = std::time::Instant::now(); - conn.write_frame(&Frame { code: GET_NIPOPOW_PROOF, body }) - .await - .expect("write frame failed"); - - let proof_bytes = timeout( - Duration::from_secs(FULL_CHAIN_BUILD_TIMEOUT_SECS), - async { - loop { - let frame = conn - .read_frame(&Blacklist::new()) - .await - .expect("read frame failed"); - eprintln!( - "[full-chain] received frame code={} body_len={}", - frame.code, - frame.body.len() - ); - if frame.code == NIPOPOW_PROOF { - return parse_nipopow_proof(&frame.body) - .expect("parse_nipopow_proof failed"); - } + conn.write_frame(&Frame { + code: GET_NIPOPOW_PROOF, + body, + }) + .await + .expect("write frame failed"); + + let proof_bytes = timeout(Duration::from_secs(FULL_CHAIN_BUILD_TIMEOUT_SECS), async { + loop { + let frame = conn + .read_frame(&Blacklist::new()) + .await + .expect("read frame failed"); + eprintln!( + "[full-chain] received frame code={} body_len={}", + frame.code, + frame.body.len() + ); + if frame.code == NIPOPOW_PROOF { + return parse_nipopow_proof(&frame.body).expect("parse_nipopow_proof failed"); } - }, - ) + } + }) .await .expect("full-chain build timed out — perf regression?"); let elapsed = send_at.elapsed(); @@ -317,13 +329,18 @@ async fn nipopow_serve_full_chain_round_trip() { #[ignore = "requires a running ergo-node-rust on NIPOPOW_TARGET (default 127.0.0.1:9030)"] async fn nipopow_serve_no_anchor_repro() { let target = env::var("NIPOPOW_TARGET").unwrap_or_else(|_| DEFAULT_TARGET.to_string()); - let addr: SocketAddr = target.parse().expect("NIPOPOW_TARGET must be a valid SocketAddr"); + let addr: SocketAddr = target + .parse() + .expect("NIPOPOW_TARGET must be a valid SocketAddr"); eprintln!("[no-anchor] connecting to {addr}"); - let stream = timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS), TcpStream::connect(addr)) - .await - .expect("connect timed out") - .expect("tcp connect failed"); + let stream = timeout( + Duration::from_secs(CONNECT_TIMEOUT_SECS), + TcpStream::connect(addr), + ) + .await + .expect("connect timed out") + .expect("tcp connect failed"); let hs_config = HandshakeConfig { agent_name: "ergo-rust-test-client".into(), @@ -347,13 +364,19 @@ async fn nipopow_serve_no_anchor_repro() { body.push(0); // header_id_present = 0 body.put_u16(0).expect("vec write"); // pad_len = 0 - conn.write_frame(&Frame { code: GET_NIPOPOW_PROOF, body }) - .await - .expect("write frame failed"); + conn.write_frame(&Frame { + code: GET_NIPOPOW_PROOF, + body, + }) + .await + .expect("write frame failed"); let proof_bytes = timeout(Duration::from_secs(FULL_CHAIN_BUILD_TIMEOUT_SECS), async { loop { - let frame = conn.read_frame(&Blacklist::new()).await.expect("read frame failed"); + let frame = conn + .read_frame(&Blacklist::new()) + .await + .expect("read frame failed"); if frame.code == NIPOPOW_PROOF { return parse_nipopow_proof(&frame.body).expect("parse_nipopow_proof failed"); } diff --git a/tests/snapshot_round_trip_test.rs b/tests/snapshot_round_trip_test.rs index b0d613f..c245e9a 100644 --- a/tests/snapshot_round_trip_test.rs +++ b/tests/snapshot_round_trip_test.rs @@ -57,8 +57,7 @@ fn dump_serve_receive_round_trip() { .unwrap(); } - let _persistent = - PersistentBatchAVLProver::new(prover, Box::new(storage), vec![]).unwrap(); + let _persistent = PersistentBatchAVLProver::new(prover, Box::new(storage), vec![]).unwrap(); let digest = _persistent.digest(); let original_root: [u8; 32] = digest[..32].try_into().unwrap(); let original_height = digest[32]; @@ -113,7 +112,8 @@ fn dump_serve_receive_round_trip() { key_length: 32, value_length: None, }; - let mut storage2 = RedbAVLStorage::open(&state2_path, params2, 0, CacheSize::default()).unwrap(); + let mut storage2 = + RedbAVLStorage::open(&state2_path, params2, 0, CacheSize::default()).unwrap(); let mut version_bytes = Vec::with_capacity(33); version_bytes.extend_from_slice(&dump.root_hash); diff --git a/tests/snapshot_serve_test.rs b/tests/snapshot_serve_test.rs index bcdcddd..ad2bc26 100644 --- a/tests/snapshot_serve_test.rs +++ b/tests/snapshot_serve_test.rs @@ -60,7 +60,10 @@ fn handle_get_manifest_unknown_id_returns_none() { let store = SnapshotStore::open(&dir.path().join("snapshots.redb")).unwrap(); let response = handle_snapshot_request(GET_MANIFEST, &[0xFF; 32], &store); - assert!(response.is_none(), "unknown manifest should return no response"); + assert!( + response.is_none(), + "unknown manifest should return no response" + ); } #[test] @@ -71,7 +74,13 @@ fn handle_get_chunk_returns_stored_chunk() { let chunk_id = [0xCC; 32]; let chunk_data = vec![0x01, 0x03, 0x04, 0x05]; store - .write_snapshot(7000, [0xDD; 32], &[0x10], &[(chunk_id, chunk_data.clone())], 2) + .write_snapshot( + 7000, + [0xDD; 32], + &[0x10], + &[(chunk_id, chunk_data.clone())], + 2, + ) .unwrap(); let response = handle_snapshot_request(GET_UTXO_SNAPSHOT_CHUNK, &chunk_id, &store); diff --git a/tests/snapshot_store_test.rs b/tests/snapshot_store_test.rs index 8103957..bbfc296 100644 --- a/tests/snapshot_store_test.rs +++ b/tests/snapshot_store_test.rs @@ -18,7 +18,10 @@ fn store_and_retrieve_snapshot() { 1000, manifest_id, &manifest_bytes, - &[(chunk_id_1, chunk_data_1.clone()), (chunk_id_2, chunk_data_2.clone())], + &[ + (chunk_id_1, chunk_data_1.clone()), + (chunk_id_2, chunk_data_2.clone()), + ], 2, ) .unwrap(); @@ -37,14 +40,8 @@ fn store_and_retrieve_snapshot() { assert_eq!(store.get_manifest(&[0xFF; 32]).unwrap(), None); // Verify chunk lookup - assert_eq!( - store.get_chunk(&chunk_id_1).unwrap(), - Some(chunk_data_1) - ); - assert_eq!( - store.get_chunk(&chunk_id_2).unwrap(), - Some(chunk_data_2) - ); + assert_eq!(store.get_chunk(&chunk_id_1).unwrap(), Some(chunk_data_1)); + assert_eq!(store.get_chunk(&chunk_id_2).unwrap(), Some(chunk_data_2)); assert_eq!(store.get_chunk(&[0xFF; 32]).unwrap(), None); } diff --git a/validation/Cargo.toml b/validation/Cargo.toml index 9ca9573..960840c 100644 --- a/validation/Cargo.toml +++ b/validation/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ergo-validation" -version = "0.1.0" +version.workspace = true edition = "2021" license = "MIT" description = "Block validation for the Ergo Rust node" @@ -30,3 +30,7 @@ thiserror = "2" ergotree-interpreter = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } ergotree-ir = { git = "https://github.com/mwaddip/sigma-rust.git", rev = "f76db922" } tempfile = "3" +# `no-env-filter` is required, not cosmetic: without it the default filter +# drops the emits under test and a "and warns" assertion silently passes on +# an empty buffer. +tracing-test = { version = "0.2", features = ["no-env-filter"] } diff --git a/validation/src/digest.rs b/validation/src/digest.rs index 28dbb0b..fd6e839 100644 --- a/validation/src/digest.rs +++ b/validation/src/digest.rs @@ -16,7 +16,9 @@ use crate::sections::{parse_ad_proofs, parse_block_transactions, parse_extension use crate::state_changes::{compute_state_changes, transactions_to_summaries}; use crate::tx_validation; use crate::voting; -use crate::{ApplyStateOutcome, BlockValidator, ValidationError}; +use crate::{ + ApplyStateOutcome, BlockValidator, ScriptEvalInputs, StatePersistence, ValidationError, +}; /// Key length for Ergo's UTXO AVL+ tree (BoxId = 32 bytes). pub(crate) const KEY_LENGTH: usize = 32; @@ -100,24 +102,24 @@ impl BlockValidator for DigestValidator { // Uses JVM v6 matchParameters60 semantics: local can have fewer // entries than received, every entry in local must match received. // At v4+ the proposedUpdate byte-for-byte comparison also runs. - let (epoch_boundary_params, epoch_boundary_proposed_update) = - match expected_boundary_params { - Some(expected) => { - let parsed = voting::parse_parameters_from_extension(&parsed_ext)?; - let parsed_pu = voting::extract_proposed_update(&parsed_ext); - let expected_pu = expected_proposed_update.unwrap_or(&[]); - voting::check_parameters_v6( - expected, - &parsed, - header.height, - header.version, - expected_pu, - &parsed_pu, - )?; - (Some(parsed), Some(parsed_pu)) - } - None => (None, None), - }; + let (epoch_boundary_params, epoch_boundary_proposed_update) = match expected_boundary_params + { + Some(expected) => { + let parsed = voting::parse_parameters_from_extension(&parsed_ext)?; + let parsed_pu = voting::extract_proposed_update(&parsed_ext); + let expected_pu = expected_proposed_update.unwrap_or(&[]); + voting::check_parameters_v6( + expected, + &parsed, + header.height, + header.version, + expected_pu, + &parsed_pu, + )?; + (Some(parsed), Some(parsed_pu)) + } + None => (None, None), + }; // 1b. Block-version gate (consensus check — JVM exBlockVersion). // Boundary-only: the JVM checks header.version against the newly @@ -130,10 +132,9 @@ impl BlockValidator for DigestValidator { } // 2. Verify AD proofs digest matches header - let proof_digest: [u8; 32] = blake2::Blake2b::::digest( - &parsed_proofs.proof_bytes, - ) - .into(); + let proof_digest: [u8; 32] = + blake2::Blake2b::::digest(&parsed_proofs.proof_bytes) + .into(); let expected_digest: [u8; 32] = header.ad_proofs_root.into(); if proof_digest != expected_digest { return Err(ValidationError::ProofDigestMismatch { @@ -183,11 +184,9 @@ impl BlockValidator for DigestValidator { let mut proof_box_bytes: HashMap<[u8; 32], Vec> = HashMap::new(); for (i, op) in operations.iter().enumerate() { - let result = verifier - .perform_one_operation(op) - .map_err(|e| ValidationError::ProofVerificationFailed( - format!("operation {i} failed: {e}"), - ))?; + let result = verifier.perform_one_operation(op).map_err(|e| { + ValidationError::ProofVerificationFailed(format!("operation {i} failed: {e}")) + })?; if validate_txs { if let Some(value) = result { @@ -221,24 +220,34 @@ impl BlockValidator for DigestValidator { } } - // 7. Build DeferredEval for deferred script verification - let deferred_eval = if validate_txs { + // 7. Script evaluation. Always here, never the caller's — an Ok from + // this function means the block's scripts passed. + // + // Same ordering rule as UTXO mode (state-root check first, it is + // cheap and rejects malformed blocks before the expensive step), and + // the same single entry point through `evaluate_scripts` so the two + // validators cannot drift on a consensus path. Nothing to roll back + // on the Err path: step 8 below is the only mutation in the whole + // function, and it has not happened yet. + if validate_txs { let mut proof_boxes = HashMap::with_capacity(proof_box_bytes.len()); for (id, bytes) in &proof_box_bytes { proof_boxes.insert(*id, tx_validation::deserialize_box(bytes)?); } - Some(crate::DeferredEval { + let eval = ScriptEvalInputs { height: header.height, transactions: parsed_txs.transactions, proof_boxes, header: header.clone(), preceding_headers: preceding_headers.to_vec(), parameters: active_params.clone(), - }) - } else { - None - }; + }; + + // Cost discarded, not unchecked — the maxBlockCost gate runs + // inside evaluate_scripts. + tx_validation::evaluate_scripts(&eval)?; + } // 8. Advance state self.current_digest = header.state_root; @@ -249,7 +258,6 @@ impl BlockValidator for DigestValidator { Ok(ApplyStateOutcome { epoch_boundary_params, epoch_boundary_proposed_update, - deferred_eval, }) } @@ -269,4 +277,157 @@ impl BlockValidator for DigestValidator { tracing::info!(height, "validator reset to fork point"); Ok(()) } + + /// Digest mode owns no persistent state — there is nothing to flush and + /// no cache to resize, so there is no [`StatePersistence`] to hand out. + /// + /// ⚠ The absence of a [`StatePersistence`] impl is the mode signal. Do + /// not "helpfully" give `DigestValidator` a no-op one — a defaulted + /// `resize_cache` on `BlockValidator` is exactly what let the enum + /// wrapper in `src/main.rs` drop the at-tip cache resize for the life of + /// the feature while logging success. + fn state_persistence(&self) -> Option<&dyn StatePersistence> { + None + } +} + +#[cfg(test)] +mod tests { + //! Digest-mode script evaluation. The blocks here are real: a + //! `BatchAVLProver` builds the tree and emits the block's AD proof, which + //! is what the validator's `BatchAVLVerifier` replays — the same + //! prover→verifier path a digest-mode node walks against a serving peer. + + use super::*; + use crate::test_support::*; + use ergo_avltree_rust::batch_avl_prover::BatchAVLProver; + use ergo_avltree_rust::operation::{KeyValue, Operation}; + use ergo_chain_types::blake2b256_hash; + use ergo_lib::chain::transaction::Transaction; + use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; + + /// A block ready to feed `apply_state`, with the AD proof that backs it. + struct Fixture { + pre_digest: ADDigest, + header: Header, + txs: Vec, + proofs: Vec, + extension: Vec, + preceding: Vec
, + } + + /// Seed a tree with `boxes`, then run `transactions` over it, keeping the + /// resulting proof. The trailing `generate_proof` after seeding is what + /// makes the block's proof cover the block's operations and nothing else. + fn fixture(boxes: &[ErgoBox], transactions: &[Transaction]) -> Fixture { + let tree = AVLTree::with_resolver(label_preserving_resolver(), KEY_LENGTH, None); + let mut prover = BatchAVLProver::new(tree, false); + + for b in boxes { + prover + .perform_one_operation(&Operation::Insert(KeyValue { + key: Bytes::copy_from_slice(&box_key(b)), + value: Bytes::from(serialized_box(b)), + })) + .expect("seed insert"); + } + let _ = prover.generate_proof(); + let pre_digest = ad_digest(&prover.digest().expect("seeded tree has a root")); + + for op in block_operations(transactions) { + prover.perform_one_operation(&op).expect("block operation"); + } + let post_digest = ad_digest(&prover.digest().expect("post-block root")); + let proof = prover.generate_proof(); + + let (txs, extension) = sections(transactions); + Fixture { + pre_digest, + header: make_header(BLOCK_HEIGHT, post_digest, blake2b256_hash(proof.as_ref())), + txs, + proofs: crate::sections::serialize_ad_proofs(&HEADER_ID, proof.as_ref()), + extension, + preceding: preceding_headers(), + } + } + + impl Fixture { + fn validator(&self) -> DigestValidator { + DigestValidator::from_state(self.pre_digest, SEED_HEIGHT, 0) + } + + fn apply( + &self, + validator: &mut DigestValidator, + ) -> Result { + validator.apply_state( + &self.header, + &self.txs, + Some(&self.proofs), + &self.extension, + &self.preceding, + &Parameters::default(), + None, + None, + ) + } + } + + /// The accept arm, and the reason the rejection tests below are not + /// vacuous: a block whose scripts are satisfied is accepted and advances + /// state. Without this a validator that rejected everything would pass + /// the rest of this module. + #[test] + fn a_valid_block_is_accepted_and_advances_state() { + let input = make_box(true, 11); + let tx = spend_tx(std::slice::from_ref(&input)); + let f = fixture(std::slice::from_ref(&input), std::slice::from_ref(&tx)); + + let mut validator = f.validator(); + f.apply(&mut validator).expect("valid block applies"); + + assert_eq!(validator.validated_height(), BLOCK_HEIGHT); + assert_eq!(*validator.current_digest(), f.header.state_root); + } + + /// Digest mode has no prover and no persistence — its whole state is two + /// fields written at the very end — so "Err leaves nothing behind" means + /// those two fields never move. + #[test] + fn script_failure_leaves_state_unchanged() { + let input = make_box(false, 12); + let tx = spend_tx(std::slice::from_ref(&input)); + let f = fixture(std::slice::from_ref(&input), std::slice::from_ref(&tx)); + + let mut validator = f.validator(); + let err = f + .apply(&mut validator) + .expect_err("an unsatisfied script must be rejected"); + assert!( + matches!(err, ValidationError::TransactionInvalid { .. }), + "expected a script failure, got: {err:?}" + ); + assert_eq!(validator.validated_height(), SEED_HEIGHT); + assert_eq!(*validator.current_digest(), f.pre_digest); + } + + /// At or below the checkpoint no script is looked at — the AD proof + /// replay alone is the guarantee. The block below carries an + /// unsatisfiable script and is accepted anyway, which is the whole + /// observable: had it been evaluated, the test above shows what happens. + #[test] + fn the_checkpoint_skips_evaluation_entirely() { + let input = make_box(false, 13); + let tx = spend_tx(std::slice::from_ref(&input)); + let f = fixture(std::slice::from_ref(&input), std::slice::from_ref(&tx)); + + // Checkpoint at the block's own height: `height > checkpoint` is + // false, so the unsatisfiable script is never looked at. + let mut validator = DigestValidator::from_state(f.pre_digest, SEED_HEIGHT, BLOCK_HEIGHT); + f.apply(&mut validator) + .expect("checkpointed block applies without script evaluation"); + + assert_eq!(validator.validated_height(), BLOCK_HEIGHT); + assert_eq!(*validator.current_digest(), f.header.state_root); + } } diff --git a/validation/src/lib.rs b/validation/src/lib.rs index 158e7d3..bc1f9a6 100644 --- a/validation/src/lib.rs +++ b/validation/src/lib.rs @@ -1,6 +1,9 @@ mod digest; +mod prover_memory; mod sections; mod state_changes; +#[cfg(test)] +mod test_support; mod tx_validation; mod utxo; mod voting; @@ -10,15 +13,19 @@ use std::collections::HashMap; use ergo_chain_types::{ADDigest, Header}; pub use digest::DigestValidator; +pub use prover_memory::ProverMemoryEstimate; pub use sections::{ - ExtensionField, ParsedAdProofs, ParsedBlockTransactions, ParsedExtension, parse_block_transactions, - parse_extension, serialize_ad_proofs, serialize_block_transactions, serialize_extension, + parse_block_transactions, parse_extension, serialize_ad_proofs, serialize_block_transactions, + serialize_extension, ExtensionField, ParsedAdProofs, ParsedBlockTransactions, ParsedExtension, +}; +pub use state_changes::{ + compute_state_changes, transactions_to_summaries, Insertion, StateChanges, }; -pub use state_changes::{StateChanges, compute_state_changes, transactions_to_summaries}; pub use tx_validation::{ - build_state_context, deserialize_box, evaluate_scripts, validate_single_transaction, + build_state_context, build_upcoming_state_context, deserialize_box, evaluate_scripts, + validate_single_transaction, }; -pub use utxo::UtxoValidator; +pub use utxo::{proofs_from_storage, EmissionSource, UtxoValidator}; pub use voting::{pack_parameters, parse_parameters_from_extension}; // Re-export types needed by mempool callers @@ -28,6 +35,10 @@ pub use ergo_lib::chain::transaction::Transaction; pub use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox; /// Outcome of a successful state application. +/// +/// Carries nothing about script evaluation, because there is nothing to +/// carry: `apply_state` evaluates the block's scripts itself and an `Ok` +/// already means they passed (facts/validation.md, "Script evaluation"). #[derive(Debug)] pub struct ApplyStateOutcome { /// `Some(parsed)` if this was an epoch-boundary block AND the parsed @@ -41,18 +52,21 @@ pub struct ApplyStateOutcome { /// passes this to `chain.apply_epoch_boundary_parameters` alongside /// the parameters so both advance atomically. pub epoch_boundary_proposed_update: Option>, - /// `Some` if script evaluation is needed (height > checkpoint). - /// The caller should pass this to `evaluate_scripts()` — either - /// inline or on a background thread; on success it returns the - /// block-accumulated transaction cost. - pub deferred_eval: Option, } -/// Everything needed to verify transaction spending proofs. -/// Owned, `Send` — can move to any thread for background evaluation. +/// Everything needed to verify a block's transaction spending proofs. +/// +/// Built inside `apply_state` and consumed by [`evaluate_scripts`] a few +/// lines later — it never leaves the stack frame that built it. That is why +/// it is a plain bundle of public fields: no constructor to route through, no +/// self-weighing, and no `Send` requirement, all of which existed only to +/// feed a queue that no longer exists. +/// +/// **Renamed from `DeferredEval` in v0.8.0.** The old name described a +/// deferral that does not happen. #[derive(Debug)] -pub struct DeferredEval { - /// Block height (for error reporting and result tracking). +pub struct ScriptEvalInputs { + /// Block height (for error reporting). pub height: u32, /// Parsed transactions from the block. pub transactions: Vec, @@ -79,16 +93,23 @@ pub struct DeferredEval { /// returned `ApplyStateOutcome::epoch_boundary_params` carries the parsed /// parameters for the caller to apply via `chain.apply_epoch_boundary_parameters`. /// -/// Script evaluation is NOT performed by `apply_state`. Instead, the caller -/// receives a `DeferredEval` and runs `evaluate_scripts()` — either inline -/// or on a background thread — which returns the block-accumulated cost -/// on success. +/// The validator evaluates the block's scripts itself, before persisting. +/// There is no mode and nothing the caller is left owing — see +/// [`apply_state`](BlockValidator::apply_state). pub trait BlockValidator { /// Apply state transition: parse sections, compute state changes, - /// apply AVL operations, verify digest, persist. + /// apply AVL operations, verify digest, evaluate scripts, persist — + /// in that order. + /// + /// After Ok, state has advanced to this block's height AND the block's + /// scripts have been evaluated and passed. `Ok` means exactly that; + /// there is nothing left owed. Heights at or below the validator's + /// `checkpoint_height` skip evaluation entirely — the state-root check + /// alone is the guarantee there. /// - /// After Ok, state has advanced to this block's height. - /// Script evaluation is deferred — see `ApplyStateOutcome::deferred_eval`. + /// On Err nothing moved: `validated_height()`, `current_digest()`, and + /// the underlying prover are exactly as they were on entry, so the + /// caller may retry the block or move on to another candidate. #[allow(clippy::too_many_arguments)] fn apply_state( &mut self, @@ -108,7 +129,7 @@ pub trait BlockValidator { /// Current state root digest (33 bytes). fn current_digest(&self) -> &ADDigest; - /// Reset to a previous state after reorg or deferred-eval failure. + /// Reset to a previous state after a reorg. /// /// On Err the underlying state rollback FAILED and the validator's /// observable state is UNCHANGED: `validated_height()`, @@ -117,35 +138,94 @@ pub trait BlockValidator { /// onto the un-rolled state — it decides recovery. fn reset_to(&mut self, height: u32, digest: ADDigest) -> Result<(), ValidationError>; + /// Does this validator own persistent state? `Some` hands out its storage + /// lifecycle; `None` means it owns none (digest mode). + /// + /// REQUIRED — no default body. `sync/` is generic over + /// `V: BlockValidator` (`HeaderSync`) and never names main's + /// `Validator`, so this is the only route by which a generic caller can + /// reach [`StatePersistence`]. [`MiningState`] needs no such accessor + /// because its only consumer is `main`, which does name the type. + /// + /// This method answers a capability question; it does NOT perform work. + /// That is what separates it from the defaulted no-ops it replaces — a + /// `None` return is a truthful answer, whereas `Ok(())` from a defaulted + /// `flush` was a claim that work happened. + fn state_persistence(&self) -> Option<&dyn StatePersistence>; +} + +/// Storage lifecycle. Implemented by [`UtxoValidator`] only — a validator that +/// owns no persistent state does not implement it, and the caller handles that +/// case explicitly rather than being handed a successful no-op. +/// +/// ⚠ **The absence of an impl is the mode signal.** [`DigestValidator`] must +/// never gain a no-op impl of this trait: a defaulted `resize_cache` on +/// `BlockValidator` is exactly what let the enum wrapper in `src/main.rs` +/// silently drop the at-tip cache resize for the life of the feature, while +/// logging success (facts/validation.md). +pub trait StatePersistence { /// Force a durable commit (fsync) of all outstanding storage writes. - /// Call periodically during long sweeps (bounds crash data loss) and - /// on graceful shutdown. Digest-mode validators may make this a no-op. - fn flush(&self) -> Result<(), ValidationError> { - Ok(()) - } - - /// Resize the storage read cache at runtime (e.g. after initial sync). - /// Digest-mode validators may make this a no-op. - fn resize_cache(&self, _cache_bytes: usize) -> Result<(), ValidationError> { - Ok(()) - } - - /// Compute AD proofs and new state root for a set of transactions - /// without modifying persistent state. Returns None for digest-mode - /// validators (mining requires UTXO mode). + /// Called at sweep flush points (bounds crash data loss) and on graceful + /// shutdown. + /// + /// Postconditions on Ok: every write issued before this call is durable. + /// Postconditions on Err: durability is UNKNOWN. The caller must not + /// advance any watermark that assumes persistence — see facts/sync.md + /// § "Flush ordering". + fn flush(&self) -> Result<(), ValidationError>; + + /// Resize the storage read cache at runtime (e.g. on reaching the tip). + /// + /// ⚠ Read cache only. `stateCacheBytes` covers read + write, so a 64 MB + /// resize gives roughly a 128 MB envelope, not 64. + fn resize_cache(&self, cache_bytes: usize) -> Result<(), ValidationError>; + + /// Resident bytes held by the AVL prover, best-effort. + /// `None` when a figure cannot be computed — never a fabricated zero. + /// + /// It belongs on this trait because the prover *is* the state this trait + /// already governs, and because [`DigestValidator`] does not implement the + /// trait at all: digest mode therefore reports absent, which is correct — + /// it has no prover. + /// + /// REQUIRED — no default body, matching the cache accessors. A default + /// would let an implementor silently return a wrong value, which is + /// structurally how `AVG_HEADER_BYTES` survived four months of reporting + /// 1.48 GB for a `Vec` that no longer existed (`facts/api.md`). + /// + /// ⚠ **O(resident nodes).** The figure is walked, not multiplied, so the + /// call costs a full traversal of the materialised tree — on a synced + /// mainnet node, the same order as applying a block. Sample it on a flush + /// cadence or every N blocks; not per block, and never from an HTTP + /// handler. + fn prover_memory_estimate(&self) -> Option; +} + +/// Mining support. Implemented by [`UtxoValidator`] only — candidate assembly +/// requires a live UTXO set, so digest mode does not implement it and the +/// caller skips mining rather than receiving a `None` that means "wrong mode". +pub trait MiningState { + /// Compute AD proofs and the resulting state root for a set of + /// transactions WITHOUT modifying persistent state. + /// + /// No outer `Option`: "wrong mode" is expressed by not implementing the + /// trait, so every layer of this return type now carries exactly one + /// meaning. An `Err` is a real failure to compute the proof. fn proofs_for_transactions( &self, txs: &[Transaction], - ) -> Option, ADDigest), ValidationError>> { - let _ = txs; - None - } - - /// Current emission box ID in the UTXO set. None if digest mode or - /// all ERG emitted. Updated after each block validation. - fn emission_box_id(&self) -> Option<[u8; 32]> { - None - } + ) -> Result<(Vec, ADDigest), ValidationError>; + + /// Current emission box ID in the UTXO set, updated after each block. + /// + /// `None` means **all ERG has been emitted** — nothing else. It no longer + /// doubles as the digest-mode signal. + /// + /// ⚠ **Valid immediately after construction, not only after the first + /// applied block.** [`UtxoValidator::new`] recovers it from a required + /// [`EmissionSource`]; see facts/validation.md, "Recovering + /// `emission_box_id` on resume". + fn emission_box_id(&self) -> Option<[u8; 32]>; } #[derive(Debug, thiserror::Error)] diff --git a/validation/src/prover_memory.rs b/validation/src/prover_memory.rs new file mode 100644 index 0000000..f38c216 --- /dev/null +++ b/validation/src/prover_memory.rs @@ -0,0 +1,178 @@ +//! Prover memory attribution — measured from the live AVL structures. +//! +//! `/debug/memory` could not name most of a catching-up node's heap: a v0.8.0 +//! node that applied ~27k blocks held 1356 MB of live heap (87%) that no crate +//! claimed, while a node at the same tip that did not sync held 214 MB +//! unattributed. The caches were not the difference — the catch-up node's were +//! already down to 34 MB state / 18 MB store. Something that grows with block +//! *application* and is never released is holding it, and the AVL prover was +//! one of two suspects that report nothing (`facts/api.md`, +//! `facts/validation.md`). +//! +//! ## Why the prover's tree is a candidate at all +//! +//! `AVLTree::resolve` replaces a `Node::LabelOnly` child **in place** with the +//! node unpacked from storage (`batch_node.rs`), and nothing ever converts a +//! resolved node back to a label. `RedbAVLStorage::update_internal` does not +//! reinstall the root either — it commits, resets the dirty flags, and clears +//! the changed-node buffers, leaving `tree.root` exactly as the block left it +//! (`state/src/storage.rs`). So every node touched since the last +//! `restore_root` stays materialised in RAM, and the resident tree grows +//! monotonically — with reads as much as with writes, since a lookup resolves +//! too. That is the shape of the reported symptom; this module measures it +//! rather than asserting it. +//! +//! ## Everything here is counted, never multiplied +//! +//! `AVG_HEADER_BYTES` reported 1.48 GB for a `Vec` that had been deleted four +//! months earlier because it multiplied a count by a constant nobody rechecked +//! (`facts/api.md`). Nothing here is a constant: the per-node figure is +//! `size_of` of the real types, so it moves if the fork's `Node` changes, and +//! every payload figure is the `len()` of the actual buffer. + +use std::cell::RefCell; + +use ergo_avltree_rust::authenticated_tree_ops::AuthenticatedTreeOpsBase; +use ergo_avltree_rust::batch_avl_prover::BatchAVLProver; +use ergo_avltree_rust::batch_node::{Node, NodeHeader, NodeId}; + +/// Resident bytes held by the AVL prover, best-effort. +/// +/// The two byte figures are **disjoint** and may be summed. Neither is derived +/// from the other, and neither is a count times a constant. +/// +/// ⚠ **Producing this costs O(`node_count`)** — a full traversal of the +/// materialised tree, which on a synced mainnet node is the same order as +/// applying a block. It is a diagnostic, not an accessor: sample it on a flush +/// cadence or every N blocks, never per block and never on an HTTP path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProverMemoryEstimate { + /// Retained allocation of the prover's three per-cycle bookkeeping + /// containers: `modified_nodes` (the address-keyed map proof generation + /// gates on), `changed_nodes_buffer`, and `changed_nodes_buffer_to_check`. + /// All three are populated as nodes are visited and cleared at the end of + /// the proof cycle, so between blocks this is small and stable. + /// + /// It counts the **containers, not the `Node`s they reference** — those + /// are the same allocations `resident_nodes_bytes` already counted, and + /// adding them here would double-count the tree against itself. + /// + /// Stated blind spot: a copy-on-write predecessor displaced from the tree + /// but still pinned by `changed_nodes_buffer` is counted by neither field. + /// It is bounded by one block's working set and freed when the buffers + /// clear, so it cannot be a source of unbounded growth — but it is not + /// zero, and this is where it went. + /// + /// `Vec` capacity is exact retained heap and is used as such, so a buffer + /// that was cleared but kept a large allocation still shows up. `BTreeMap` + /// exposes only `len()`, so its slot payload is a lower bound on the + /// B-tree's internal arrays. + pub modified_nodes_bytes: u64, + /// Tree nodes held resident between blocks: every `Rc>` + /// allocation reachable from the root **without touching storage**, plus + /// the key/value bytes each node owns. This is the figure that grows with + /// blocks applied. + /// + /// Accuracy, in both directions: + /// - **Under** by the allocator's per-allocation overhead and size-class + /// rounding, so real RSS is at least this. + /// - **Under** by the `bytes::Bytes` shared-control-block allocation (tens + /// of bytes per storage-unpacked node). + /// - **Over** where several `Bytes` handles share one allocation and each + /// contributes its `len()`. On the dominant path — `AVLTree::unpack`, + /// which splits one buffer into key, value and next-key — those three + /// lengths sum to very nearly that one allocation, so this term is + /// small. + /// + /// Not counted: the tree's `Resolver`, an `Arc` capturing the redb + /// handle already attributed as `stateCacheBytes`. + pub resident_nodes_bytes: u64, + /// Number of resident nodes behind `resident_nodes_bytes`, so a + /// bytes-per-node that drifts is visible rather than inferred. Includes + /// the unresolved `LabelOnly` frontier, which is a real allocation with no + /// payload. `modified_nodes_bytes` counts no nodes and is not in this + /// ratio. + pub node_count: u64, +} + +/// Measure the prover's resident heap. +/// +/// `None` only when the prover has no root, which is the one state in which +/// there is genuinely nothing to measure. A rooted prover always yields a +/// figure, because a root is itself at least one real allocation — so `0` is +/// unreachable here and `None` never has to stand in for "small". +/// +/// The walk is depth-first from `tree.root` and **never resolves**: it matches +/// on each node in place and stops at every `Node::LabelOnly`, seeing exactly +/// what is already in RAM. Going through `AVLTree::left`/`right` instead would +/// resolve each label out of redb and pull the entire UTXO set into memory — +/// the measurement would cause the exhaustion it exists to diagnose. +/// +/// Per node it counts the `Rc>` allocation and the `Bytes` the +/// node owns: the header key, and a leaf's value and next-node key. `Digest32` +/// labels are `[u8; 32]` stored inline and are already inside +/// `size_of::()`. +pub(crate) fn estimate(prover: &BatchAVLProver) -> Option { + let root = prover.base.tree.root.as_ref()?; + + let per_node = node_allocation_bytes(); + let mut node_count: u64 = 0; + let mut resident_nodes_bytes: u64 = 0; + + // Explicit stack, not recursion: the fork hit a stack-exhausting spine in + // `label_subtree` and solved it the same way. An AVL tree keeps this to + // O(height) entries — the pending siblings along the current path. + let mut stack: Vec = vec![root.clone()]; + while let Some(node) = stack.pop() { + node_count += 1; + resident_nodes_bytes += per_node; + match &*node.borrow() { + // The unresolved frontier: a real allocation holding a label, and + // the point past which nothing is in memory. + Node::LabelOnly(hdr) => resident_nodes_bytes += header_payload_bytes(hdr), + Node::Internal(inner) => { + resident_nodes_bytes += header_payload_bytes(&inner.hdr); + stack.push(inner.left.clone()); + stack.push(inner.right.clone()); + } + Node::Leaf(leaf) => { + resident_nodes_bytes += header_payload_bytes(&leaf.hdr) + + leaf.value.len() as u64 + + leaf.next_node_key.len() as u64; + } + } + } + + Some(ProverMemoryEstimate { + modified_nodes_bytes: cycle_buffer_bytes(&prover.base), + resident_nodes_bytes, + node_count, + }) +} + +/// Heap bytes for one `Rc>`: `RcBox`'s strong and weak counters +/// followed by the payload. Derived from the fork's own types, so a change to +/// `Node` moves this figure instead of silently invalidating it — 160 bytes +/// against the pinned rev. +pub(crate) fn node_allocation_bytes() -> u64 { + (2 * std::mem::size_of::() + std::mem::size_of::>()) as u64 +} + +/// The key a node owns. `label` is an inline `[u8; 32]` and is already in +/// `size_of::()`; only the `Bytes` key is out of line. +fn header_payload_bytes(hdr: &NodeHeader) -> u64 { + hdr.key.as_ref().map_or(0, |key| key.len()) as u64 +} + +/// Retained allocation of the per-cycle bookkeeping containers. All three +/// share one lifecycle: populated by `on_node_visit`, cleared by +/// `generate_proof()` and `restore_root()`, and the two `Vec`s additionally by +/// `RedbAVLStorage::update_internal`. +fn cycle_buffer_bytes(base: &AuthenticatedTreeOpsBase) -> u64 { + let map_slot = (std::mem::size_of::() + std::mem::size_of::()) as u64; + let vec_slot = std::mem::size_of::() as u64; + + base.modified_nodes.len() as u64 * map_slot + + base.changed_nodes_buffer.capacity() as u64 * vec_slot + + base.changed_nodes_buffer_to_check.capacity() as u64 * vec_slot +} diff --git a/validation/src/sections.rs b/validation/src/sections.rs index 523c7bb..1157f5c 100644 --- a/validation/src/sections.rs +++ b/validation/src/sections.rs @@ -48,7 +48,10 @@ fn section_parse_err(section_type: u8, reason: impl Into) -> ValidationE pub fn parse_ad_proofs(data: &[u8]) -> Result { const TYPE_ID: u8 = 104; if data.len() < 33 { - return Err(section_parse_err(TYPE_ID, "too short for header_id + proof_size")); + return Err(section_parse_err( + TYPE_ID, + "too short for header_id + proof_size", + )); } let header_id: [u8; 32] = data[..32].try_into().unwrap(); let mut cursor = Cursor::new(&data[32..]); @@ -162,10 +165,7 @@ pub fn parse_extension(data: &[u8]) -> Result fields.push(ExtensionField { key, value }); } - Ok(ParsedExtension { - header_id, - fields, - }) + Ok(ParsedExtension { header_id, fields }) } // --------------------------------------------------------------------------- @@ -213,9 +213,9 @@ pub fn serialize_block_transactions( // Transactions for (i, tx) in transactions.iter().enumerate() { - let tx_bytes = tx.sigma_serialize_bytes().map_err(|e| { - section_parse_err(102, format!("transaction {i} serialize: {e}")) - })?; + let tx_bytes = tx + .sigma_serialize_bytes() + .map_err(|e| section_parse_err(102, format!("transaction {i} serialize: {e}")))?; out.extend_from_slice(&tx_bytes); } diff --git a/validation/src/state_changes.rs b/validation/src/state_changes.rs index 7055254..df27c7e 100644 --- a/validation/src/state_changes.rs +++ b/validation/src/state_changes.rs @@ -15,14 +15,22 @@ pub struct TxSummary { pub output_entries: Vec<([u8; 32], Vec)>, } +/// One box entering the UTXO set: `(box_id, serialized_box_bytes)`. +/// +/// The same pair the genesis bootstrap produces, which is why +/// [`crate::EmissionSource::GenesisBoxes`] carries it too — the emission-box +/// scan reads insertions and does not care which side of the chain's first +/// block they came from. +pub type Insertion = ([u8; 32], Vec); + /// AVL+ tree operations derived from a block's transactions. pub struct StateChanges { /// Data input lookups (box_id). Applied first. pub lookups: Vec<[u8; 32]>, /// Input removals (box_id). Applied second. pub removals: Vec<[u8; 32]>, - /// Output insertions (box_id, serialized_box_bytes). Applied third. - pub insertions: Vec<([u8; 32], Vec)>, + /// Output insertions. Applied third. + pub insertions: Vec, } /// Compute AVL+ tree operations from transaction summaries. @@ -43,14 +51,14 @@ pub fn compute_state_changes( let mut removals: Vec<[u8; 32]> = Vec::new(); let mut lookups: Vec<[u8; 32]> = Vec::new(); // Preserve transaction output order for insertions - let mut all_inserts: Vec<([u8; 32], Vec)> = Vec::new(); + let mut all_inserts: Vec = Vec::new(); for summary in &tx_summaries { for &input_id in &summary.input_ids { if !spent.insert(input_id) { - return Err(ValidationError::IntraBlockDoubleSpend( - hex::encode(input_id), - )); + return Err(ValidationError::IntraBlockDoubleSpend(hex::encode( + input_id, + ))); } if created.contains(&input_id) { // Intra-block: output created by earlier tx, now spent — net-zero @@ -75,7 +83,7 @@ pub fn compute_state_changes( // Lookups preserve transaction order (data inputs don't modify the tree). removals.sort(); - let mut insertions: Vec<([u8; 32], Vec)> = all_inserts + let mut insertions: Vec = all_inserts .into_iter() .filter(|(id, _)| !netted.contains(id)) .collect(); @@ -114,20 +122,20 @@ pub fn transactions_to_summaries( .map(|dis| dis.iter().map(|di| box_id_to_bytes(&di.box_id)).collect()) .unwrap_or_default(); - let output_entries: Vec<([u8; 32], Vec)> = tx - .outputs - .iter() - .map(|output| { - let id = box_id_to_bytes(&output.box_id()); - let box_bytes = output.sigma_serialize_bytes().map_err(|e| { - ValidationError::SectionParse { - section_type: 102, - reason: format!("output serialization: {e}"), - } - })?; - Ok((id, box_bytes)) - }) - .collect::, ValidationError>>()?; + let output_entries: Vec<([u8; 32], Vec)> = + tx.outputs + .iter() + .map(|output| { + let id = box_id_to_bytes(&output.box_id()); + let box_bytes = output.sigma_serialize_bytes().map_err(|e| { + ValidationError::SectionParse { + section_type: 102, + reason: format!("output serialization: {e}"), + } + })?; + Ok((id, box_bytes)) + }) + .collect::, ValidationError>>()?; summaries.push(TxSummary { input_ids, diff --git a/validation/src/test_support.rs b/validation/src/test_support.rs new file mode 100644 index 0000000..8dd3edc --- /dev/null +++ b/validation/src/test_support.rs @@ -0,0 +1,191 @@ +//! Block fixtures shared by the validator tests. +//! +//! Everything here builds *real* artefacts — real `ErgoTree`s, real +//! transactions, real serialized sections — because the properties under test +//! (does the prover rewind, does `apply_state` actually evaluate) are only +//! observable when the prover really mutates and the interpreter really runs. +//! A stubbed block proves nothing. +//! +//! The scripts are the two trivial sigma propositions: `sigmaProp(true)` +//! verifies against an empty proof, `sigmaProp(false)` cannot, which gives a +//! script failure that does not depend on key material or on any particular +//! sigma-rust reduction. + +use bytes::Bytes; +use ergo_avltree_rust::operation::{KeyValue, Operation}; +use ergo_chain_types::{ADDigest, AutolykosSolution, BlockId, Digest32, EcPoint, Header, Votes}; +use ergo_lib::chain::transaction::input::prover_result::ProverResult; +use ergo_lib::chain::transaction::input::Input; +use ergo_lib::chain::transaction::Transaction; +use ergo_lib::ergotree_ir::chain::ergo_box::box_value::BoxValue; +use ergo_lib::ergotree_ir::chain::ergo_box::{ErgoBox, ErgoBoxCandidate, NonMandatoryRegisters}; +use ergo_lib::ergotree_ir::chain::tx_id::TxId; +use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; +use ergo_lib::ergotree_ir::serialization::SigmaSerializable; +use ergotree_interpreter::sigma_protocol::prover::ProofBytes; +use ergotree_ir::chain::context_extension::ContextExtension; +use ergotree_ir::mir::bool_to_sigma::BoolToSigmaProp; +use ergotree_ir::mir::expr::Expr; + +use crate::state_changes::{compute_state_changes, transactions_to_summaries}; + +/// Block heights are well past genesis so that nothing under test trips a +/// near-genesis special case (storage rent, short header windows). +pub const SEED_HEIGHT: u32 = 1_000; +pub const BLOCK_HEIGHT: u32 = SEED_HEIGHT + 1; + +/// Comfortably above `minValuePerByte × box size` for these small boxes. +pub const BOX_VALUE: u64 = 1_000_000_000; + +/// The header id every section in these fixtures is prefixed with. +pub const HEADER_ID: [u8; 32] = [0u8; 32]; + +/// `sigmaProp()` — a proposition that reduces to a trivial true or +/// false with no proof and no prover key. +pub fn sigma_bool_tree(value: bool) -> ErgoTree { + let expr = Expr::BoolToSigmaProp(BoolToSigmaProp { + input: Box::new(Expr::Const(value.into())), + }); + ErgoTree::try_from(expr).expect("sigmaProp(bool) is a valid ErgoTree") +} + +/// A UTXO guarded by `sigmaProp(spendable)`, distinguished from its siblings +/// by `seed` (which varies the source tx id, hence the box id, hence its key +/// in the tree). +pub fn make_box(spendable: bool, seed: u8) -> ErgoBox { + ErgoBox::new( + BoxValue::try_from(BOX_VALUE).expect("value above the minimum"), + sigma_bool_tree(spendable), + None, + NonMandatoryRegisters::empty(), + SEED_HEIGHT, + TxId::from(Digest32::from([seed; 32])), + 0, + ) + .expect("box construction") +} + +pub fn box_key(ergo_box: &ErgoBox) -> [u8; 32] { + let mut id = [0u8; 32]; + id.copy_from_slice(ergo_box.box_id().as_ref()); + id +} + +pub fn serialized_box(ergo_box: &ErgoBox) -> Vec { + ergo_box.sigma_serialize_bytes().expect("box serialization") +} + +/// A transaction spending `inputs` into a single always-true output. Value is +/// preserved exactly — Ergo forbids burning ERG, and an unbalanced tx would +/// fail for a reason that has nothing to do with what these tests measure. +pub fn spend_tx(inputs: &[ErgoBox]) -> Transaction { + spend_tx_to(inputs, sigma_bool_tree(true)) +} + +/// As [`spend_tx`], but the single output is guarded by `output_tree`. +/// +/// Output scripts are never evaluated — only inputs' are — so any tree works +/// here, including the emission contract, which is what the emission-recovery +/// tests need a block to create. +pub fn spend_tx_to(inputs: &[ErgoBox], output_tree: ErgoTree) -> Transaction { + let total: u64 = inputs.iter().map(|b| *b.value.as_u64()).sum(); + let output = ErgoBoxCandidate { + value: BoxValue::try_from(total).expect("output value above the minimum"), + ergo_tree: output_tree, + tokens: None, + additional_registers: NonMandatoryRegisters::empty(), + creation_height: BLOCK_HEIGHT, + }; + let tx_inputs: Vec = inputs + .iter() + .map(|b| { + Input::new( + b.box_id(), + ProverResult { + proof: ProofBytes::Empty, + extension: ContextExtension::empty(), + }, + ) + }) + .collect(); + Transaction::new_from_vec(tx_inputs, vec![], vec![output]).expect("transaction construction") +} + +/// The AVL operations a block of `txs` produces, in the validator's own order. +/// +/// Deliberately routed through the crate's own `compute_state_changes` rather +/// than re-derived: these fixtures exist to test *when* scripts are evaluated +/// and *what* an Err leaves behind, not whether the operation ordering is +/// right — that is `state_changes.rs`'s own tests' job, and a second ordering +/// here would only produce state-root mismatches that say nothing. +pub fn block_operations(txs: &[Transaction]) -> Vec { + let changes = compute_state_changes( + transactions_to_summaries(txs).expect("summaries from well-formed txs"), + ) + .expect("state changes from non-conflicting txs"); + + let mut ops = Vec::new(); + for lookup in &changes.lookups { + ops.push(Operation::Lookup(Bytes::copy_from_slice(lookup))); + } + for removal in &changes.removals { + ops.push(Operation::Remove(Bytes::copy_from_slice(removal))); + } + for (key, value) in &changes.insertions { + ops.push(Operation::Insert(KeyValue { + key: Bytes::copy_from_slice(key), + value: Bytes::copy_from_slice(value), + })); + } + ops +} + +pub fn ad_digest(bytes: &Bytes) -> ADDigest { + let mut arr = [0u8; 33]; + arr.copy_from_slice(bytes); + ADDigest::from(arr) +} + +/// A header for the block under test. `state_root` and `ad_proofs_root` are +/// caller-supplied because every test's point is which of them is right. +pub fn make_header(height: u32, state_root: ADDigest, ad_proofs_root: Digest32) -> Header { + Header { + version: 3, + id: BlockId(Digest32::from(HEADER_ID)), + parent_id: BlockId(Digest32::zero()), + ad_proofs_root, + state_root, + transaction_root: Digest32::zero(), + timestamp: 1_600_000_000_000 + height as u64, + n_bits: 100_000, + height, + extension_root: Digest32::zero(), + autolykos_solution: AutolykosSolution { + miner_pk: Box::new(EcPoint::default()), + pow_onetime_pk: None, + nonce: vec![0u8; 8], + pow_distance: None, + }, + votes: Votes([0, 0, 0]), + unparsed_bytes: Box::new([]), + } +} + +/// Ten preceding headers. Non-empty on purpose: `validate_transactions` +/// short-circuits to `Ok(0)` without them, which would let an "apply_state +/// rejects a bad script" test pass while evaluating nothing at all. +pub fn preceding_headers() -> Vec
{ + (0..10) + .map(|i| make_header(BLOCK_HEIGHT - 1 - i, ADDigest::zero(), Digest32::zero())) + .collect() +} + +/// Raw section bytes for a block carrying `txs`. +pub fn sections(txs: &[Transaction]) -> (Vec, Vec) { + ( + crate::sections::serialize_block_transactions(&HEADER_ID, 3, txs) + .expect("tx section serialization"), + crate::sections::serialize_extension(&HEADER_ID, &[]) + .expect("extension section serialization"), + ) +} diff --git a/validation/src/tx_validation.rs b/validation/src/tx_validation.rs index ade55e7..223de0c 100644 --- a/validation/src/tx_validation.rs +++ b/validation/src/tx_validation.rs @@ -10,6 +10,7 @@ use std::io::Cursor; use rayon::prelude::*; +use ergo_chain_types::{ec_point, Header, PreHeader, Votes}; use ergo_lib::chain::ergo_state_context::{ErgoStateContext, Headers}; use ergo_lib::chain::parameters::Parameters; use ergo_lib::chain::transaction::Transaction; @@ -18,7 +19,6 @@ use ergo_lib::ergotree_ir::serialization::constant_store::ConstantStore; use ergo_lib::ergotree_ir::serialization::sigma_byte_reader::SigmaByteReader; use ergo_lib::ergotree_ir::serialization::SigmaSerializable; use ergo_lib::wallet::tx_context::TransactionContext; -use ergo_chain_types::{Header, PreHeader}; use crate::ValidationError; @@ -58,6 +58,103 @@ pub fn build_state_context( ErgoStateContext::new(pre_header, headers, parameters.clone()) } +/// Build an ErgoStateContext for a transaction that is **not yet in a block** — +/// the mempool and API-submitted transactions. The preheader describes the +/// *next* block, the one built on `last_header`. +/// +/// `build_state_context` puts the block's own header in the preheader, which is +/// right for a block that exists and wrong for one that does not: a wallet sets +/// `creationHeight` to the block it expects to land in, ergo-lib enforces +/// `creation_height <= pre_header.height` (`tx_context.rs` `verify_output`), so +/// a preheader sitting at the tip rejects every well-formed transaction on the +/// network by exactly one block. Which builder you call is a correctness +/// decision (facts/validation.md, "Free Functions: state context"). +/// +/// # Caller contract +/// +/// `preceding_headers` are the headers **strictly before `last_header`**, +/// newest first — the same slice `build_state_context` takes for the block at +/// `last_header`, so the two builders are interchangeable at a call site. It +/// must NOT contain `last_header`; this function puts it at the head of the +/// window itself, because for the *upcoming* block the tip is the newest +/// preceding header. +/// +/// That placement is load-bearing. ergo-lib derives `lastBlockUtxoRoot` from +/// the newest header in the window (`signing.rs` `make_context`), and for an +/// unconfirmed transaction that has to be the UTXO root *after* the tip, i.e. +/// `last_header.state_root`. Pass a window whose head is the tip's parent and +/// every mempool script reading the UTXO root sees the state of one block ago, +/// silently and with no error anywhere. +/// +/// Unlike `build_state_context` there is no non-empty requirement: +/// `preceding_headers` may be empty (height 1), since `last_header` alone +/// already satisfies the `Headers` lower bound. The window is `last_header` +/// plus up to 9 of them — 10 total, the JVM's `LastHeadersInContext`. +/// +/// # JVM reference +/// +/// `ErgoStateContext.simplifiedUpcoming()` (`ErgoStateContext.scala:140`) +/// composed with `PreHeader.apply` (`PreHeader.scala:49-63`) and +/// `AutolykosPowScheme.derivedHeaderFields` (`AutolykosPowScheme.scala:455`). +/// `UpcomingStateContext` overrides `sigmaLastHeaders` to the whole +/// `lastHeaders` with no `drop(1)` (`ErgoStateContext.scala:46`) — that missing +/// drop is precisely why the tip stays in the window here while block +/// validation excludes its own header. +/// +/// Two deliberate divergences, both recorded in the contract: +/// - `parameters` are the caller's, active for `last_header`. The JVM +/// recomputes them for `height + 1` inside `simplifiedUpcoming()`; the two +/// differ only on a block that crosses an epoch boundary. +/// - `votes` is `[0, 0, 0]` where the JVM passes an empty array. `Votes` is +/// three fixed bytes and cannot represent empty; a script reading +/// `CONTEXT.preHeader.votes` sees a 3-byte zero collection instead of an +/// empty one. (The JVM's own `PreHeader.fake` uses three zero bytes.) +/// +/// **Mining does not use this builder, deliberately** — it synthesizes a stub +/// header at `height + 1` with a real wall-clock timestamp and a real miner key +/// and feeds that to `build_state_context`. A miner knows both; the mempool +/// knows neither. +pub fn build_upcoming_state_context( + last_header: &Header, + preceding_headers: &[Header], + parameters: &Parameters, +) -> ErgoStateContext { + debug_assert!( + preceding_headers + .first() + .is_none_or(|h| h.id != last_header.id), + "preceding_headers must hold only headers strictly before last_header — \ + build_upcoming_state_context prepends last_header itself" + ); + + let pre_header = PreHeader { + version: last_header.version, + parent_id: last_header.id, + // The JVM's literal `lastHeader.timestamp + 1`, not wall clock: the + // mempool cannot know when the next block is mined. Saturating because + // a partial function has no business on a mempool-facing path — the + // input is chain data, but nothing here needs to be able to panic. + timestamp: last_header.timestamp.saturating_add(1), + n_bits: last_header.n_bits, + height: last_header.height.saturating_add(1), + // The next block's miner is unknown, so the group generator stands in. + // A script reading `CONTEXT.preHeader.minerPk` therefore sees a + // placeholder in the mempool and the real key once mined — the same + // divergence the JVM has, and the reason a transaction can pass here + // and still fail in a block. + miner_pk: Box::new(ec_point::generator()), + votes: Votes([0, 0, 0]), + }; + + let mut window = Vec::with_capacity(1 + preceding_headers.len().min(9)); + window.push(last_header.clone()); + window.extend(preceding_headers.iter().take(9).cloned()); + let headers = + Headers::from_vec(window).expect("window always holds last_header, so it is never empty"); + + ErgoStateContext::new(pre_header, headers, parameters.clone()) +} + /// Validate a single transaction against provided input and data-input boxes. /// /// Runs full ErgoScript evaluation via ergo-lib's TransactionContext. @@ -68,19 +165,21 @@ pub fn validate_single_transaction( data_boxes: Vec, state_context: &ErgoStateContext, ) -> Result { - let tx_context = TransactionContext::new(tx.clone(), input_boxes, data_boxes) - .map_err(|e| ValidationError::TransactionInvalid { - index: 0, - reason: format!("context: {e}"), - })?; - - let cost = tx_context.validate(state_context).map_err(|e| { + let tx_context = TransactionContext::new(tx.clone(), input_boxes, data_boxes).map_err(|e| { ValidationError::TransactionInvalid { index: 0, - reason: format!("{e}"), + reason: format!("context: {e}"), } })?; + let cost = + tx_context + .validate(state_context) + .map_err(|e| ValidationError::TransactionInvalid { + index: 0, + reason: format!("{e}"), + })?; + Ok(cost) } @@ -110,7 +209,10 @@ pub fn validate_transactions( // Can't build ErgoStateContext without preceding headers. // Only happens at height 1 (genesis) which has no standard transactions. // The SDK's `Headers` type (BoundedVec<_, 1, 10>) now also enforces ≥ 1. - tracing::warn!(height = header.height, "skipping tx validation: no preceding headers"); + tracing::warn!( + height = header.height, + "skipping tx validation: no preceding headers" + ); return Ok(0); } @@ -129,19 +231,19 @@ pub fn validate_transactions( .par_iter() .enumerate() .map(|(tx_idx, tx)| { - let input_boxes: Vec = tx - .inputs - .iter() - .map(|input| { - let id = box_id_bytes(&input.box_id); - box_map.get(&id).cloned().ok_or_else(|| { - ValidationError::TransactionInvalid { - index: tx_idx, - reason: format!("input box {} not found", hex::encode(id)), - } + let input_boxes: Vec = + tx.inputs + .iter() + .map(|input| { + let id = box_id_bytes(&input.box_id); + box_map.get(&id).cloned().ok_or_else(|| { + ValidationError::TransactionInvalid { + index: tx_idx, + reason: format!("input box {} not found", hex::encode(id)), + } + }) }) - }) - .collect::, _>>()?; + .collect::, _>>()?; let data_boxes: Vec = tx .data_inputs @@ -162,13 +264,17 @@ pub fn validate_transactions( .transpose()? .unwrap_or_default(); - validate_single_transaction(tx, input_boxes, data_boxes, &state_context) - .map_err(|e| match e { + validate_single_transaction(tx, input_boxes, data_boxes, &state_context).map_err(|e| { + match e { ValidationError::TransactionInvalid { reason, .. } => { - ValidationError::TransactionInvalid { index: tx_idx, reason } + ValidationError::TransactionInvalid { + index: tx_idx, + reason, + } } other => other, - }) + } + }) }) .collect::, _>>()?; @@ -191,10 +297,16 @@ fn enforce_block_cost(costs: &[u64], max_cost: u64) -> Result max_cost { - return Err(ValidationError::BlockCostExceeded { cost: total, max_cost }); + return Err(ValidationError::BlockCostExceeded { + cost: total, + max_cost, + }); } Ok(total) } @@ -205,7 +317,7 @@ fn enforce_block_cost(costs: &[u64], max_cost: u64) -> Result Result { +pub fn evaluate_scripts(eval: &crate::ScriptEvalInputs) -> Result { validate_transactions( &eval.transactions, &eval.proof_boxes, @@ -226,6 +338,7 @@ fn box_id_bytes(box_id: &ergo_lib::ergotree_ir::chain::ergo_box::BoxId) -> [u8; mod block_342964_tests { use super::*; + use ergo_chain_types::{BlockId, EcPoint, Votes}; use ergo_lib::chain::transaction::input::prover_result::ProverResult as ChainProverResult; use ergo_lib::chain::transaction::input::Input; use ergo_lib::chain::transaction::Transaction; @@ -235,7 +348,6 @@ mod block_342964_tests { use ergo_lib::ergotree_ir::chain::tx_id::TxId; use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - use ergo_chain_types::{BlockId, EcPoint, Votes}; use ergotree_interpreter::sigma_protocol::prover::ProofBytes; use ergotree_ir::chain::context_extension::ContextExtension; @@ -286,12 +398,11 @@ mod block_342964_tests { fn make_pre_header() -> PreHeader { let miner_pk = EcPoint::from_base16_str(MINER_PK_HEX.to_string()).unwrap(); - let parent_id_bytes: [u8; 32] = hex::decode( - "be5d64122592b6d2a07a3a619d4e68598e8df38e57ccbff732fc797bbdcf86ef", - ) - .unwrap() - .try_into() - .unwrap(); + let parent_id_bytes: [u8; 32] = + hex::decode("be5d64122592b6d2a07a3a619d4e68598e8df38e57ccbff732fc797bbdcf86ef") + .unwrap() + .try_into() + .unwrap(); PreHeader { version: 1, @@ -382,8 +493,7 @@ mod block_342964_tests { let pre_header = make_pre_header(); let dummy_header = make_dummy_header(); let headers = Headers::from_vec(vec![dummy_header.clone(); 10]).unwrap(); - let state_context = - ErgoStateContext::new(pre_header, headers, Parameters::default()); + let state_context = ErgoStateContext::new(pre_header, headers, Parameters::default()); // Validate — this is where we expect the "ReducedToFalse" failure let input_boxes = vec![input0, input1, input2]; @@ -434,8 +544,7 @@ mod block_342964_tests { let pre_header = make_pre_header(); let dummy_header = make_dummy_header(); let headers = Headers::from_vec(vec![dummy_header.clone(); 10]).unwrap(); - let state_context = - ErgoStateContext::new(pre_header, headers, Parameters::default()); + let state_context = ErgoStateContext::new(pre_header, headers, Parameters::default()); for (i, (value, creation_height, src_tx, src_idx)) in boxes.iter().enumerate() { let input_box = make_fee_box(*value, *creation_height, src_tx, *src_idx); @@ -449,11 +558,9 @@ mod block_342964_tests { }; let inputs = vec![make_empty_input(input_box.box_id())]; - let tx = - Transaction::new_from_vec(inputs, vec![], vec![output_candidate]).unwrap(); + let tx = Transaction::new_from_vec(inputs, vec![], vec![output_candidate]).unwrap(); - let result = - validate_single_transaction(&tx, vec![input_box], vec![], &state_context); + let result = validate_single_transaction(&tx, vec![input_box], vec![], &state_context); match &result { Ok(cost) => println!(" input[{i}]: PASS (cost={cost})"), @@ -544,7 +651,9 @@ mod block_342964_tests { None, ); // input[2]: P2S script, 0.001 ERG, with token and registers R4-R7 - let r4_bytes = hex::decode("73b6c8cfa0ef80096cb7127fa0f943acb22053e87f77e824b4d2749ffe0336d2").unwrap(); + let r4_bytes = + hex::decode("73b6c8cfa0ef80096cb7127fa0f943acb22053e87f77e824b4d2749ffe0336d2") + .unwrap(); let r6_pk = EcPoint::from_base16_str( "0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a".to_string(), ) @@ -552,7 +661,10 @@ mod block_342964_tests { let p2s_regs = NonMandatoryRegisters::new(vec![ (NonMandatoryRegisterId::R4, Constant::from(r4_bytes)), - (NonMandatoryRegisterId::R5, Constant::from(100_000_000_000i64)), + ( + NonMandatoryRegisterId::R5, + Constant::from(100_000_000_000i64), + ), (NonMandatoryRegisterId::R6, Constant::from(r6_pk)), (NonMandatoryRegisterId::R7, Constant::from(350_000i32)), ]) @@ -572,8 +684,7 @@ mod block_342964_tests { token_id, amount: token_amount, }; - let tokens = - Some(ergotree_ir::chain::ergo_box::BoxTokens::from_vec(vec![token]).unwrap()); + let tokens = Some(ergotree_ir::chain::ergo_box::BoxTokens::from_vec(vec![token]).unwrap()); let input2 = make_box_with_regs( 1_000_000, @@ -587,11 +698,9 @@ mod block_342964_tests { // --- Output candidates for tx[1] --- // output[0]: 100 ERG, P2PK(0355e3...), R4=SInt(-1) - let out0_regs = NonMandatoryRegisters::new(vec![( - NonMandatoryRegisterId::R4, - Constant::from(-1i32), - )]) - .unwrap(); + let out0_regs = + NonMandatoryRegisters::new(vec![(NonMandatoryRegisterId::R4, Constant::from(-1i32))]) + .unwrap(); let out0 = ErgoBoxCandidate { value: BoxValue::try_from(100_000_000_000u64).unwrap(), ergo_tree: ErgoTree::sigma_parse_bytes( @@ -641,8 +750,7 @@ mod block_342964_tests { let pre_header = make_pre_header(); let dummy_header = make_dummy_header(); let headers = Headers::from_vec(vec![dummy_header.clone(); 10]).unwrap(); - let state_context = - ErgoStateContext::new(pre_header, headers, Parameters::default()); + let state_context = ErgoStateContext::new(pre_header, headers, Parameters::default()); // Build TransactionContext + evaluation context for input[2] let tx_context = @@ -654,24 +762,22 @@ mod block_342964_tests { let result = reduce_to_crypto(&p2s_tree, &ctx); match &result { - Ok(rr) => { - match &rr.sigma_prop { - SigmaBoolean::TrivialProp(true) => { - println!("PASS — script reduces to TrivialProp(true) — no proof needed"); - println!(" This matches JVM behavior."); - } - SigmaBoolean::TrivialProp(false) => { - println!("BUG — script reduces to TrivialProp(false)"); - println!(" JVM evaluates this as true. sigma-rust divergence!"); - } - other => { - println!("SIGMA — script reduces to: {:?}", other); - println!(" If this is ProveDlog, it means the boolean condition is false"); - println!(" and the fallback path (needing a proof) is taken."); - println!(" JVM accepts this without a proof, so this is a divergence."); - } + Ok(rr) => match &rr.sigma_prop { + SigmaBoolean::TrivialProp(true) => { + println!("PASS — script reduces to TrivialProp(true) — no proof needed"); + println!(" This matches JVM behavior."); } - } + SigmaBoolean::TrivialProp(false) => { + println!("BUG — script reduces to TrivialProp(false)"); + println!(" JVM evaluates this as true. sigma-rust divergence!"); + } + other => { + println!("SIGMA — script reduces to: {:?}", other); + println!(" If this is ProveDlog, it means the boolean condition is false"); + println!(" and the fallback path (needing a proof) is taken."); + println!(" JVM accepts this without a proof, so this is a divergence."); + } + }, Err(e) => { println!("ERROR — script evaluation failed: {e}"); println!(" This is an evaluation error, not a 'reduced to false'."); @@ -700,66 +806,102 @@ mod block_342964_tests { // Build same context as p2s_script_selfboxindex_342964 let input0 = make_box_with_regs( - 10_000_000_000, 342_704, + 10_000_000_000, + 342_704, "0008cd02d84a11191f434daa5bed70e0e4db4e1563910622ee269f3dc219e0e854e108a5", "08c97e58d0ffc6356b31230b2c69ceb2e1a6883dcdde22cd1d41d5102e9e2503", - 0, NonMandatoryRegisters::empty(), None, + 0, + NonMandatoryRegisters::empty(), + None, ); let input1 = make_box_with_regs( - 100_000_000_000, 342_717, + 100_000_000_000, + 342_717, "0008cd02d84a11191f434daa5bed70e0e4db4e1563910622ee269f3dc219e0e854e108a5", "034c3ac56efe249edcf88dbbf974531596848a1c48aa39e17948689d5e78c877", - 0, NonMandatoryRegisters::empty(), None, + 0, + NonMandatoryRegisters::empty(), + None, ); - let r4_bytes = hex::decode("73b6c8cfa0ef80096cb7127fa0f943acb22053e87f77e824b4d2749ffe0336d2").unwrap(); + let r4_bytes = + hex::decode("73b6c8cfa0ef80096cb7127fa0f943acb22053e87f77e824b4d2749ffe0336d2") + .unwrap(); let r6_pk = EcPoint::from_base16_str( "0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a".to_string(), - ).unwrap(); + ) + .unwrap(); let p2s_regs = NonMandatoryRegisters::new(vec![ (NonMandatoryRegisterId::R4, Constant::from(r4_bytes)), - (NonMandatoryRegisterId::R5, Constant::from(100_000_000_000i64)), + ( + NonMandatoryRegisterId::R5, + Constant::from(100_000_000_000i64), + ), (NonMandatoryRegisterId::R6, Constant::from(r6_pk)), (NonMandatoryRegisterId::R7, Constant::from(350_000i32)), - ]).unwrap(); + ]) + .unwrap(); - let token_id_bytes: [u8; 32] = hex::decode("f9230aa721f97a319d91c6b701742403fcb4a8e069c9172d9e3370f3fcd01f47").unwrap().try_into().unwrap(); - let token_id = ergo_lib::ergotree_ir::chain::token::TokenId::from(ergo_chain_types::Digest32::from(token_id_bytes)); - let token_amount = ergo_lib::ergotree_ir::chain::token::TokenAmount::try_from(4_294_967_296u64).unwrap(); - let token = ergo_lib::ergotree_ir::chain::token::Token { token_id, amount: token_amount }; + let token_id_bytes: [u8; 32] = + hex::decode("f9230aa721f97a319d91c6b701742403fcb4a8e069c9172d9e3370f3fcd01f47") + .unwrap() + .try_into() + .unwrap(); + let token_id = ergo_lib::ergotree_ir::chain::token::TokenId::from( + ergo_chain_types::Digest32::from(token_id_bytes), + ); + let token_amount = + ergo_lib::ergotree_ir::chain::token::TokenAmount::try_from(4_294_967_296u64).unwrap(); + let token = ergo_lib::ergotree_ir::chain::token::Token { + token_id, + amount: token_amount, + }; let tokens = Some(ergotree_ir::chain::ergo_box::BoxTokens::from_vec(vec![token]).unwrap()); let input2 = make_box_with_regs( - 1_000_000, 342_935, P2S_TREE_HEX, + 1_000_000, + 342_935, + P2S_TREE_HEX, "e35caa4c6e257053381b1cd7b453bac84c7064a6b955c07ff02a57ea2c61a703", - 0, p2s_regs, tokens.clone(), + 0, + p2s_regs, + tokens.clone(), ); // Output[0] with R4=SInt(-1) - let out0_regs = NonMandatoryRegisters::new(vec![ - (NonMandatoryRegisterId::R4, Constant::from(-1i32)), - ]).unwrap(); + let out0_regs = + NonMandatoryRegisters::new(vec![(NonMandatoryRegisterId::R4, Constant::from(-1i32))]) + .unwrap(); let out0 = ErgoBoxCandidate { value: BoxValue::try_from(100_000_000_000u64).unwrap(), - ergo_tree: ErgoTree::sigma_parse_bytes(&hex::decode( - "0008cd0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a" - ).unwrap()).unwrap(), + ergo_tree: ErgoTree::sigma_parse_bytes( + &hex::decode( + "0008cd0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a", + ) + .unwrap(), + ) + .unwrap(), tokens: None, additional_registers: out0_regs, creation_height: 342_935, }; let out1 = ErgoBoxCandidate { value: BoxValue::try_from(10_000_000_000u64).unwrap(), - ergo_tree: ErgoTree::sigma_parse_bytes(&hex::decode( - "0008cd02d84a11191f434daa5bed70e0e4db4e1563910622ee269f3dc219e0e854e108a5" - ).unwrap()).unwrap(), + ergo_tree: ErgoTree::sigma_parse_bytes( + &hex::decode( + "0008cd02d84a11191f434daa5bed70e0e4db4e1563910622ee269f3dc219e0e854e108a5", + ) + .unwrap(), + ) + .unwrap(), tokens, additional_registers: NonMandatoryRegisters::empty(), creation_height: 342_935, }; let out2 = ErgoBoxCandidate { value: BoxValue::try_from(1_000_000u64).unwrap(), - ergo_tree: ErgoTree::sigma_parse_bytes(&hex::decode(FEE_CONTRACT_HEX).unwrap()).unwrap(), + ergo_tree: ErgoTree::sigma_parse_bytes(&hex::decode(FEE_CONTRACT_HEX).unwrap()) + .unwrap(), tokens: None, additional_registers: NonMandatoryRegisters::empty(), creation_height: 342_935, @@ -777,7 +919,8 @@ mod block_342964_tests { let headers = Headers::from_vec(vec![dummy_header.clone(); 10]).unwrap(); let state_context = ErgoStateContext::new(pre_header, headers, Parameters::default()); - let tx_context = TransactionContext::new(tx.clone(), vec![input0, input1, input2], vec![]).unwrap(); + let tx_context = + TransactionContext::new(tx.clone(), vec![input0, input1, input2], vec![]).unwrap(); let ctx = make_context(&state_context, &tx_context, 2).unwrap(); // Check selfBoxIndex by finding self_box position in inputs @@ -786,7 +929,9 @@ mod block_342964_tests { // Check output[0].R4 let out0_box = &ctx.outputs[0]; - let r4_val = out0_box.additional_registers.get(NonMandatoryRegisterId::R4); + let r4_val = out0_box + .additional_registers + .get(NonMandatoryRegisterId::R4); println!("output[0].R4: {:?}", r4_val); // Check: do propositionBytes match? @@ -794,41 +939,59 @@ mod block_342964_tests { let pk_hex = "0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a"; let expected_p2pk = format!("0008cd{}", pk_hex); let expected_bytes = hex::decode(&expected_p2pk).unwrap(); - println!("output[0] ergoTree bytes: {}", hex::encode(&out0_script_bytes)); + println!( + "output[0] ergoTree bytes: {}", + hex::encode(&out0_script_bytes) + ); println!("expected proveDlog bytes: {}", expected_p2pk); - println!("propositionBytes match: {}", out0_script_bytes == expected_bytes); + println!( + "propositionBytes match: {}", + out0_script_bytes == expected_bytes + ); // Now try evaluating simpler scripts to isolate the failing condition // Test: HEIGHT < SELF.R7 println!("\nHEIGHT = {}", ctx.height); println!("SELF.R7 should be 350000"); - println!("HEIGHT < R7 = {} < 350000 = {}", ctx.height, ctx.height < 350_000); + println!( + "HEIGHT < R7 = {} < 350000 = {}", + ctx.height, + ctx.height < 350_000 + ); // The real question: what does CONTEXT.selfBoxIndex evaluate to? // And does output[0].R4[Int] == selfBoxIndex? - println!("\nKey comparison: output[0].R4 = {:?} vs selfBoxIndex = {:?}", - r4_val, self_box_idx); + println!( + "\nKey comparison: output[0].R4 = {:?} vs selfBoxIndex = {:?}", + r4_val, self_box_idx + ); // Verify R4 by constructing output[0] with the REAL tx_id and checking box_id - let real_tx_id_bytes: [u8; 32] = hex::decode( - "9302a2983d9cc3f2b9e271097aa3128581c6cad8b59f7b6bc3e08fa6cb63ad3f" - ).unwrap().try_into().unwrap(); + let real_tx_id_bytes: [u8; 32] = + hex::decode("9302a2983d9cc3f2b9e271097aa3128581c6cad8b59f7b6bc3e08fa6cb63ad3f") + .unwrap() + .try_into() + .unwrap(); let real_tx_id = TxId::from(ergo_chain_types::Digest32::from(real_tx_id_bytes)); // Construct output[0] as ErgoBox with R4=SInt(-1) let out0_with_r4 = ErgoBox::new( BoxValue::try_from(100_000_000_000u64).unwrap(), - ErgoTree::sigma_parse_bytes(&hex::decode( - "0008cd0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a" - ).unwrap()).unwrap(), + ErgoTree::sigma_parse_bytes( + &hex::decode( + "0008cd0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a", + ) + .unwrap(), + ) + .unwrap(), None, - NonMandatoryRegisters::new(vec![ - (NonMandatoryRegisterId::R4, Constant::from(-1i32)), - ]).unwrap(), + NonMandatoryRegisters::new(vec![(NonMandatoryRegisterId::R4, Constant::from(-1i32))]) + .unwrap(), 342_935, real_tx_id, 0, - ).unwrap(); + ) + .unwrap(); let computed_box_id = hex::encode(out0_with_r4.box_id().as_ref()); let expected_box_id = "a384930961967f4112d71445feebe000706a064a5c467b483f3d5b982b52a502"; @@ -841,34 +1004,54 @@ mod block_342964_tests { // Try R4=SInt(0) let out0_r4_zero = ErgoBox::new( BoxValue::try_from(100_000_000_000u64).unwrap(), - ErgoTree::sigma_parse_bytes(&hex::decode( - "0008cd0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a" - ).unwrap()).unwrap(), + ErgoTree::sigma_parse_bytes( + &hex::decode( + "0008cd0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a", + ) + .unwrap(), + ) + .unwrap(), None, - NonMandatoryRegisters::new(vec![ - (NonMandatoryRegisterId::R4, Constant::from(0i32)), - ]).unwrap(), + NonMandatoryRegisters::new(vec![( + NonMandatoryRegisterId::R4, + Constant::from(0i32), + )]) + .unwrap(), 342_935, real_tx_id, 0, - ).unwrap(); - println!(" computed (R4=0): {}", hex::encode(out0_r4_zero.box_id().as_ref())); + ) + .unwrap(); + println!( + " computed (R4=0): {}", + hex::encode(out0_r4_zero.box_id().as_ref()) + ); // Try R4=SInt(2) — would match selfBoxIndex let out0_r4_two = ErgoBox::new( BoxValue::try_from(100_000_000_000u64).unwrap(), - ErgoTree::sigma_parse_bytes(&hex::decode( - "0008cd0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a" - ).unwrap()).unwrap(), + ErgoTree::sigma_parse_bytes( + &hex::decode( + "0008cd0355e3409b35892e2b916a6362a93f742d06ce1726e2eaa688738b34b652d1142a", + ) + .unwrap(), + ) + .unwrap(), None, - NonMandatoryRegisters::new(vec![ - (NonMandatoryRegisterId::R4, Constant::from(2i32)), - ]).unwrap(), + NonMandatoryRegisters::new(vec![( + NonMandatoryRegisterId::R4, + Constant::from(2i32), + )]) + .unwrap(), 342_935, real_tx_id, 0, - ).unwrap(); - println!(" computed (R4=2): {}", hex::encode(out0_r4_two.box_id().as_ref())); + ) + .unwrap(); + println!( + " computed (R4=2): {}", + hex::encode(out0_r4_two.box_id().as_ref()) + ); } } } @@ -877,9 +1060,7 @@ mod block_342964_tests { mod state_context_window_tests { use super::*; - use ergo_chain_types::{ - ADDigest, AutolykosSolution, BlockId, Digest32, EcPoint, Votes, - }; + use ergo_chain_types::{ADDigest, AutolykosSolution, BlockId, Digest32, EcPoint, Votes}; fn header_at(height: u32) -> Header { Header { @@ -934,6 +1115,211 @@ mod state_context_window_tests { } } +#[cfg(test)] +mod upcoming_state_context_tests { + use super::*; + + use ergo_chain_types::{ADDigest, AutolykosSolution, BlockId, Digest32, EcPoint}; + use ergo_lib::chain::transaction::input::prover_result::ProverResult; + use ergo_lib::chain::transaction::input::Input; + use ergo_lib::ergotree_ir::chain::ergo_box::{ErgoBoxCandidate, NonMandatoryRegisters}; + use ergotree_interpreter::sigma_protocol::prover::ProofBytes; + use ergotree_ir::chain::context_extension::ContextExtension; + + use crate::test_support::{make_box, sigma_bool_tree, SEED_HEIGHT}; + + fn tagged(height: u32) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[..4].copy_from_slice(&height.to_le_bytes()); + bytes + } + + fn tagged_root(height: u32) -> [u8; 33] { + let mut bytes = [0u8; 33]; + bytes[..4].copy_from_slice(&height.to_le_bytes()); + bytes + } + + /// `id`, `parent_id` and `state_root` are all distinct per height — those + /// are exactly the fields a wrong window silently swaps. + fn header_at(height: u32) -> Header { + Header { + version: 3, + id: BlockId(Digest32::from(tagged(height))), + parent_id: BlockId(Digest32::from(tagged(height.wrapping_sub(1)))), + ad_proofs_root: Digest32::from([0u8; 32]), + state_root: ADDigest::from(tagged_root(height)), + transaction_root: Digest32::from([0u8; 32]), + timestamp: 1_600_000_000_000 + height as u64, + n_bits: 118_099_735, + height, + extension_root: Digest32::from([0u8; 32]), + autolykos_solution: AutolykosSolution { + miner_pk: Box::new(EcPoint::default()), + pow_onetime_pk: None, + nonce: vec![0u8; 8], + pow_distance: None, + }, + votes: Votes([0, 0, 0]), + unparsed_bytes: Box::new([]), + } + } + + /// What a wallet builds: spend one box, create one output declaring the + /// block it expects to land in. + fn tx_creating_at(creation_height: u32, source: &ErgoBox) -> Transaction { + let output = ErgoBoxCandidate { + value: source.value, + ergo_tree: sigma_bool_tree(true), + tokens: None, + additional_registers: NonMandatoryRegisters::empty(), + creation_height, + }; + let input = Input::new( + source.box_id(), + ProverResult { + proof: ProofBytes::Empty, + extension: ContextExtension::empty(), + }, + ); + Transaction::new_from_vec(vec![input], vec![], vec![output]).expect("transaction") + } + + /// The preheader is the *next* block: height `tip + 1`, parented on the tip + /// itself rather than on the tip's parent. + #[test] + fn preheader_describes_the_next_block() { + let last = header_at(1_850_410); + let ctx = + build_upcoming_state_context(&last, &[header_at(1_850_409)], &Parameters::default()); + + assert_eq!(ctx.pre_header.height, last.height + 1); + assert_eq!( + ctx.pre_header.parent_id, last.id, + "the next block is parented on the tip" + ); + assert_ne!( + ctx.pre_header.parent_id, last.parent_id, + "PreHeader::from(Header) would have copied the tip's own parent" + ); + assert_eq!(ctx.pre_header.version, last.version); + assert_eq!(ctx.pre_header.n_bits, last.n_bits); + assert_eq!( + ctx.pre_header.timestamp, + last.timestamp + 1, + "the JVM's literal +1, not wall clock" + ); + assert_eq!( + *ctx.pre_header.miner_pk, + ec_point::generator(), + "no miner is known for a block nobody has mined" + ); + assert_eq!(ctx.pre_header.votes, Votes([0, 0, 0])); + } + + /// The live failure, in one test: at tip H a node rejected **every** + /// transaction the network offered it with `Creation height H+1 > + /// preheader height`, because the mempool validated against a preheader + /// sitting at H. Wallets target H+1; the upcoming context is the one that + /// admits them. + #[test] + fn tip_plus_one_creation_height_passes_upcoming_and_fails_at_the_tip() { + let last = header_at(SEED_HEIGHT); + let preceding = [header_at(SEED_HEIGHT - 1)]; + let source = make_box(true, 0x11); + let tx = tx_creating_at(SEED_HEIGHT + 1, &source); + + let upcoming = build_upcoming_state_context(&last, &preceding, &Parameters::default()); + validate_single_transaction(&tx, vec![source.clone()], vec![], &upcoming) + .expect("a transaction targeting the next block must validate against it"); + + let at_tip = build_state_context(&last, &preceding, &Parameters::default()); + let err = validate_single_transaction(&tx, vec![source], vec![], &at_tip) + .expect_err("the same transaction is one block early for a preheader at the tip"); + let reason = format!("{err}"); + assert!( + reason.contains(&format!( + "Creation height {} > preheader height", + SEED_HEIGHT + 1 + )), + "expected the observed mempool rejection, got: {reason}" + ); + } + + /// The tip occupies a slot in the window, so ten ancestors become nine. + /// Total stays at the JVM's `LastHeadersInContext`. + #[test] + fn window_is_the_tip_plus_nine_ancestors() { + let last = header_at(20); + let preceding: Vec
= (10..20).rev().map(header_at).collect(); + let ctx = build_upcoming_state_context(&last, &preceding, &Parameters::default()); + + assert_eq!(ctx.headers.len(), 10); + assert_eq!( + ctx.headers.first().height, + 20, + "the tip is the newest preceding header of the upcoming block" + ); + assert_eq!( + ctx.headers.last().height, + 11, + "the tenth ancestor (height 10) drops out to make room for the tip" + ); + } + + /// Near genesis the window is short rather than padded, same as the block + /// builder — and unlike it, an empty ancestor slice is legal, because the + /// tip alone already satisfies `Headers`' lower bound. + #[test] + fn short_window_is_not_padded_and_no_ancestors_is_legal() { + let ctx = build_upcoming_state_context( + &header_at(3), + &[header_at(2), header_at(1)], + &Parameters::default(), + ); + assert_eq!(ctx.headers.len(), 3); + assert_eq!(ctx.headers.first().height, 3); + assert_eq!(ctx.headers.last().height, 1); + + let genesis = build_upcoming_state_context(&header_at(1), &[], &Parameters::default()); + assert_eq!(genesis.headers.len(), 1); + assert_eq!(genesis.headers.first().height, 1); + assert_eq!(genesis.pre_header.height, 2); + } + + /// `lastBlockUtxoRoot` is derived from the newest header in the window + /// (ergo-lib `signing.rs` `make_context`), so `headers.first()` is the + /// observable proxy for it. A transaction in the mempool at tip H spends + /// the UTXO set as of H — the same set the block at H+1 will be validated + /// against — so the two windows must be the *same window*, not merely the + /// same length. + #[test] + fn utxo_root_matches_the_block_context_at_the_same_chain_position() { + let last = header_at(20); + let ancestors: Vec
= (11..20).rev().map(header_at).collect(); + + let upcoming = build_upcoming_state_context(&last, &ancestors, &Parameters::default()); + + // The block at 21, once mined, validates against the tip and its nine + // ancestors — which is exactly what the mempool saw a moment earlier. + let mut preceding_for_next = vec![last.clone()]; + preceding_for_next.extend(ancestors.iter().cloned()); + let at_next = + build_state_context(&header_at(21), &preceding_for_next, &Parameters::default()); + + assert_eq!( + upcoming.headers.first().state_root, + last.state_root, + "the UTXO root is the state after the tip, not after its parent" + ); + assert_eq!( + upcoming.headers.as_vec(), + at_next.headers.as_vec(), + "same chain position, same window" + ); + } +} + #[cfg(test)] mod block_cost_tests { use super::*; @@ -998,12 +1384,11 @@ mod block_cost_tests { /// miner_pk/height the fee contract's SubstConstants check reads. fn block_header() -> Header { let miner_pk = EcPoint::from_base16_str(MINER_PK_HEX.to_string()).unwrap(); - let parent_id_bytes: [u8; 32] = hex::decode( - "be5d64122592b6d2a07a3a619d4e68598e8df38e57ccbff732fc797bbdcf86ef", - ) - .unwrap() - .try_into() - .unwrap(); + let parent_id_bytes: [u8; 32] = + hex::decode("be5d64122592b6d2a07a3a619d4e68598e8df38e57ccbff732fc797bbdcf86ef") + .unwrap() + .try_into() + .unwrap(); Header { version: 1, id: BlockId(Digest32::from([0u8; 32])), @@ -1075,11 +1460,12 @@ mod block_cost_tests { let params = Parameters::default(); let ctx = build_state_context(&header, &preceding, ¶ms); - let cost_a = - validate_single_transaction(&tx_a, vec![box_a.clone()], vec![], &ctx).unwrap(); - let cost_b = - validate_single_transaction(&tx_b, vec![box_b.clone()], vec![], &ctx).unwrap(); - assert!(cost_a > 0 && cost_b > 0, "fixture txs must have nonzero cost"); + let cost_a = validate_single_transaction(&tx_a, vec![box_a.clone()], vec![], &ctx).unwrap(); + let cost_b = validate_single_transaction(&tx_b, vec![box_b.clone()], vec![], &ctx).unwrap(); + assert!( + cost_a > 0 && cost_b > 0, + "fixture txs must have nonzero cost" + ); let total = validate_transactions( &[tx_a, tx_b], @@ -1109,10 +1495,10 @@ mod block_cost_tests { // units trips on the rounding residue): each tx fits, the pair doesn't. // Per-tx cost itself is independent of MaxBlockCost — it only sets limits. let baseline_ctx = build_state_context(&header, &preceding, &Parameters::default()); - let cost_a = validate_single_transaction(&tx_a, vec![box_a.clone()], vec![], &baseline_ctx) - .unwrap(); - let cost_b = validate_single_transaction(&tx_b, vec![box_b.clone()], vec![], &baseline_ctx) - .unwrap(); + let cost_a = + validate_single_transaction(&tx_a, vec![box_a.clone()], vec![], &baseline_ctx).unwrap(); + let cost_b = + validate_single_transaction(&tx_b, vec![box_b.clone()], vec![], &baseline_ctx).unwrap(); let max = cost_a.max(cost_b) + 1; let params = params_with_max_cost(i32::try_from(max).unwrap()); diff --git a/validation/src/utxo.rs b/validation/src/utxo.rs index a5987ec..4bff9cb 100644 --- a/validation/src/utxo.rs +++ b/validation/src/utxo.rs @@ -1,23 +1,70 @@ //! UtxoValidator: persistent AVL+ tree state verification via BatchAVLProver. +use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::rc::Rc; use bytes::Bytes; use enr_state::RedbAVLStorage; use ergo_avltree_rust::authenticated_tree_ops::AuthenticatedTreeOps; use ergo_avltree_rust::batch_avl_prover::BatchAVLProver; -use ergo_avltree_rust::batch_node::AVLTree; -use ergo_avltree_rust::operation::{KeyValue, Operation}; +use ergo_avltree_rust::batch_node::{AVLTree, Node, Resolver}; +use ergo_avltree_rust::operation::{Digest32, KeyValue, Operation}; use ergo_avltree_rust::versioned_avl_storage::VersionedAVLStorage; use ergo_chain_types::{blake2b256_hash, ADDigest, Header}; use ergo_lib::chain::parameters::Parameters; use crate::sections::{parse_block_transactions, parse_extension}; -use crate::state_changes::{compute_state_changes, transactions_to_summaries}; +use crate::state_changes::{compute_state_changes, transactions_to_summaries, Insertion}; use crate::tx_validation; use crate::voting; -use crate::{ApplyStateOutcome, BlockValidator, ValidationError}; +use crate::{ + ApplyStateOutcome, BlockValidator, MiningState, ProverMemoryEstimate, ScriptEvalInputs, + StatePersistence, ValidationError, +}; + +/// Where a freshly constructed [`UtxoValidator`] recovers `emission_box_id` +/// from. +/// +/// [`UtxoValidator::new`] takes one of these by value, so a caller cannot +/// build a validator without saying where the emission box comes from. That +/// is the whole design: `emission_box_id` is derived state maintained by +/// `apply_state`, so a validator that has applied nothing reports `None` — +/// the value that means **all ERG has been emitted**. `main`'s +/// `update_mining_proofs` returns early on it, so a node restarted at the +/// chain tip served no mining candidate at all until a peer delivered the +/// next block; for a solo miner that is a deadlock, not a delay +/// (facts/validation.md, "Recovering `emission_box_id` on resume"). +/// +/// The variants are deliberately not an `Option`. "No block exists yet" +/// (genesis) and "I could not read the block" are different facts with +/// different consequences, and collapsing them into one `None` is the exact +/// overload this type exists to remove. +pub enum EmissionSource<'a> { + /// Raw BlockTransactions (type-102) section bytes of the block at the + /// validator's resume height. + /// + /// The coinbase spends and recreates the emission box in every block that + /// has one, so this block's insertions contain the current emission box. + /// One block to parse — **not** a UTXO-set scan, which would be O(millions + /// of boxes) on the startup path. + TipBlock(&'a [u8]), + + /// The boxes a genesis bootstrap just inserted, for a validator starting + /// at height 0 where no block exists yet. One of them carries the emission + /// contract. + GenesisBoxes(&'a [Insertion]), + + /// Neither is obtainable — e.g. UTXO snapshot bootstrap, which loads a + /// state tree at a height whose block transactions were never downloaded. + /// + /// `emission_box_id` stays `None` and `reason` is logged at WARN. Stating + /// a reason is the price of this variant: an unexplained `None` here is + /// indistinguishable from the legitimate one, which is how the defect this + /// type addresses stayed invisible. + Unavailable(&'a str), +} /// UTXO-mode block validator. /// @@ -57,11 +104,26 @@ impl UtxoValidator { /// match `storage.version()`: either by calling `storage.rollback(&version)` /// and installing the returned root, or by performing the genesis-bootstrap /// insertions plus a first `storage.update_with_height(&mut prover, vec![], 0)`. + /// + /// `checkpoint_height` is the only thing that changes whether a block's + /// scripts are evaluated: at or below it they are skipped, above it they + /// always run. There is no mode to configure and no way for a caller to + /// end up holding an evaluation this validator did not perform. + /// + /// `emission_source` is required, not optional, because forgetting it is + /// the defect: `emission_box_id` used to start at `None` and only ever be + /// written from `apply_state`, so a validator constructed at the tip + /// reported "all ERG emitted" until a block arrived. See + /// [`EmissionSource`]. The returned validator's `emission_box_id()` is + /// valid immediately — there is no second call to remember, and no + /// defaulted method a wrapper can silently swallow the way the enum + /// wrapper in `src/main.rs` swallowed `resize_cache`. pub fn new( storage: RedbAVLStorage, prover: BatchAVLProver, height: u32, checkpoint_height: u32, + emission_source: EmissionSource<'_>, ) -> Self { let digest_bytes = prover.digest().expect("prover has no root"); let digest = bytes_to_ad_digest(&digest_bytes); @@ -69,8 +131,8 @@ impl UtxoValidator { // Compute emission contract ErgoTree bytes for box matching. // Uses mainnet MonetarySettings — the emission contract is the same // across mainnet/testnet (same script, different genesis boxes). - use ergo_lib::chain::ergo_tree_predef; use ergo_lib::chain::emission::MonetarySettings; + use ergo_lib::chain::ergo_tree_predef; use ergo_lib::ergotree_ir::serialization::SigmaSerializable; let emission_tree_bytes = @@ -80,13 +142,16 @@ impl UtxoValidator { Vec::new() }; + let emission_box_id = + recover_emission_box_id(&emission_source, &emission_tree_bytes, height); + Self { storage, prover, validated_height: height, checkpoint_height, current_digest: digest, - emission_box_id: None, + emission_box_id, emission_tree_bytes, adproof_dump_heights: HashSet::new(), adproof_dump_dir: None, @@ -104,6 +169,135 @@ impl UtxoValidator { } } +/// Which of `insertions` carries the emission contract, if any. +/// +/// **The single implementation of "which box is the emission box."** Step 9 of +/// `apply_state_internal` and construction-time recovery both call it, and +/// neither may grow its own copy: two implementations of this match is +/// precisely the divergence facts/validation.md exists to prevent, and if they +/// ever disagreed the node would mine against the wrong box. The equivalence +/// is asserted in `recovery_at_the_tip_matches_applying_the_same_block`, not +/// merely asserted here in prose. +/// +/// `None` means no insertion carries the contract. Read against a block that +/// was parsed successfully, that means emission has ended — see +/// [`recover_emission_box_id`] for why the caller must not treat every `None` +/// alike. +fn find_emission_box(insertions: &[Insertion], emission_tree_bytes: &[u8]) -> Option<[u8; 32]> { + use ergo_lib::ergotree_ir::serialization::SigmaSerializable; + + insertions.iter().find_map(|(box_id, box_bytes)| { + let ergo_box = tx_validation::deserialize_box(box_bytes).ok()?; + let tree_bytes = ergo_box.ergo_tree.sigma_serialize_bytes().ok()?; + (tree_bytes == emission_tree_bytes).then_some(*box_id) + }) +} + +/// The insertions a block's transactions produce, by the same route +/// `apply_state_internal` takes to them. +/// +/// Routed through `compute_state_changes` rather than reading `tx.outputs` +/// directly, so intra-block netting and ordering are identical to the apply +/// path by construction. A second derivation here could disagree with the +/// first and nothing would notice. +fn insertions_of_block(section_bytes: &[u8]) -> Result, ValidationError> { + let parsed = parse_block_transactions(section_bytes)?; + let summaries = transactions_to_summaries(&parsed.transactions)?; + Ok(compute_state_changes(summaries)?.insertions) +} + +/// Resolve `emission_box_id` for a validator that has applied no block yet. +/// +/// Both outcomes are `None`-shaped and only one of them is benign, so they are +/// logged differently: +/// +/// - **Read the source, found no emission output** → emission has ended. The +/// documented meaning of `None`; logged at INFO. +/// - **Could not read the source** → unknown. Logged at WARN, because a silent +/// `None` here is byte-identical to the legitimate answer above, and that +/// indistinguishability is how the original defect survived: during sync a +/// block lands within seconds and the field fills itself, so only an at-tip +/// restart ever exposed it. +/// +/// It does not fail construction. An unreadable tip block costs mining until +/// the next applied block; it is not a reason to refuse to start a node that +/// can still validate, serve, and sync. +fn recover_emission_box_id( + source: &EmissionSource<'_>, + emission_tree_bytes: &[u8], + height: u32, +) -> Option<[u8; 32]> { + // No contract bytes means the match can never succeed, so every answer + // below would be a `None` that says "emission ended" about a scan that + // never ran. Same trap, one level earlier. + if emission_tree_bytes.is_empty() { + tracing::warn!( + height, + "emission contract ErgoTree could not be built — emission_box_id \ + cannot be tracked at all and mining will produce no candidates" + ); + return None; + } + + let found = match source { + EmissionSource::TipBlock(section_bytes) => match insertions_of_block(section_bytes) { + Ok(insertions) => { + let found = find_emission_box(&insertions, emission_tree_bytes); + if found.is_none() { + tracing::info!( + height, + "block at resume height created no emission output — \ + all ERG has been emitted" + ); + } + found + } + Err(e) => { + tracing::warn!( + height, + error = %e, + "emission_box_id recovery failed: the block at the resume \ + height could not be read; mining is unavailable until the \ + next block is applied" + ); + return None; + } + }, + EmissionSource::GenesisBoxes(boxes) => { + let found = find_emission_box(boxes, emission_tree_bytes); + if found.is_none() { + // Emission cannot have ended at genesis, so this is a broken + // bootstrap rather than the legitimate `None`. + tracing::warn!( + height, + box_count = boxes.len(), + "emission_box_id recovery failed: no genesis box carries the \ + emission contract" + ); + } + found + } + EmissionSource::Unavailable(reason) => { + tracing::warn!( + height, + reason, + "emission_box_id could not be recovered; mining is unavailable \ + until the next block is applied" + ); + return None; + } + }; + + if let Some(id) = found { + tracing::info!( + height, + emission_box_id = %hex::encode(id), + "recovered emission box at construction" + ); + } + found +} + impl BlockValidator for UtxoValidator { fn apply_state( &mut self, @@ -116,13 +310,28 @@ impl BlockValidator for UtxoValidator { expected_boundary_params: Option<&Parameters>, expected_proposed_update: Option<&[u8]>, ) -> Result { - // The op loop in apply_state_internal mutates the in-memory prover. - // An early-return after partial mutation leaves the prover dirty; - // sync's retry then re-enters with a half-applied tree and surfaces - // a different error on a different op number, burying the original - // failure cause. Roll the prover back to pre-block state on any - // failure so retries are deterministic and the original error - // survives. + // Every Err leaves the prover byte-for-byte as it was on entry + // (facts/validation.md, "Err leaves the prover clean"). This wrapper + // is the only thing that makes that true: apply_state_internal + // mutates the prover in step 4 and every Err return below it — the + // digest check, box deserialization, script evaluation, the persist, + // the proof-digest check — is a bare `return`/`?` that undoes nothing + // on its own. + // + // Two failure regions, one mechanism: + // - before `update_with_height`: storage's current_version is still + // `saved_digest`, so `rollback` short-circuits to a re-read of the + // persisted root (state/src/storage.rs, the current_version == + // version branch). No undo log involved — this block was never a + // version. + // - after it (the proof-digest check): the block *is* the newest + // version, and the same call walks the undo log back one step. + // + // Without it, sync's retry re-enters on a half-applied tree and fails + // on a different op with a different error, burying the real cause — + // and since script evaluation now runs here, a rejected block is + // routine rather than near-unreachable, so the tree would carry that + // block's mutations into the next candidate. let saved_digest = self.current_digest; match self.apply_state_internal( @@ -171,10 +380,18 @@ impl BlockValidator for UtxoValidator { Ok(()) } + /// UTXO mode owns an AVL+ tree over redb, so it hands out its own + /// storage lifecycle. + fn state_persistence(&self) -> Option<&dyn StatePersistence> { + Some(self) + } +} + +impl StatePersistence for UtxoValidator { fn flush(&self) -> Result<(), ValidationError> { - self.storage.flush().map_err(|e| { - ValidationError::StateOperationFailed(format!("flush: {e}")) - }) + self.storage + .flush() + .map_err(|e| ValidationError::StateOperationFailed(format!("flush: {e}"))) } fn resize_cache(&self, cache_bytes: usize) -> Result<(), ValidationError> { @@ -182,11 +399,19 @@ impl BlockValidator for UtxoValidator { Ok(()) } + /// Measured from the live prover — see [`ProverMemoryEstimate`] for what + /// each figure covers and which way each one is wrong. + fn prover_memory_estimate(&self) -> Option { + crate::prover_memory::estimate(&self.prover) + } +} + +impl MiningState for UtxoValidator { fn proofs_for_transactions( &self, txs: &[ergo_lib::chain::transaction::Transaction], - ) -> Option, ADDigest), ValidationError>> { - Some(self.compute_proofs(txs)) + ) -> Result<(Vec, ADDigest), ValidationError> { + self.compute_proofs(txs) } fn emission_box_id(&self) -> Option<[u8; 32]> { @@ -227,7 +452,8 @@ impl UtxoValidator { // Uses JVM v6 matchParameters60 semantics: local can have fewer // entries than received, every entry in local must match received. // At v4+ the proposedUpdate byte-for-byte comparison also runs. - let (epoch_boundary_params, epoch_boundary_proposed_update) = match expected_boundary_params { + let (epoch_boundary_params, epoch_boundary_proposed_update) = match expected_boundary_params + { Some(expected) => { let parsed = voting::parse_parameters_from_extension(&parsed_ext)?; let parsed_pu = voting::extract_proposed_update(&parsed_ext); @@ -279,12 +505,9 @@ impl UtxoValidator { let mut proof_box_bytes: HashMap<[u8; 32], Vec> = HashMap::new(); for (i, op) in operations.iter().enumerate() { - let result = self - .prover - .perform_one_operation(op) - .map_err(|e| ValidationError::StateOperationFailed( - format!("operation {i} failed: {e}"), - ))?; + let result = self.prover.perform_one_operation(op).map_err(|e| { + ValidationError::StateOperationFailed(format!("operation {i} failed: {e}")) + })?; if validate_txs { if let Some(value) = result { @@ -302,12 +525,9 @@ impl UtxoValidator { // 5. Verify resulting digest matches header.state_root let expected_state_root: [u8; 33] = header.state_root.into(); - let prover_digest = self - .prover - .digest() - .ok_or_else(|| ValidationError::StateOperationFailed( - "prover has no root after operations".to_string(), - ))?; + let prover_digest = self.prover.digest().ok_or_else(|| { + ValidationError::StateOperationFailed("prover has no root after operations".to_string()) + })?; if prover_digest.as_ref() != expected_state_root.as_slice() { return Err(ValidationError::StateRootMismatch { expected: expected_state_root.to_vec(), @@ -315,24 +535,42 @@ impl UtxoValidator { }); } - // 6. Build DeferredEval for deferred script verification - let deferred_eval = if validate_txs { + // 6. Script evaluation. Always here, never the caller's — the block's + // scripts are checked before anything about it is persisted, so an Ok + // from this function already means they passed. + // + // Evaluation sits between the digest check and persistence, not + // before the prover mutations. The JVM checks scripts before touching + // its AVL tree, but it reads each input box via boxById and pays a + // second traversal to remove it; step 4 above captures the box from + // the removal's own return value, so copying that ordering would buy + // a read per input and nothing else. Persistence is the boundary that + // matters, because persistence is what survives a crash: everything + // up to `update_with_height` below is in memory, and an Err from here + // rewinds the prover (see `apply_state`), so no block whose scripts + // are unverified ever reaches state.redb. + // + // The digest check stays ahead of it because it is cheap and rejects + // malformed blocks before the expensive step. + if validate_txs { let mut proof_boxes = HashMap::with_capacity(proof_box_bytes.len()); for (id, bytes) in &proof_box_bytes { proof_boxes.insert(*id, tx_validation::deserialize_box(bytes)?); } - Some(crate::DeferredEval { + let eval = ScriptEvalInputs { height: header.height, transactions: parsed_txs.transactions, proof_boxes, header: header.clone(), preceding_headers: preceding_headers.to_vec(), parameters: active_params.clone(), - }) - } else { - None - }; + }; + + // The cost is discarded, not unchecked: the maxBlockCost + // consensus gate runs inside evaluate_scripts. + tx_validation::evaluate_scripts(&eval)?; + } // 7. Persist state changes atomically with block_height. // Must precede generate_proof(): update_internal builds its delete @@ -344,9 +582,7 @@ impl UtxoValidator { // ~658 KB/block, zero deletions). self.storage .update_with_height(&mut self.prover, vec![], header.height) - .map_err(|e| ValidationError::StateOperationFailed( - format!("persist failed: {e}"), - ))?; + .map_err(|e| ValidationError::StateOperationFailed(format!("persist failed: {e}")))?; // 8. Generate the AD proof AFTER persisting — the canonical order, // matching the JVM's @@ -412,8 +648,7 @@ impl UtxoValidator { if let Some(dir) = &self.adproof_dump_dir { if self.adproof_dump_heights.contains(&header.height) { // Raw type-104 section: [header_id:32][proof_size:VLQ][proof]. - let section = - crate::sections::serialize_ad_proofs(&header.id.0 .0, proof.as_ref()); + let section = crate::sections::serialize_ad_proofs(&header.id.0 .0, proof.as_ref()); let path = dir.join(format!("adproofs-{}.104", header.height)); match std::fs::write(&path, §ion) { Ok(()) => tracing::info!( @@ -431,20 +666,13 @@ impl UtxoValidator { } } - // 9. Track emission box: scan new outputs for emission contract + // 9. Track emission box: scan new outputs for the emission contract. + // [`find_emission_box`] is shared with construction-time recovery — + // the two paths must not drift, or a resumed node mines against a + // different box than an applying one. if !self.emission_tree_bytes.is_empty() { - self.emission_box_id = None; - for (box_id, box_bytes) in &changes.insertions { - if let Ok(ergo_box) = tx_validation::deserialize_box(box_bytes) { - use ergo_lib::ergotree_ir::serialization::SigmaSerializable; - if let Ok(tree_bytes) = ergo_box.ergo_tree.sigma_serialize_bytes() { - if tree_bytes == self.emission_tree_bytes { - self.emission_box_id = Some(*box_id); - break; - } - } - } - } + self.emission_box_id = + find_emission_box(&changes.insertions, &self.emission_tree_bytes); } // 10. Advance state @@ -456,7 +684,6 @@ impl UtxoValidator { Ok(ApplyStateOutcome { epoch_boundary_params, epoch_boundary_proposed_update, - deferred_eval, }) } @@ -474,6 +701,18 @@ impl UtxoValidator { match self.storage.rollback(&avl_digest) { Ok((root, tree_height)) => { + // `restore_root` is the entire cleanup: it installs the + // on-disk root and drops the abandoned cycle's bookkeeping — + // the changed-node buffers, the directions, and + // `modified_nodes`, the address-keyed map `pack_tree` gates + // on. That last clear used to be a separate line here; it + // moved *into* `restore_root` in fork rev b955790, so it is + // upstream now rather than gone. Do not reinstate it. + // + // The `modified_nodes.is_empty()` assertions in this file's + // rejection tests are what makes that upstream guarantee + // observable — they fail first if the pin ever moves back + // below b955790. self.prover.restore_root(root, tree_height); } Err(e) => { @@ -489,91 +728,107 @@ impl UtxoValidator { /// Compute AD proofs and new state root for a set of transactions /// without modifying persistent state. /// - /// Builds a **separate** prover from storage (fresh tree, no shared Rc - /// nodes). The main prover's tree is never touched — the old path of - /// calling `prover.generate_proof_for_operations()` cloned the tree - /// shallowly (`Rc>`) and operations on the clone mutated - /// shared nodes (visited flags + children pointers during restructuring), - /// corrupting the main prover for the next block apply (state-root - /// mismatch on the first attempt; self-healing retry after rollback). + /// The work is [`proofs_from_storage`]'s; this only supplies the two + /// storage accessors it takes. The validator was never the dependency — + /// see that function's documentation. fn compute_proofs( &self, txs: &[ergo_lib::chain::transaction::Transaction], ) -> Result<(Vec, ADDigest), ValidationError> { - use crate::state_changes::{compute_state_changes, transactions_to_summaries}; + proofs_from_storage(self.storage.resolver(), self.storage.root_state(), txs) + } +} - // 1. Convert transactions to state changes - let summaries = transactions_to_summaries(txs)?; - let changes = compute_state_changes(summaries)?; +/// Compute AD proofs and the resulting state root for `txs` against a +/// committed tree, without a [`UtxoValidator`] and without touching any live +/// prover. +/// +/// `resolver` and `root` are exactly what `RedbAVLStorage` and +/// `SnapshotReader` both hand out (facts/state.md). Mining holds the latter, +/// which is the point: the validator is owned by `sync/` and is `!Sync`, so +/// the mining task cannot reach it — but this path never needed it. It +/// deliberately builds a **separate** prover from storage, because the old +/// approach of calling `prover.generate_proof_for_operations()` on the live +/// prover cloned the tree shallowly (`Rc>`) and operations on +/// the clone mutated shared nodes (visited flags + children pointers during +/// restructuring), corrupting the main prover for the next block apply +/// (state-root mismatch on the first attempt; self-healing retry after +/// rollback). So the dependency was always storage access. +/// +/// ⚠ **The resolver must hand out fresh node handles.** `state/` guarantees +/// that structurally — no cache, a fresh read transaction per resolve — and +/// nothing may be inserted between it and the prover here. A prover working +/// over nodes still keyed in another prover's address-keyed map emits a +/// *different proof for identical tree state*: same digest, different bytes +/// (facts/state.md, under `rollback()`). +/// +/// ⚠ **Read path.** It reports what proofs *would* be. It applies nothing, +/// persists nothing, and advances no watermark; success is not evidence that +/// a block is valid. +pub fn proofs_from_storage( + resolver: Resolver, + root: Option<(Digest32, usize)>, + txs: &[ergo_lib::chain::transaction::Transaction], +) -> Result<(Vec, ADDigest), ValidationError> { + // 1. Convert transactions to state changes + let summaries = transactions_to_summaries(txs)?; + let changes = compute_state_changes(summaries)?; - // 2. Build AVL operations (same order as validate_block) - let mut operations: Vec = Vec::new(); - for lookup_id in &changes.lookups { - operations.push(Operation::Lookup(Bytes::copy_from_slice(lookup_id))); - } - for removal_id in &changes.removals { - operations.push(Operation::Remove(Bytes::copy_from_slice(removal_id))); - } - for (insert_id, insert_value) in &changes.insertions { - operations.push(Operation::Insert(KeyValue { - key: Bytes::copy_from_slice(insert_id), - value: Bytes::copy_from_slice(insert_value), - })); - } + // 2. Build AVL operations (same order as validate_block) + let mut operations: Vec = Vec::new(); + for lookup_id in &changes.lookups { + operations.push(Operation::Lookup(Bytes::copy_from_slice(lookup_id))); + } + for removal_id in &changes.removals { + operations.push(Operation::Remove(Bytes::copy_from_slice(removal_id))); + } + for (insert_id, insert_value) in &changes.insertions { + operations.push(Operation::Insert(KeyValue { + key: Bytes::copy_from_slice(insert_id), + value: Bytes::copy_from_slice(insert_value), + })); + } - // 3. Build a separate prover from storage — do NOT touch self.prover. - // generate_proof_for_operations clones the tree shallowly (Rc) - // and operations on the clone mutate shared nodes, corrupting the - // main prover. Instead, load the current root from storage into a - // fresh tree with its own resolver — every resolved node is a new - // Rc>, independent of the main prover. - let (root_label, tree_height) = self - .storage - .root_state() - .ok_or_else(|| { - ValidationError::StateOperationFailed( - "no root state for mining proof computation".to_string(), - ) - })?; - let root_bytes = self - .storage - .get_node(&root_label) - .map_err(|e| { - ValidationError::StateOperationFailed(format!( - "mining proof: failed to read root node: {e}" - )) - })? - .ok_or_else(|| { - ValidationError::StateOperationFailed( - "mining proof: root node not found in storage".to_string(), - ) - })?; + // 3. Load the committed root into a fresh tree with its own resolver — + // every node it resolves is a new Rc>, independent of + // any other prover's tree. + let (root_label, tree_height) = root.ok_or_else(|| { + ValidationError::StateOperationFailed( + "no root state for mining proof computation".to_string(), + ) + })?; - let mut tree = AVLTree::with_resolver(self.storage.resolver(), 32, None); - let root_node = tree.unpack(&root_bytes); - tree.root = Some(root_node); - tree.height = tree_height; - - let mut temp_prover = BatchAVLProver::new(tree, false); - for op in &operations { - temp_prover - .perform_one_operation(op) - .map_err(|e| { - ValidationError::StateOperationFailed(format!( - "mining proof operation failed: {e}" - )) - })?; - } - let proof_bytes = temp_prover.generate_proof(); - let new_digest = temp_prover.digest().ok_or_else(|| { - ValidationError::StateOperationFailed( - "temp prover has no root after mining operations".to_string(), - ) - })?; + // The resolver is the way in: it unpacks the stored bytes exactly as + // `AVLTree::unpack` does, and `unpack` itself never yields a LabelOnly — + // so that variant IS the miss. A redb read error arrives here the same + // way (`state/` logs the cause at ERROR with the digest), which is why one + // message covers both. + let root_node = (resolver)(&root_label); + if matches!(root_node, Node::LabelOnly(_)) { + return Err(ValidationError::StateOperationFailed( + "mining proof: root node not found in storage".to_string(), + )); + } - let ad_digest = bytes_to_ad_digest(&new_digest); - Ok((proof_bytes.to_vec(), ad_digest)) + let mut tree = AVLTree::with_resolver(resolver, 32, None); + tree.root = Some(Rc::new(RefCell::new(root_node))); + tree.height = tree_height; + + let mut temp_prover = BatchAVLProver::new(tree, false); + for op in &operations { + temp_prover.perform_one_operation(op).map_err(|e| { + ValidationError::StateOperationFailed(format!("mining proof operation failed: {e}")) + })?; } + let proof_bytes = temp_prover.generate_proof(); + let new_digest = temp_prover.digest().ok_or_else(|| { + ValidationError::StateOperationFailed( + "temp prover has no root after mining operations".to_string(), + ) + })?; + + let ad_digest = bytes_to_ad_digest(&new_digest); + Ok((proof_bytes.to_vec(), ad_digest)) } fn bytes_to_ad_digest(bytes: &Bytes) -> ADDigest { @@ -581,3 +836,746 @@ fn bytes_to_ad_digest(bytes: &Bytes) -> ADDigest { arr.copy_from_slice(bytes); ADDigest::from(arr) } + +#[cfg(test)] +mod tests { + //! UTXO-mode script evaluation, and the Err postcondition that makes a + //! rejected block survivable: *the prover* is byte-for-byte as it was on + //! entry (facts/validation.md, "Err leaves the prover clean"). + //! + //! These live inside the crate rather than in `tests/` for one reason: the + //! postcondition is about `self.prover`, and from outside the only view of + //! the tree is `proofs_for_transactions`, which builds its own prover from + //! *storage*. Storage is untouched on the pre-persist failure paths, so an + //! out-of-crate test would report "unchanged" no matter how dirty the + //! in-memory tree was — a test that cannot fail. Here we read + //! `prover.digest()` directly. + + use super::*; + use crate::test_support::*; + use crate::ErgoBox; + use enr_state::{AVLTreeParams, CacheSize, RedbAVLStorage}; + use ergo_chain_types::Digest32; + use ergo_lib::chain::transaction::Transaction; + use ergo_lib::ergotree_ir::ergo_tree::ErgoTree; + use tempfile::TempDir; + use tracing_test::traced_test; + + const KEY_LEN: usize = 32; + + fn open_storage(dir: &TempDir) -> RedbAVLStorage { + RedbAVLStorage::open( + &dir.path().join("state.redb"), + AVLTreeParams { + key_length: KEY_LEN, + value_length: None, + }, + 10, + CacheSize::default(), + ) + .expect("fresh redb opens") + } + + /// Seed a storage/prover pair with `boxes` committed at `SEED_HEIGHT`, + /// left in the cycle state a steady-state validator is in: the storage + /// commit clears the dirty-node bookkeeping, and the trailing + /// `generate_proof` rebases the proof baseline exactly as step 8 of a + /// completed `apply_state` does. Without that rebase the next block's + /// internally generated proof would be packed from a stale root and every + /// test here would fail on the proof-digest check for the wrong reason. + fn seed(storage: &mut RedbAVLStorage, boxes: &[ErgoBox]) -> BatchAVLProver { + let tree = AVLTree::with_resolver(storage.resolver(), KEY_LEN, None); + let mut prover = BatchAVLProver::new(tree, true); + for b in boxes { + prover + .perform_one_operation(&Operation::Insert(KeyValue { + key: Bytes::copy_from_slice(&box_key(b)), + value: Bytes::from(serialized_box(b)), + })) + .expect("seed insert"); + } + storage + .update_with_height(&mut prover, vec![], SEED_HEIGHT) + .expect("seed commit"); + let _ = prover.generate_proof(); + prover + } + + fn seeded_validator(boxes: &[ErgoBox]) -> (UtxoValidator, TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let mut storage = open_storage(&dir); + let prover = seed(&mut storage, boxes); + ( + UtxoValidator::new( + storage, + prover, + SEED_HEIGHT, + 0, + EmissionSource::Unavailable("seeded fixture: no tip block"), + ), + dir, + ) + } + + /// What the block under test must claim to be accepted: the post-block + /// state root, and the digest of the proof the validator will generate + /// internally. Computed by replaying the identical sequence — seed, + /// commit, rebase, block operations, commit, proof — on an independent + /// storage/prover pair, in the same order `apply_state_internal` uses. + fn oracle(boxes: &[ErgoBox], ops: &[Operation]) -> (ADDigest, Digest32) { + let dir = tempfile::tempdir().expect("tempdir"); + let mut storage = open_storage(&dir); + let mut prover = seed(&mut storage, boxes); + + for op in ops { + prover.perform_one_operation(op).expect("oracle operation"); + } + let state_root = bytes_to_ad_digest(&prover.digest().expect("oracle has a root")); + storage + .update_with_height(&mut prover, vec![], BLOCK_HEIGHT) + .expect("oracle commit"); + let proof = prover.generate_proof(); + + (state_root, blake2b256_hash(proof.as_ref())) + } + + fn prover_digest(validator: &UtxoValidator) -> ADDigest { + bytes_to_ad_digest(&validator.prover.digest().expect("prover has a root")) + } + + /// Everything an `apply_state` call needs for a one-transaction block. + struct Block { + header: Header, + txs: Vec, + extension: Vec, + preceding: Vec
, + } + + impl Block { + fn new(transactions: &[Transaction], state_root: ADDigest, ad_root: Digest32) -> Self { + let (txs, extension) = sections(transactions); + Self { + header: make_header(BLOCK_HEIGHT, state_root, ad_root), + txs, + extension, + preceding: preceding_headers(), + } + } + + fn apply( + &self, + validator: &mut UtxoValidator, + ) -> Result { + validator.apply_state( + &self.header, + &self.txs, + None, + &self.extension, + &self.preceding, + &Parameters::default(), + None, + None, + ) + } + } + + /// A rejected block must leave nothing of itself behind. The digest check + /// fires after step 4 has applied every operation, so this is the path + /// where "nothing undoes them" would show up. + #[test] + fn digest_mismatch_leaves_the_prover_clean() { + let input = make_box(true, 1); + let tx = spend_tx(std::slice::from_ref(&input)); + let ops = block_operations(std::slice::from_ref(&tx)); + + let (mut validator, _dir) = seeded_validator(std::slice::from_ref(&input)); + let before = prover_digest(&validator); + + // Not a vacuous test: this block really does move the tree, so an + // un-rewound prover would be observably different afterwards. + let (real_state_root, _) = oracle(std::slice::from_ref(&input), &ops); + assert_ne!( + real_state_root, before, + "fixture is degenerate — the block changes nothing, so no rewind is needed" + ); + + // The block claims the tree did not change. Every operation is applied + // before that claim is checked. + let block = Block::new(std::slice::from_ref(&tx), before, Digest32::zero()); + let err = block + .apply(&mut validator) + .expect_err("a wrong state root must be rejected"); + assert!( + matches!(err, ValidationError::StateRootMismatch { .. }), + "unexpected error variant: {err:?}" + ); + + assert_eq!(validator.validated_height(), SEED_HEIGHT); + assert_eq!(*validator.current_digest(), before); + assert_eq!( + prover_digest(&validator), + before, + "the prover kept the rejected block's mutations" + ); + assert!( + validator.prover.base.modified_nodes.is_empty(), + "the rejected block's touched nodes are still pinned in the proof-cycle map" + ); + } + + /// The script-failure rejection path — the one that turns the case above + /// from near-unreachable into routine. The block is otherwise perfectly + /// well-formed; only its script fails. + #[test] + fn script_failure_leaves_the_prover_clean_and_storage_untouched() { + let input = make_box(false, 2); + let tx = spend_tx(std::slice::from_ref(&input)); + let ops = block_operations(std::slice::from_ref(&tx)); + let (state_root, ad_root) = oracle(std::slice::from_ref(&input), &ops); + + let (mut validator, _dir) = seeded_validator(std::slice::from_ref(&input)); + let before = prover_digest(&validator); + let block = Block::new(std::slice::from_ref(&tx), state_root, ad_root); + + let err = block + .apply(&mut validator) + .expect_err("an unsatisfied script must be rejected"); + assert!( + matches!(err, ValidationError::TransactionInvalid { .. }), + "expected a script failure, got: {err:?}" + ); + + assert_eq!(validator.validated_height(), SEED_HEIGHT); + assert_eq!(*validator.current_digest(), before); + assert_eq!( + prover_digest(&validator), + before, + "the prover kept the rejected block's mutations" + ); + assert!( + validator.prover.base.modified_nodes.is_empty(), + "the rejected block's touched nodes are still pinned in the proof-cycle map" + ); + + // The whole point of evaluating before `update_with_height`: the + // rejected block never reached storage. `proofs_for_transactions` + // reads the tree back out of storage, so this observes the durable + // side specifically, not the in-memory one asserted above. + let (_, storage_digest) = validator + .proofs_for_transactions(&[]) + .expect("empty-operation proof"); + assert_eq!( + storage_digest, before, + "an unverified block reached state.redb" + ); + } + + /// The accept arm. Both rejection tests above prove the validator says no + /// to something; this proves it still says yes to a block that deserves + /// it, and that an Ok really does advance both height and digest. + #[test] + fn a_valid_block_is_accepted_and_advances_state() { + let input = make_box(true, 3); + let tx = spend_tx(std::slice::from_ref(&input)); + let ops = block_operations(std::slice::from_ref(&tx)); + let (state_root, ad_root) = oracle(std::slice::from_ref(&input), &ops); + let block = Block::new(std::slice::from_ref(&tx), state_root, ad_root); + + let (mut validator, _dir) = seeded_validator(std::slice::from_ref(&input)); + block.apply(&mut validator).expect("valid block applies"); + + assert_eq!(validator.validated_height(), BLOCK_HEIGHT); + assert_eq!(*validator.current_digest(), state_root); + } + + /// At or below the checkpoint nothing is evaluated — the state-root check + /// alone is the guarantee. This block carries the same unsatisfiable + /// script the rejection test above uses and is accepted anyway, which is + /// the whole observable: the skip is what separates the two outcomes. + #[test] + fn the_checkpoint_skips_evaluation_entirely() { + let input = make_box(false, 4); + let tx = spend_tx(std::slice::from_ref(&input)); + let ops = block_operations(std::slice::from_ref(&tx)); + let (state_root, ad_root) = oracle(std::slice::from_ref(&input), &ops); + let block = Block::new(std::slice::from_ref(&tx), state_root, ad_root); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut storage = open_storage(&dir); + let prover = seed(&mut storage, std::slice::from_ref(&input)); + // Checkpoint at the block's own height: `height > checkpoint` is + // false, so the unsatisfiable script is never looked at. + let mut validator = UtxoValidator::new( + storage, + prover, + SEED_HEIGHT, + BLOCK_HEIGHT, + EmissionSource::Unavailable("checkpoint fixture: no tip block"), + ); + + block + .apply(&mut validator) + .expect("checkpointed block applies without script evaluation"); + + assert_eq!(validator.validated_height(), BLOCK_HEIGHT); + assert_eq!(*validator.current_digest(), state_root); + } + + /// The extraction's whole contract: the free function and the method agree + /// on the proof *bytes*, not merely on the digest. Same-digest-different- + /// bytes is precisely the hazard this path lives in (facts/state.md, under + /// `rollback()`), so a digest-only assertion would pass straight through + /// the failure it exists to catch. + /// + /// Not tautological despite the delegation: the free function is reached + /// here through a `SnapshotReader` — a second handle on the same database, + /// and the route mining will actually take — while the method goes through + /// the validator's own `RedbAVLStorage`. Two accessor pairs, one answer. + #[test] + fn proofs_from_storage_matches_the_validator_byte_for_byte() { + let input = make_box(true, 5); + let tx = spend_tx(std::slice::from_ref(&input)); + + let (validator, _dir) = seeded_validator(std::slice::from_ref(&input)); + let before = prover_digest(&validator); + + let (method_proof, method_root) = validator + .compute_proofs(std::slice::from_ref(&tx)) + .expect("the validator computes proofs"); + + let reader = validator.storage.snapshot_reader(); + let (free_proof, free_root) = proofs_from_storage( + reader.resolver(), + reader.root_state(), + std::slice::from_ref(&tx), + ) + .expect("the free function computes proofs"); + + assert_eq!( + free_proof, method_proof, + "same tree, same transactions, different proof bytes — the two \ + provers are not seeing independent nodes" + ); + assert_eq!(free_root, method_root, "the two paths disagree on the root"); + + // Not a vacuous fixture: this transaction really does move the tree, + // so an equality that held for a no-op would not hold here. + assert_ne!( + free_root, before, + "fixture is degenerate — the transaction changes nothing" + ); + + // And neither call disturbed the live prover, which is the reason + // this path is safe to run from another task at all. + assert_eq!( + prover_digest(&validator), + before, + "computing mining proofs moved the validator's own prover" + ); + } + + // ── Prover memory attribution ──────────────────────────────────────── + // + // These read `validator.prover` through `prover_memory_estimate`, so they + // belong in here for the same reason the rewind tests do: from outside the + // crate the in-memory tree is invisible, and a test that cannot see it + // cannot fail. + + fn estimate(validator: &UtxoValidator) -> ProverMemoryEstimate { + validator + .prover_memory_estimate() + .expect("a prover with a root always has something to measure") + } + + /// The literal ask: a validator that has applied a block names a tree. + /// The bytes floor is what separates a measurement from a placeholder — + /// every counted node is at least one `Rc>` allocation, so a + /// figure that satisfies this cannot have been a `node_count` with the + /// bytes stubbed out. + #[test] + fn a_validator_that_applied_a_block_reports_a_resident_tree() { + let input = make_box(true, 6); + let tx = spend_tx(std::slice::from_ref(&input)); + let ops = block_operations(std::slice::from_ref(&tx)); + let (state_root, ad_root) = oracle(std::slice::from_ref(&input), &ops); + let block = Block::new(std::slice::from_ref(&tx), state_root, ad_root); + + let (mut validator, _dir) = seeded_validator(std::slice::from_ref(&input)); + block.apply(&mut validator).expect("valid block applies"); + + let measured = estimate(&validator); + assert!( + measured.node_count > 0, + "an applied block left no resident nodes" + ); + assert!( + measured.resident_nodes_bytes + >= measured.node_count * crate::prover_memory::node_allocation_bytes(), + "{} bytes across {} nodes is below one allocation per node", + measured.resident_nodes_bytes, + measured.node_count + ); + } + + /// The estimate must be a measurement, not a multiplier. Two validators + /// differing only in how much state they hold must report two different + /// answers, and both figures must move together. `AVG_HEADER_BYTES` + /// (`facts/api.md`) passed every test anyone wrote for four months because + /// nobody made it answer a question whose answer it could get wrong. + #[test] + fn the_estimate_follows_the_tree_it_is_measuring() { + let few: Vec = (100u8..102).map(|s| make_box(true, s)).collect(); + let many: Vec = (110u8..134).map(|s| make_box(true, s)).collect(); + + let (small, _small_dir) = seeded_validator(&few); + let (large, _large_dir) = seeded_validator(&many); + + let small = estimate(&small); + let large = estimate(&large); + + assert!( + large.node_count > small.node_count, + "twelve times the boxes, {} nodes against {} — the walk is not \ + seeing the tree", + large.node_count, + small.node_count + ); + assert!( + large.resident_nodes_bytes > small.resident_nodes_bytes, + "node_count moved and bytes did not: {} against {}", + large.resident_nodes_bytes, + small.resident_nodes_bytes + ); + } + + /// Two claims at once, and the second is the dangerous one. + /// + /// `reset_to` reinstalls the root unpacked from storage — one internal + /// node and two `LabelOnly` children — so a walk that resolved would + /// report the whole tree here instead of three nodes, having just pulled + /// the entire UTXO set into RAM in order to measure it. That is the + /// failure this asserts against. + /// + /// What it demonstrates: nothing here mutates state. Reads alone resolve + /// nodes in place and the resident tree grows permanently, which is the + /// mechanism behind the unattributed heap being hunted. + #[test] + fn resolving_from_storage_grows_the_resident_tree() { + let boxes: Vec = (140u8..164).map(|s| make_box(true, s)).collect(); + let (mut validator, _dir) = seeded_validator(&boxes); + + let seeded = estimate(&validator); + let digest = prover_digest(&validator); + + validator + .reset_to(SEED_HEIGHT, digest) + .expect("rollback to the version storage is already at"); + let frontier = estimate(&validator); + assert!( + frontier.node_count < seeded.node_count, + "the frontier reports {} of {} nodes — the walk is resolving \ + LabelOnly children from storage", + frontier.node_count, + seeded.node_count + ); + + // The frontier is a structure small enough to state exactly, which is + // what pins the payload accounting: `unpack` builds one internal node + // carrying a 32-byte key and two `LabelOnly` children carrying none. + // A `node_count × constant` — the `AVG_HEADER_BYTES` shape — cannot + // land on this by construction. + assert_eq!( + frontier.node_count, 3, + "an unpacked root is one internal node and two labels" + ); + assert_eq!( + frontier.resident_nodes_bytes, + 3 * crate::prover_memory::node_allocation_bytes() + KEY_LEN as u64, + "three allocations plus one key is the whole frontier; this figure \ + is not counting what the nodes actually own" + ); + + for ergo_box in &boxes { + let key = Bytes::copy_from_slice(&box_key(ergo_box)); + assert!( + validator.prover.unauthenticated_lookup(&key).is_some(), + "a seeded box is missing from the tree" + ); + } + + let resolved = estimate(&validator); + assert!( + resolved.node_count > frontier.node_count, + "resolving every leaf left the node count at {}", + resolved.node_count + ); + assert!( + resolved.resident_nodes_bytes > frontier.resident_nodes_bytes, + "node_count grew and bytes did not: {} against {}", + resolved.resident_nodes_bytes, + frontier.resident_nodes_bytes + ); + } + + /// `modified_nodes_bytes` must read the live buffers rather than report a + /// fixed shape. + /// + /// ⚠ The contract calls that buffer "cleared on flush" + /// (`facts/validation.md`). It is not: `StatePersistence::flush` is redb's + /// fsync and never touches the prover. The clear is the *proof-cycle* + /// boundary — `generate_proof()` for all three buffers, plus + /// `update_internal` for the two `Vec`s — and both of those sit inside + /// `apply_state`. So this drives that seam directly; going through a + /// completed `apply_state` would sample only after the clear and leave + /// nothing to observe. + #[test] + fn the_cycle_buffers_are_measured_and_released() { + let boxes: Vec = (170u8..178).map(|s| make_box(true, s)).collect(); + let (mut validator, _dir) = seeded_validator(&boxes); + + let idle = estimate(&validator); + + // Mid-cycle: operations performed, no proof generated yet — where an + // `apply_state` sits between applying its operations and step 8. + for ergo_box in &boxes { + validator + .prover + .perform_one_operation(&Operation::Lookup(Bytes::copy_from_slice(&box_key( + ergo_box, + )))) + .expect("lookup of a seeded box"); + } + let mid_cycle = estimate(&validator); + assert!( + mid_cycle.modified_nodes_bytes > idle.modified_nodes_bytes, + "the touched nodes did not register: {} against an idle {}", + mid_cycle.modified_nodes_bytes, + idle.modified_nodes_bytes + ); + + let _ = validator.prover.generate_proof(); + let released = estimate(&validator); + assert!( + released.modified_nodes_bytes < mid_cycle.modified_nodes_bytes, + "the figure did not move across the clear — it is measuring \ + something other than the buffers" + ); + assert_eq!( + released.modified_nodes_bytes, idle.modified_nodes_bytes, + "the cycle ended but the figure did not return to idle" + ); + + // The tree itself is untouched by a lookup, so the *other* figure must + // not have moved. If it did, the two are not separated and summing + // them at the endpoint would double-count. + assert_eq!( + released.resident_nodes_bytes, idle.resident_nodes_bytes, + "lookups changed the resident figure" + ); + } + + // ── Emission box recovery at construction ──────────────────────────── + // + // `emission_box_id` is derived state that step 9 of `apply_state` + // maintains. What these cover is the value a validator reports *before* + // it has applied anything — the state a node restarted at the chain tip + // is in, where `None` reads as "all ERG has been emitted" and mining + // therefore never starts. + + fn emission_tree() -> ErgoTree { + use ergo_lib::chain::emission::MonetarySettings; + use ergo_lib::chain::ergo_tree_predef; + ergo_tree_predef::emission_box_prop(&MonetarySettings::default()) + .expect("the emission contract builds") + } + + /// A block at `BLOCK_HEIGHT` whose single transaction recreates the + /// emission box, plus the input it spends — the shape of every real + /// coinbase, which is what makes the tip block a sufficient source. + fn emission_block(seed: u8) -> (ErgoBox, Transaction, Block) { + let input = make_box(true, seed); + let tx = spend_tx_to(std::slice::from_ref(&input), emission_tree()); + let ops = block_operations(std::slice::from_ref(&tx)); + let (state_root, ad_root) = oracle(std::slice::from_ref(&input), &ops); + let block = Block::new(std::slice::from_ref(&tx), state_root, ad_root); + (input, tx, block) + } + + /// The whole point of the change, and the structural assertion that both + /// paths run one implementation: a validator that has applied nothing must + /// already name **the same box** applying the block would have named. + /// + /// Asserting only `is_some()` would pass on a second, divergent scan that + /// picked a different output — the failure mode where a resumed node mines + /// against the wrong box and every block it produces is rejected. + #[test] + fn recovery_at_the_tip_matches_applying_the_same_block() { + let (input, tx, block) = emission_block(7); + + // The applying path. + let (mut applied, _applied_dir) = seeded_validator(std::slice::from_ref(&input)); + assert_eq!( + applied.emission_box_id(), + None, + "fixture is degenerate — this validator already knew the answer \ + before applying anything" + ); + block.apply(&mut applied).expect("valid block applies"); + let after_apply = applied + .emission_box_id() + .expect("the applied block recreated the emission box"); + + // The resume path: a fresh validator over the post-block box set, + // handed that block's section bytes and nothing else. (`seed` commits + // at SEED_HEIGHT while the validator resumes at BLOCK_HEIGHT — + // immaterial, because recovery reads the section bytes, never the + // tree. Which is exactly why it costs one block to parse instead of a + // UTXO-set scan.) + let dir = tempfile::tempdir().expect("tempdir"); + let mut storage = open_storage(&dir); + let prover = seed(&mut storage, tx.outputs.as_vec()); + let resumed = UtxoValidator::new( + storage, + prover, + BLOCK_HEIGHT, + 0, + EmissionSource::TipBlock(&block.txs), + ); + + assert_eq!( + resumed.validated_height(), + BLOCK_HEIGHT, + "the resumed validator is not where the tip block left it" + ); + assert_eq!( + resumed.emission_box_id(), + Some(after_apply), + "a validator resumed at the tip names a different emission box \ + than one that applied the same block" + ); + } + + /// `None` from a source that read cleanly is the documented answer rather + /// than a failure: the tip block created no emission output, so emission + /// has ended. + #[test] + fn a_tip_block_without_an_emission_output_recovers_none() { + let input = make_box(true, 8); + let tx = spend_tx(std::slice::from_ref(&input)); + let (txs, _extension) = sections(std::slice::from_ref(&tx)); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut storage = open_storage(&dir); + let prover = seed(&mut storage, std::slice::from_ref(&input)); + let validator = UtxoValidator::new( + storage, + prover, + BLOCK_HEIGHT, + 0, + EmissionSource::TipBlock(&txs), + ); + + assert_eq!( + validator.emission_box_id(), + None, + "a block whose outputs carry no emission contract reported one" + ); + } + + /// The case that must never be silent. `None` here is byte-identical to + /// the legitimate answer above, so without the WARN an operator cannot + /// tell "emission ended" from "I could not read the block" — which is how + /// the original defect survived to reach the field. + #[traced_test] + #[test] + fn an_unreadable_tip_block_recovers_none_and_warns() { + // A well-formed header id followed by a claim of five transactions and + // no transaction bytes: parses far enough to be a real section and + // then fails, rather than being rejected on length alone. + let mut malformed = vec![0u8; 32]; + malformed.push(5); + + let input = make_box(true, 9); + let dir = tempfile::tempdir().expect("tempdir"); + let mut storage = open_storage(&dir); + let prover = seed(&mut storage, std::slice::from_ref(&input)); + let validator = UtxoValidator::new( + storage, + prover, + BLOCK_HEIGHT, + 0, + EmissionSource::TipBlock(&malformed), + ); + + assert_eq!(validator.emission_box_id(), None); + assert!( + logs_contain("emission_box_id recovery failed"), + "an unreadable tip block left `None` silently" + ); + } + + /// The caller that cannot supply a source at all — UTXO snapshot + /// bootstrap, which loads a tree at a height whose block transactions were + /// never downloaded. It must say why, and the why must reach the log. + #[traced_test] + #[test] + fn an_unavailable_source_recovers_none_and_names_its_reason() { + let input = make_box(true, 10); + let dir = tempfile::tempdir().expect("tempdir"); + let mut storage = open_storage(&dir); + let prover = seed(&mut storage, std::slice::from_ref(&input)); + let validator = UtxoValidator::new( + storage, + prover, + BLOCK_HEIGHT, + 0, + EmissionSource::Unavailable("snapshot bootstrap: no block at the snapshot height"), + ); + + assert_eq!(validator.emission_box_id(), None); + assert!( + logs_contain("snapshot bootstrap: no block at the snapshot height"), + "the caller's reason never reached the log" + ); + } + + /// The genesis arm. At height 0 there is no tip block, and the emission box + /// is one of the three boxes the bootstrap just inserted. Left uncovered, a + /// node mining a fresh chain would deadlock at height 1 for exactly the + /// reason an at-tip restart did. + /// + /// Built from `ergo-lib`'s own `genesis_boxes`, which is what `main` calls, + /// so this also pins the assumption underneath the whole file: that the + /// genesis emission box carries the same `emission_box_prop` tree + /// `UtxoValidator::new` computes. + #[test] + fn genesis_boxes_recover_the_emission_box() { + use ergo_lib::chain::emission::MonetarySettings; + use ergo_lib::chain::genesis::genesis_boxes; + use ergo_lib::ergotree_ir::sigma_protocol::sigma_boolean::ProveDlog; + + let founders = vec![ProveDlog::new(ergo_chain_types::EcPoint::default())]; + let (emission, no_premine, founders_box) = + genesis_boxes(&MonetarySettings::default(), &founders, 1, &[]) + .expect("genesis boxes construct"); + + let boxes: Vec<([u8; 32], Vec)> = [&emission, &no_premine, &founders_box] + .into_iter() + .map(|b| (box_key(b), serialized_box(b))) + .collect(); + + let input = make_box(true, 11); + let dir = tempfile::tempdir().expect("tempdir"); + let mut storage = open_storage(&dir); + let prover = seed(&mut storage, std::slice::from_ref(&input)); + let validator = + UtxoValidator::new(storage, prover, 0, 0, EmissionSource::GenesisBoxes(&boxes)); + + assert_eq!( + validator.emission_box_id(), + Some(box_key(&emission)), + "the genesis emission box was not recognised — `emission_box_prop` \ + and the genesis bootstrap have diverged" + ); + } +} diff --git a/validation/src/voting.rs b/validation/src/voting.rs index d3f1d0a..6e1046b 100644 --- a/validation/src/voting.rs +++ b/validation/src/voting.rs @@ -173,7 +173,11 @@ pub fn check_parameters_v6( // mainnet block 1,629,184 where the block's ID 124 (6 bytes, empty // statusUpdates) differs from the previous boundary's 18 bytes, yet // JVM v6.0.3 accepts the chain. - let _ = (block_version, expected_proposed_update, parsed_proposed_update); + let _ = ( + block_version, + expected_proposed_update, + parsed_proposed_update, + ); Ok(()) } @@ -236,9 +240,7 @@ pub fn pack_parameters(params: &Parameters) -> Vec<([u8; 2], Vec)> { let mut entries: Vec<(i8, i32)> = params .parameters_table .iter() - .filter_map(|(param, value)| { - parameter_to_signed_id(*param).map(|id| (id, *value)) - }) + .filter_map(|(param, value)| parameter_to_signed_id(*param).map(|id| (id, *value))) .collect(); entries.sort_by_key(|(id, _)| *id); @@ -373,11 +375,15 @@ mod tests { ]); let params = parse_parameters_from_extension(&ext).unwrap(); assert_eq!( - params.parameters_table.get(&Parameter::SoftForkVotesCollected), + params + .parameters_table + .get(&Parameter::SoftForkVotesCollected), Some(&100) ); assert_eq!( - params.parameters_table.get(&Parameter::SoftForkStartingHeight), + params + .parameters_table + .get(&Parameter::SoftForkStartingHeight), Some(&1024) ); } @@ -387,8 +393,12 @@ mod tests { let mut params = Parameters::default(); params.parameters_table.clear(); params.parameters_table.insert(Parameter::BlockVersion, 1); - params.parameters_table.insert(Parameter::StorageFeeFactor, 1_250_000); - params.parameters_table.insert(Parameter::MaxBlockSize, 524_288); + params + .parameters_table + .insert(Parameter::StorageFeeFactor, 1_250_000); + params + .parameters_table + .insert(Parameter::MaxBlockSize, 524_288); let fields = pack_parameters(¶ms); assert_eq!(fields.len(), 3); @@ -402,7 +412,9 @@ mod tests { fn check_v6_exact_match() { let mut expected = Parameters::default(); expected.parameters_table.clear(); - expected.parameters_table.insert(Parameter::StorageFeeFactor, 1_250_000); + expected + .parameters_table + .insert(Parameter::StorageFeeFactor, 1_250_000); expected.parameters_table.insert(Parameter::BlockVersion, 4); let parsed = expected.clone(); @@ -415,11 +427,15 @@ mod tests { // v6 allows local to be smaller — newer protocol can introduce params. let mut expected = Parameters::default(); expected.parameters_table.clear(); - expected.parameters_table.insert(Parameter::StorageFeeFactor, 1_250_000); + expected + .parameters_table + .insert(Parameter::StorageFeeFactor, 1_250_000); expected.parameters_table.insert(Parameter::BlockVersion, 4); let mut parsed = expected.clone(); - parsed.parameters_table.insert(Parameter::MaxBlockSize, 524_288); + parsed + .parameters_table + .insert(Parameter::MaxBlockSize, 524_288); assert!(check_parameters_v6(&expected, &parsed, 128, 1, &[], &[]).is_ok()); } @@ -430,13 +446,19 @@ mod tests { // parameter we expected — consensus failure. let mut expected = Parameters::default(); expected.parameters_table.clear(); - expected.parameters_table.insert(Parameter::StorageFeeFactor, 1_250_000); + expected + .parameters_table + .insert(Parameter::StorageFeeFactor, 1_250_000); expected.parameters_table.insert(Parameter::BlockVersion, 4); - expected.parameters_table.insert(Parameter::MaxBlockSize, 524_288); + expected + .parameters_table + .insert(Parameter::MaxBlockSize, 524_288); let mut parsed = Parameters::default(); parsed.parameters_table.clear(); - parsed.parameters_table.insert(Parameter::StorageFeeFactor, 1_250_000); + parsed + .parameters_table + .insert(Parameter::StorageFeeFactor, 1_250_000); parsed.parameters_table.insert(Parameter::BlockVersion, 4); let err = check_parameters_v6(&expected, &parsed, 128, 1, &[], &[]).unwrap_err(); @@ -467,7 +489,9 @@ mod tests { let mut parsed = Parameters::default(); parsed.parameters_table.clear(); - parsed.parameters_table.insert(Parameter::StorageFeeFactor, 1_250_000); + parsed + .parameters_table + .insert(Parameter::StorageFeeFactor, 1_250_000); let err = check_parameters_v6(&expected, &parsed, 128, 1, &[], &[]).unwrap_err(); assert!(matches!(err, ValidationError::ParameterMismatch { .. })); @@ -485,7 +509,12 @@ mod tests { params.parameters_table.clear(); params.parameters_table.insert(Parameter::BlockVersion, 4); assert!(check_parameters_v6( - ¶ms, ¶ms, 1_629_184, 4, &[0x00, 0x00], &[0x02, 0xd7, 0x01], + ¶ms, + ¶ms, + 1_629_184, + 4, + &[0x00, 0x00], + &[0x02, 0xd7, 0x01], ) .is_ok()); } @@ -498,7 +527,12 @@ mod tests { params.parameters_table.clear(); params.parameters_table.insert(Parameter::BlockVersion, 3); assert!(check_parameters_v6( - ¶ms, ¶ms, 1024, 3, &[0x00, 0x00], &[0x02, 0xd7, 0x01], + ¶ms, + ¶ms, + 1024, + 3, + &[0x00, 0x00], + &[0x02, 0xd7, 0x01], ) .is_ok()); } @@ -556,9 +590,15 @@ mod tests { fn round_trip_parse_pack() { let mut original = Parameters::default(); original.parameters_table.clear(); - original.parameters_table.insert(Parameter::StorageFeeFactor, 1_250_000); - original.parameters_table.insert(Parameter::MaxBlockSize, 524_288); - original.parameters_table.insert(Parameter::MaxBlockCost, 1_000_000); + original + .parameters_table + .insert(Parameter::StorageFeeFactor, 1_250_000); + original + .parameters_table + .insert(Parameter::MaxBlockSize, 524_288); + original + .parameters_table + .insert(Parameter::MaxBlockCost, 1_000_000); original.parameters_table.insert(Parameter::BlockVersion, 4); let packed = pack_parameters(&original); @@ -569,7 +609,10 @@ mod tests { let ext = make_extension(fields); let parsed = parse_parameters_from_extension(&ext).unwrap(); - assert_eq!(parsed.parameters_table.len(), original.parameters_table.len()); + assert_eq!( + parsed.parameters_table.len(), + original.parameters_table.len() + ); for (k, v) in original.parameters_table.iter() { assert_eq!(parsed.parameters_table.get(k), Some(v)); } diff --git a/validation/tests/adproof_dump_ordering_diag.rs b/validation/tests/adproof_dump_ordering_diag.rs index 040534e..49fe5b6 100644 --- a/validation/tests/adproof_dump_ordering_diag.rs +++ b/validation/tests/adproof_dump_ordering_diag.rs @@ -78,7 +78,9 @@ fn verify( v.perform_one_operation(op) .map_err(|e| format!("verifier replay failed at op {i}: {e}"))?; } - let got = v.digest().ok_or_else(|| "verifier produced no digest".to_string())?; + let got = v + .digest() + .ok_or_else(|| "verifier produced no digest".to_string())?; Ok(got == *expected_post) } @@ -125,11 +127,7 @@ fn repro_reset_before_generate_proof() { let proof = prover.generate_proof(); let result = verify(&start, &proof, &ops, &post); - println!( - "REPRO: proof_len={} result={:?}", - proof.len(), - result - ); + println!("REPRO: proof_len={} result={:?}", proof.len(), result); match &result { Ok(true) => println!( "REPRO VERDICT: proof VERIFIED — apply-time proof is valid; dump task premise HOLDS" diff --git a/validation/tests/block_version_gate.rs b/validation/tests/block_version_gate.rs index 7ebef93..b627ce6 100644 --- a/validation/tests/block_version_gate.rs +++ b/validation/tests/block_version_gate.rs @@ -85,7 +85,16 @@ fn mid_epoch_version_divergence_not_checked() { let active = make_params(Some(3)); let err = validator - .apply_state(&header, &fx.txs, Some(&fx.proofs), &ext, &[], &active, None, None) + .apply_state( + &header, + &fx.txs, + Some(&fx.proofs), + &ext, + &[], + &active, + None, + None, + ) .unwrap_err(); assert!( matches!(err, ValidationError::ProofDigestMismatch { .. }), @@ -102,11 +111,8 @@ fn boundary_mismatch_rejected_against_recomputed_set() { let mut validator = DigestValidator::new(ADDigest::zero(), 0); let header = make_header(1, 4); let fx = make_sections(); - let ext = serialize_extension( - &[0u8; 32], - &[([0x00, 123], 3i32.to_be_bytes().to_vec())], - ) - .expect("extension with params"); + let ext = serialize_extension(&[0u8; 32], &[([0x00, 123], 3i32.to_be_bytes().to_vec())]) + .expect("extension with params"); let active = make_params(Some(4)); let boundary = make_params(Some(3)); @@ -138,11 +144,8 @@ fn matching_version_passes_gate_at_boundary() { let mut validator = DigestValidator::new(ADDigest::zero(), 0); let header = make_header(1, 4); let fx = make_sections(); - let ext = serialize_extension( - &[0u8; 32], - &[([0x00, 123], 4i32.to_be_bytes().to_vec())], - ) - .expect("extension with params"); + let ext = serialize_extension(&[0u8; 32], &[([0x00, 123], 4i32.to_be_bytes().to_vec())]) + .expect("extension with params"); let active = make_params(Some(4)); let boundary = make_params(Some(4)); @@ -175,11 +178,8 @@ fn absent_block_version_at_boundary_rejects_without_panicking() { let mut validator = DigestValidator::new(ADDigest::zero(), 0); let header = make_header(1, 4); let fx = make_sections(); - let ext = serialize_extension( - &[0u8; 32], - &[([0x00, 123], 4i32.to_be_bytes().to_vec())], - ) - .expect("extension with params"); + let ext = serialize_extension(&[0u8; 32], &[([0x00, 123], 4i32.to_be_bytes().to_vec())]) + .expect("extension with params"); let active = make_params(Some(4)); let boundary = make_params(None); diff --git a/validation/tests/reset_to.rs b/validation/tests/reset_to.rs index 5190eb5..07ed966 100644 --- a/validation/tests/reset_to.rs +++ b/validation/tests/reset_to.rs @@ -13,7 +13,9 @@ use ergo_avltree_rust::batch_avl_prover::BatchAVLProver; use ergo_avltree_rust::batch_node::AVLTree; use ergo_avltree_rust::operation::{KeyValue, Operation}; use ergo_chain_types::ADDigest; -use ergo_validation::{BlockValidator, DigestValidator, UtxoValidator, ValidationError}; +use ergo_validation::{ + BlockValidator, DigestValidator, EmissionSource, MiningState, UtxoValidator, ValidationError, +}; use tempfile::TempDir; const KEY_LEN: usize = 32; @@ -73,7 +75,16 @@ fn utxo_validator_with_history() -> (UtxoValidator, ADDigest, ADDigest, TempDir) storage.update_with_height(&mut prover, vec![], 2).unwrap(); let digest_h2 = prover_ad_digest(&prover); - let validator = UtxoValidator::new(storage, prover, 2, 0); + // These fixtures insert raw 16-byte values, not serialized boxes, so no + // source could yield an emission box here — say so rather than feeding + // the recovery something it will fail to parse. + let validator = UtxoValidator::new( + storage, + prover, + 2, + 0, + EmissionSource::Unavailable("reset_to fixture: synthetic non-box values"), + ); (validator, digest_h1, digest_h2, dir) } @@ -82,7 +93,6 @@ fn utxo_validator_with_history() -> (UtxoValidator, ADDigest, ADDigest, TempDir) fn observed_prover_digest(validator: &UtxoValidator) -> ADDigest { let (_, digest) = validator .proofs_for_transactions(&[]) - .expect("UTXO mode computes proofs") .expect("empty-ops proof generation succeeds"); digest }