Skip to content

Testnet readiness: sync durability, state-growth control, ZK compliance proving, and the Trilith integration surface - #76

Draft
0xZunia wants to merge 75 commits into
mainfrom
integration/testnet-prep
Draft

Testnet readiness: sync durability, state-growth control, ZK compliance proving, and the Trilith integration surface#76
0xZunia wants to merge 75 commits into
mainfrom
integration/testnet-prep

Conversation

@0xZunia

@0xZunia 0xZunia commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What this is

integration/testnet-prep is the branch the incentivized testnet has been built on. It carries 25 commits since main and touches 57 files (+5988 / -85).

It has never been through CI. ci.yml triggers only on pushes to main and pull requests targeting main, so every commit here was verified by local test sweeps instead. Getting this branch through the CI matrix is one of the reasons for opening the PR now, ahead of the testnet regenesis.

The work falls into four independent tracks that happen to share a branch: node durability, state growth, ZK compliance proving, and the Trilith integration surface. They are separable on review even though they ship together.

Breaking changes

Three things here change behavior for anyone already running a node.

Genesis is incompatible. A regenesis is required. GenesisContractDeployer now deploys a ninth system contract, TrilithAnchor (type 0x0109, address 0x100B), and BasaltNameService gained storage. The genesis state root therefore differs from any chain currently running. Existing devnet and testnet data directories must be discarded, not migrated.

Chain 4242 is now treated as a public network. ChainParameters.IsPublicNetwork returns true for chain ids 1, 2, and 4242, and the node startup guards key off it rather than the old ChainId <= 2. A 4242 deployment that previously booted with development defaults will now refuse to start unless it supplies BASALT_FAUCET_KEY, BASALT_DATA_DIR, and BASALT_VALIDATOR_KEY for validators, and it will reject BASALT_DEBUG=1 outright because debug mode enables AllowAnyOrigin CORS. Private devnets such as 31337 are unaffected.

A v* tag now publishes five additional packages. nuget-publish.yml packs Basalt.Core, Codec, Crypto, Confidentiality, and Compliance alongside the existing set and pushes them to nuget.org. Nothing is published by merging. This only takes effect the next time a version tag is pushed, and it is worth knowing before that happens.

Contents

Node durability and sync correctness

63ba20e fixes a durability bug in the sync path. BlockApplier.ApplyBatch left canonical state on an in-memory overlay store whose writes never reached RocksDB, so a node that caught up via sync lost its trie nodes on restart and every subsequent consensus write also stopped persisting. The fix flushes the overlay to CF.TrieNodes after a successful batch and adopts a fresh disk-backed state at the new root. The same commit adds a never-wedge watchdog that exits the process after 15 failed fork recoveries so an orchestrator can restart into the verified startup recovery path.

6dc34c2 and a42930b close a silent-fork hole. The replay path called the single-argument ChainManager.AddBlock, which meant the state root check never ran on sync, so any execution asymmetry between a validator and a replaying node was accepted silently and the node forked off canonical undetected. The batch now recomputes the state root before advancing the chain and aborts atomically on mismatch. Diagnostics separate three cases so a benign consensus race does not read as corruption. a42930b routes pushed single blocks through the same gate, so it now covers every sync and replay path.

State growth

The state trie never pruned, which is an unbounded disk growth problem on a long-running network.

fa3dcea adds the pruner as a library in Basalt.Storage, built around a storage-aware reachability walk that descends every account's StorageRoot rather than only the world spine. It runs under a single pinned RocksDB snapshot for the mark, enumerate, and delete phases, aborts on a missing node, refuses to run with a retention window below MaxRollbackDepth, and trips a circuit breaker if a sweep would delete more than 95 percent of nodes.

7d13c3a wires it into NodeCoordinator behind EnableTriePruning, off by default. When disabled the state mutation path is a direct call, so consensus timing is identical to today. 9c26f88 exposes sweep metrics for soak observability.

This is deliberately not enabled in this PR. It has been validated on a local devnet, where two sweeps ran and trie_nodes plateaued at 30, but the multi-day soak under real transaction churn has not happened yet. The retention window keeps genesis, the tip, and the last TriePruneWindowSize blocks, with the window floored at MaxRollbackDepth so a sweep can never delete state that a rollback still needs.

ZK compliance and the prove-then-forget flagship

0c0d7c1 retires the largest risk in this track by proving that gnark-crypto's compressed BLS12-381 serialization already matches Basalt's blst-based verifier bit for bit. No codec shim is needed.

dc90222, 96a162f, b6d3302, and eb7636a build the compliance circuit incrementally, each commit binding one more public input to the credential: issuer root via in-circuit MiMC Merkle membership, expiry and issuer tier folded into the credential leaf, and revocation root via sparse Merkle tree non-membership. The starting point was a circuit that exposed these values without constraining them, which meant a prover could claim any expiry or tier and still verify. All five inputs are now bound, and each has a test that tampers with it and expects failure.

3eb0bc9 adds the matching verifier-side enforcement (COMPL-C03) for credential expiry, tier, and nullifier binding. 6ddc563 binds the identity registry and sanctions list to the governance address (COMPL-C02), which had been constructed with no governance address at all, disabling the admin guards.

51af21e separates trusted setup from proving so the verifying key is stable and registerable on chain, rather than regenerated per run.

f346083 upgrades the circuit to v2 with request binding. The v1 proof was bound to neither the recipient key nor the content id, which allowed recipient substitution (an observed proof plus an attacker's own key retrieves the wrapped key) and cid redirection (a proof for one cid unlocks another behind the same gate). Both new inputs are folded into the nullifier hash so they are genuinely constrained. This bumped Basalt.Compliance to 0.5.0-testnet. 5ac4ec5 rebinds the golden vector to a canonical content id.

Trilith integration surface

dc9d4f5 adds BNS v2 (content records pointing at trilith:// URIs, expiry, renewal, grace period, and reclaim) and the TrilithAnchor notary, a first-writer-wins registry for 32-byte digests with primitive-only view returns so downstream consumers need no Basalt types.

83a6269 exposes GET /v1/names/{label} and GET /v1/anchors/{digest}.

7498d0f pins the ContractCall signing payload and signature as a golden, so an external signer reimplementing the payload layout cannot silently drift from it.

5ab6816 moves TrilithAnchor from 0x100A to 0x100B because 0x100A collided with the DEX governance address.

Public network parameters

4eb06b6 introduces IsPublicNetwork and re-gates the startup guards. a2fff66 gives chain 4242 its own parameter set, which the public-network validation requires because it demands a DexAdminAddress and 4242 was previously falling through to the generic default and failing Validate(). fcc9cd4 fixes the one test that regression surfaced, covered below.

Also included

4404489 adds the OracleNetwork contract at type 0x0108. It predates this work and was fast-forwarded onto the branch rather than developed here.

Bugs found and fixed along the way

Three of these were found by adversarial review of a design rather than by a failing test, and are worth calling out because the tests would not have caught them.

The original trie pruning design would have corrupted state. Its reachability walk never decoded account values, so it missed every storage subtrie and would have deleted live storage nodes. Its retention window was also 128 against a MaxRollbackDepth of 1000. Both were caught before implementation, and the shipped design carries seven guards as a result.

Genesis constructs system contracts with IsDeploying = false. GenesisContractDeployer calls CreateInstance directly rather than going through ManagedContractRuntime.Deploy, so constructor initialization blocks guarded by if (Context.IsDeploying) do not run at genesis. Unit tests missed it because the test host forces the flag true. For BNS this meant the registration period was unset, so names expired immediately. Fixed for BNS by falling back to a default when the stored value is zero. Other system contracts with IsDeploying-guarded initialization are latently affected and were left alone as out of scope.

The faucet diagnostic regression. a2fff66 broke FaucetDiagnostic_BlockBuilderIncludesTx, which had been passing on main. The test builds on chain 4242 and paid GasPrice = MinGasPrice, which defaults to 1. That only worked because 4242 had no parameters of its own and inherited the devnet default, where InitialBaseFee is also 1. Real testnet parameters raise it to 100000000, so BlockBuilder correctly dropped the transaction as underpriced. MinGasPrice is a TransactionValidator floor and unrelated to the EIP-1559 base fee, so fcc9cd4 makes the test pay what FaucetEndpoint already pays, the higher of MinGasPrice and the block base fee. The production faucet path was never affected, because it has always applied that rule.

Deliberately not done

Trie pruning ships disabled. See above.

COMPL-C01 is deferred. Wiring a compliance verifier into the replay path naively introduces silent state divergence, because the nullifier window never advances on replay, the verifying key lookup reads live state rather than the replay fork, and nullifiers are not part of the fork that gets discarded on a failed batch. A verifier failure is never fatal to a block, so a mis-wired verifier fails open and diverges rather than halting, which is the worst available outcome. Doing it correctly requires all of those addressed together. The path is inert today because no policies are registered.

Verification

Full solution sweep on this branch, macOS arm64:

16 test assemblies
3038 passed, 0 failed, 5 skipped
dotnet test Basalt.sln exit code 0

The 5 skips are the RocksDB-backed pruner tests, which cannot run on arm64 because the RocksDB 8.9.1 package ships a 52-byte stub for linux-arm64 and osx-arm64 instead of a real native. Those five were validated separately in a linux/amd64 container.

Not covered by this sweep, and the reason the branch is not yet proven for production duration:

  • No multi-day soak. The trie pruner plateau has been demonstrated across two sweeps on a local devnet, not over days under transaction churn.
  • No CI run. That is what this PR is for.
  • The gnark prover in tools/basalt-prove is a Go module outside the .NET solution and is not built or tested by CI.

Suggested review order

The tracks are independent. If reviewing in pieces, the highest-consequence commits are the first two groups.

  1. 63ba20e, 6dc34c2, a42930b for the sync and durability correctness fixes.
  2. fa3dcea, 7d13c3a for the pruner, concentrating on the reachability walk and the locking.
  3. f346083 for the request-binding fix, which is the security-relevant one in the compliance track.
  4. dc9d4f5, 83a6269 for the contract and API surface.
  5. 4eb06b6, a2fff66 for the public network gating.

0xZunia added 30 commits April 2, 2026 09:30
Median-of-N aggregation with staked reporters and economic slashing.

- Feed management: create, pause, update parameters, query fees
- Reporter staking: register/unregister with min 10k BST collateral
- Round lifecycle: open → submit (×N) → finalize with on-chain median
- Deviation slashing: reporters outside threshold lose % of stake
- Fee distribution: slashed funds + query fees split among honest reporters
- 56 tests covering full lifecycle, median math, slashing, fees, edge cases
- Registered as type 0x0108 in ContractRegistry
…chdog

The sync path (BlockApplier.ApplyBatch runs fork, execute, swap) left canonical
state on an in-memory OverlayTrieNodeStore whose Put never reached RocksDB, so a
node that caught up via sync lost its trie nodes on restart and recovery FATALed
on a state-root mismatch. Adds OverlayTrieNodeStore.FlushTo,
TrieStateDb.FlushOverlayTo, and a syncStateCommit closure that persists the
overlay to CF.TrieNodes and adopts a fresh disk-backed FlatStateDb at the new
root, wired into both the RPC (Program.cs) and validator (NodeCoordinator)
catch-up paths. Verified on a devnet where rpc-0 was hard-killed at block 52,
recovered from persistent storage, and resumed to block 104.

Also adds a BlockSyncService never-wedge guard: 15 failed fork recoveries
trigger LogCritical plus Environment.Exit(70) so the orchestrator restarts the
node into verified startup recovery instead of stalling silently. Adds
deploy/devnet-soak.sh, a 6-scenario chaos matrix (steady, restart-one, rolling
restart, partition and reconnect, kill-9, rpc probe) that reports SOAK RESULT
PASS or FAIL and exits non-zero on failure.
The state trie is append-only copy-on-write and never overwrites a node, so
every historical version accumulates in RocksDB CF.trie_nodes forever and a
long-running node eventually fills its disk. This adds a mark-and-sweep pruner
with the seven safety guards an adversarial review required, because a wrong
sweep corrupts state irrecoverably.

- TrieReachability: storage-aware reachable-union mark. Walks the world spine
  and descends into every account's storage sub-trie (StorageRoot lives at
  account value bytes 40 to 72). Throws on a missing node so a corrupt store is
  never swept. A world-only walk misses storage nodes, which a negative-control
  test pins.
- RocksDbTriePruner: marks and enumerates over one pinned RocksDB snapshot,
  refuses to delete more than 95 percent of nodes (tripwire), deletes in bounded
  batches, and compacts separately outside any lock.
- TrieRetention: rebuilds the retain set from the block store on every sweep
  (genesis, the last windowSize blocks with windowSize at least MaxRollbackDepth,
  and the tip), so it survives a restart with no separate checkpoint file.
- RocksDbStore: adds CreateSnapshotView (one snapshot backing both Get and
  Iterate) and CompactColumnFamily.

Tests: 5 reachability, 7 retention (pure), 5 against real RocksDB. The RocksDB
tests use a RocksDbFact that skips visibly where the native library is a stub
(the 8.9.1 package ships real binaries only for x64) and pass in a linux-x64
container. docs/testnet-state-policy.md documents the retention policy and the
reset escape hatch.

Not yet wired into NodeCoordinator. That step is consensus-critical and lands
together with the disk-flattening soak.
… default)

Runs the mark-and-sweep pruner as a background loop that reclaims stale trie
nodes once the tip advances a full interval past the last sweep. Off by default
(EnableTriePruning defaults false) so it cannot affect consensus until the
disk-flattening soak validates it. Enable at startup with
BASALT_ENABLE_TRIE_PRUNING=1 (optional BASALT_TRIE_PRUNE_WINDOW and _INTERVAL).

Guard 6 (serialize the sweep against state mutation) is a single lock,
_stateMutationLock, taken by the sweep and by every state-mutating path (normal
finalize apply, single sync apply, batch sync apply, fork rollback). This is
deadlock-free by construction: the sweep never acquires lock(this), so no cycle
exists with the existing lock(this) sections. When pruning is disabled the lock
is never contended, so the default configuration keeps identical consensus
timing (a direct call, no lock).

- ChainParameters: EnableTriePruning, TriePruneWindowSize (at least the rollback
  depth 1000), TriePruneIntervalBlocks, plus Validate checks. Converted to a
  record so startup can derive a pruning-enabled variant with `with`.
- NodeCoordinator: RunTriePruneLoop (poll, gate on window and interval, sweep
  under the lock, compact outside it, log stats, never crash on abort or
  tripwire), RunStateMutation wrapping the four mutation sites, and pruner
  creation gated on disk-backed stores.
- Program.cs: environment toggle for the soak.

Build 0/0 warnaserror. Consensus 221, Node 96, Storage 247 (plus 5 RocksDB
skipped on this host), Execution 566, all green with pruning off. The
enabled-path soak (sweep fires and trie_nodes plateaus under load) runs on the
testnet VPS.
Adds five Prometheus series recorded after each sweep, so the disk-flattening
soak can be watched rather than inferred from du. basalt_trie_nodes_scanned is
the node count at the last sweep, the series that must plateau to confirm
pruning bounds disk growth. Also basalt_trie_prune_sweeps_total,
_deleted_total, basalt_trie_nodes_retained, and basalt_trie_prune_last_height.
Recorded from RunTriePruneLoop via primitives, so the metrics endpoint keeps no
dependency on the storage layer.

Api tests 52 green, Node builds 0/0 warnaserror.
…t (flagship de-risk)

The plan's #1 flagship risk was that gnark-crypto's compressed BLS12-381
serialization (big-endian, ZCash flag bits, G2 as A1 then A0) might not match
what Basalt's blst-backed verifier expects, which would silently break every
prove-then-forget proof. Every existing Groth16 vector is blst-native and
round-tripped inside Basalt, so this had never been checked across the two
libraries.

tools/basalt-prove (Go, gnark v0.15) generates a real Groth16 VK and proof for
a trivial circuit (knowledge of X with X*X == Y, Y=9 public), serialized in the
Groth16Codec layout using gnark's compressed point bytes. GnarkCrossEncodingTests
decodes those bytes and asserts Basalt's blst verifier accepts them.

Result: it verifies. The two encodings already match bit-for-bit, so no codec
shim is needed. Negatives (wrong public input, swapped A/C) correctly fail, so
the pass is real. Confidentiality suite 243 green.
…nce (COMPL-C02)

The validator wiring constructed both the IdentityRegistry and the SanctionsList
with their parameterless constructors, which leaves the governance guard address
null and thereby disables admin access control: any or null caller could approve
KYC providers or edit the sanctions list. Binds both to the Governance
system-contract address (0x1003) so only Governance can perform those admin
actions. No consensus path calls those admin methods today, so there is no
runtime behavior change, this is defense-in-depth for when a governance handler
is wired. Adds SanctionsList governance guard tests (IdentityRegistry already had
them). Compliance suite 83 green.

Also documents why COMPL-C01 (compliance on the RPC and standalone replay paths)
is NOT simple wiring and is deferred: a design review of the real execution
paths found that naively wiring the verifier into the replay path introduces
silent state divergence (no state-root gate on replay, nullifier window never
advanced on replay, VK lookup bound to live rather than fork state). See
docs/compliance-replay-divergence.md for the prerequisites a correct COMPL-C01
requires.
…golden proof

Builds on the proven gnark<->blst encoding bridge to fix the compliance
public-input layout that the on-chain verifier will read (COMPL-C03 and the full
circuit both depend on this being frozen). CircuitV1Layout pins the five indices
issuerRoot, nullifier, expiry, tier, revocationRoot plus a ReadUInt64 helper for
the expiry and tier fields. tools/basalt-prove gains a `compliance` mode that
emits a real 5-public-input Groth16 proof (Nullifier == MiMC(Secret, Epoch)),
and the golden test proves Basalt's blst verifier accepts it, that gnark emits
the inputs in the frozen order, and that tampering the nullifier (the one bound
input) is rejected.

Records a security caveat as an explicit test: circuit v1 exposes but does not
yet constrain expiry/tier/roots, so their IC points are the identity and
changing them does not move vk_x. Verifier-side expiry/tier enforcement is
therefore necessary-but-not-sufficient, the full circuit must bind these inputs
to the credential. Compliance suite 87 green, go build and vet clean.
…rk gate)

The sync and replay path (BlockApplier.ApplyBatch) called the single-arg
ChainManager.AddBlock, so the state-root check (which only fires when a computed
root is supplied) never ran. Any execution asymmetry between a validator and a
replaying node produced a different state root that was silently accepted,
forking the node off canonical with no error. A design review also found the old
path could advance the chain index for an executed prefix while skipping the
state swap, splitting chain height from state.

Adds a Phase 1.5 gate: after executing the batch on the fork and BEFORE advancing
the chain, recompute the state root and compare it to the batch's last block
header. On mismatch, abort atomically (no chain advance, no swap), leaving the
node at its previous canonical state. Reuses the verified root for the H4
syncStateCommit so there is no double computation.

Diagnostics distinguish three cases so operators are not misled: an execution
failure mid-batch (Warning, retry), a benign consensus and sync race where the
tip advanced into the batch (Info, retry with a forward batch), and a genuine
divergence (Critical).

Adversarially reviewed (consistency and H4 interaction refuted as sound).
Validated on a devnet: rpc-0 synced through faucet-transaction blocks with 0
divergence, 0 false race rejections, and correct recipient balances. Node 96 and
Integration 27 green.
…lete silent-fork fix)

HandleBlockPayload applied peer-pushed sync blocks via the single-arg ApplyBlock,
which executes directly onto canonical state and never checks the recomputed
state root. This is the same silent-fork hole the batch sync path had, and worse
here because ApplyBlock cannot roll back a divergent execution. Routes the push
path through ApplyBatch (fork, execute, state-root gate, swap) so a divergent
pushed block is rejected atomically instead of forking the node off canonical.

ApplyBatch delivers the same important side effects for the applied block (WS
broadcast, metrics, mempool prune, epoch transitions). The two it omits
(RecordDexIntentCount, ProcessUnbonding) are already omitted by the existing
batch sync path, so this is consistent, not a regression.

Completes the state-root gate across all replay and sync paths. Node 96,
Integration 27, Consensus 221 green.
…ip (circuit 3B.7 increment)

Circuit v1 exposed issuerRoot as an unconstrained public input, so a prover could
claim any issuer. This adds ComplianceCircuitMembership: the identity commitment
MiMC(secret) must be a member of the issuer Merkle tree whose root is the public
IssuerRoot, proven by a private authentication path with in-circuit MiMC node
hashing. IssuerRoot is now bound to the credential. Keeps the frozen public-input
layout and the nullifier binding.

tools/basalt-prove gains a `membership` mode: it builds the depth-4 issuer tree
off-circuit with gnark-crypto fr/mimc (matching the in-circuit std/hash/mimc, so
the recomputed root matches) and emits the golden vector. The golden test proves
Basalt's blst verifier accepts the real proof and, crucially, that tampering
IssuerRoot now fails verification (it passed in v1). Nullifier tampering also
fails. Compliance suite 90 green, go vet clean.

Next in 3B.7: bind expiry and tier to the credential, and add revocation.
…uit 3B.7)

Extends the credential commitment to leaf = MiMC(secret, expiry, tier) so the
Merkle membership proof binds expiry and tier as well as issuerRoot. A prover can
no longer claim a later expiry or a higher issuer tier than the issuer attested:
tampering either now fails verification (the golden test proves both, where
circuit v1 let them pass). This resolves the v1 unconstrained-inputs caveat and
makes the on-chain expiry/tier enforcement (COMPL-C03) meaningful.

Off-circuit adds mimcHash3 to match the in-circuit MiMC(secret, expiry, tier)
leaf. gnark's own verify passed at generation, confirming the hash still matches
across the whole tree. Compliance suite 92 green, go build and vet clean.

Next: revocation (non-membership in revocationRoot), then wire COMPL-C03.
…ing (COMPL-C03)

VerifySingleProof previously verified the Groth16 proof but ignored the block
timestamp and issuer tier (the stale MED-03 note). Now, for the standardized v1
public-input layout, after the pairing check it enforces:
- expiry (public input, unix seconds) is not before the block timestamp (ms/1000),
- issuer tier is at least the schema MinIssuerTier,
- the nullifier public input equals the tracked ComplianceProof.Nullifier, so a
  prover cannot carry a fresh nullifier while the circuit reuses an old one.

These checks are meaningful because the membership circuit binds expiry, tier,
and issuerRoot into the credential. Proofs whose input count is not the v1 layout
are verified by the pairing alone, so existing schemas are unaffected
(layout-gated).

Tests exercise the real membership proof: valid credential accepted, expired
rejected, tier-too-low rejected, nullifier mismatch rejected. The golden
membership vector is shared via MembershipVector. Compliance suite 96 green.
… 3B.7 complete)

Completes the compliance circuit: the credential's revocation slot (low 32 bits
of MiMC(secret)) must have an empty leaf in the revocation sparse Merkle tree
whose root is the public revocationRoot. If the credential were revoked, that
leaf would be non-zero and the empty leaf would not hash to revocationRoot, so a
prover cannot hide a revocation by substituting a different root (the tamper test
proves it). All five public inputs are now bound: issuerRoot, expiry, tier,
nullifier, and revocationRoot.

This first cut proves non-membership against an empty revocation tree (nothing
revoked). The off-circuit revocationDefaults precompute the empty-subtree hash
per level. A populated-tree scenario (proving unrevoked while others are revoked)
is a follow-up, and the 32-bit revocation keyspace is documented as testnet-scale.
Compliance suite 97 green.
… prove modes)

Turns basalt-prove from a golden-vector generator into a usable prover. `setup`
runs the trusted setup once, writes a fixed membership.pk and membership.vk, and
prints the Basalt-layout vk_hex to register on-chain (SchemaRegistry). `prove`
reads a credential (secret, epoch, expiry, tier, leafIndex, and the issuer's
published leaves) on stdin, loads the fixed pk, produces a proof, self-verifies
it against the fixed vk, and prints the pieces a Basalt ComplianceProof carries
(proof, concatenated public inputs, nullifier). Omitting leaves synthesizes a
demo issuer tree.

This fixes the architectural gap where every run did a fresh Setup, giving a
different vk each time that could not be registered. The setup/prove round-trip
verifies, and the output uses the same encoding the existing membership and
cross-encoding tests prove Basalt's blst verifier accepts. Adds a README for the
flow. membership.pk and membership.vk are large and git-ignored.
…n-free (Phase 3D)

Prepares the libraries an external verifier (Trilith.KeyGate) consumes to be
published as NuGet packages. Removes the vestigial Basalt.Execution reference
from Basalt.Compliance (the using was unused and no Execution type is
referenced), so the package closure is Core, Codec, Crypto, Confidentiality,
Compliance with no execution or VM dependency. Marks those five as IsPackable
with descriptions. Directory.Build.props sets a shared version (0.4.0-testnet),
Apache-2.0 license, and repository URL, and defaults IsPackable=false so the
node, tools, and tests stay unpackable.

dotnet pack produces all five, and the Basalt.Compliance package depends only on
Basalt.Confidentiality, Core, and Crypto (0 Execution deps, verified in the
nuspec). Extends nuget-publish.yml to pack them alongside the existing SDK
packages, so they publish to NuGet.org on the next v* release tag. Full solution
builds clean, compliance suite 97 green.
…roof)

The compliance membership circuit now binds each proof to a specific request:
two public inputs, cidField and recipientKeyHash, are folded into the nullifier
(nullifier = MiMC(secret, cidField, recipientKeyHash)). A captured proof can no
longer be redirected to a different content id or a different recipient key,
because either change alters the proven nullifier and the pairing check rejects
it. This closes the recipient-substitution and cid-redirection gaps found in the
KeyGate review.

Public-input layout grows from 5 to 7. CircuitV1Layout (5 inputs, encoding and
the unconstrained-input caveat) is untouched; CircuitV2Layout adds CidField (5)
and RecipientKeyHash (6). The first five indices are byte-for-byte identical, so
COMPL-C03 enforcement (expiry, tier, nullifier) is unchanged and simply keys off
the v2 input count. cidField and recipientKeyHash are SHA256(domain || input)
reduced mod r, recomputed identically by the consumer that knows their semantics
(Trilith.KeyGate), which compares them to the request before verifying.

basalt-prove: ComplianceCircuitMembership drops the unused epoch, adds the two
request inputs, and the membership and prove modes derive cidField from a cid and
recipientKeyHash from an X25519 public key. The golden membership vector is
regenerated (7 inputs) and gains tamper tests for both new bindings.

Package version 0.4.0 to 0.5.0 (compliance ABI changed).
Regenerate the v2 membership golden against a realistic slash-free canonical CID
instead of a trilith:// URL, so consumers can carry the cid in a URL path
(POST /reveal/{cid}) without escaping ambiguity. Library code is unchanged: only
the test golden and the basalt-prove golden constant move.
BNS v2 (type 0x0101, unchanged arity) gains the pieces Trilith names need. A
registration now carries an expiry: names last a default term, then enter a
30-day grace in which only a renewal saves them, after which anyone may take the
name. SetContentRecord/ClearContentRecord bind a label to a trilith:// content
name (scheme-checked, length-bounded); ResolveContent and ExpiryOf read them.
Renew (payable, callable by anyone) extends from the later of now or the current
expiry so an early renewal loses no time; Reclaim garbage-collects a name past
its grace. Resolve and OwnerOf report a past-grace name as unregistered, and
re-registering an expired name clears the previous holder's content record. All
time arithmetic divides the millisecond block timestamp by 1000 to work in
seconds.

TrilithAnchor (new, type 0x0109, deployed at system address 0x100A) is a
tamper-evident notary for 32-byte Trilith digests: a CID root, a crypto-shredding
destruction-set root, a blocklist root, or a key-log root. It records who
anchored a digest, at what block and time, and of what kind, without ever holding
the content or any personal data. First-writer-wins: a re-anchor is an idempotent
no-op returning the original timestamp, so a record cannot be overwritten or
back-dated. This is the on-chain half of the prove-then-forget destruction
certificate.

Genesis now deploys nine system contracts (was eight). Views return primitive
values only, so the adapter side stays free of any Basalt type.
Add GET /v1/names/{label} and GET /v1/anchors/{digest}: plain JSON over the two
Trilith system contracts, so the (Basalt-free) Trilith.Naming.Basalt adapter can
read names and anchors with nothing but HTTP + JSON, never encoding a contract
call itself. The encode/call/decode lives in SystemContractReader, which runs
read-only view calls against a fork of the state and decodes returns with the
same wire format the source-generated dispatcher emits (FNV-1a selector,
length-prefixed args, typed returns).

An integration test deploys the system contracts at genesis, writes real state
through the runtime (register a name, set a content record, anchor a digest), and
reads it back through the reader, exercising the full encode/call/decode path.

That test caught a genesis bug: the deploy path constructs system contracts with
Context.IsDeploying == false, so BNS's constructor init block does not run at
genesis and the registration period was 0 there, making every name expire
immediately. BNS now falls back to the default term when the stored period is
unset, so a genesis-deployed name service works. The unit tests missed this
because the test host forces IsDeploying == true.
Fixed-input golden for a ContractCall transaction's signing payload and Ed25519
signature. Trilith.AnchorCli reimplements this signing over the light Basalt
packages (Core/Codec/Crypto) without referencing Basalt.Execution, and
cross-checks against the same golden bytes, so the two implementations cannot
drift apart silently if the transaction format changes.
…00B)

TrilithAnchor was deployed at system address 0x100A, which is already the DEX
governance/admin address (ChainParameters.MakeDexGovernanceAddress). 0x1009 is
the DEX state account and 0x100A is DEX governance, so the next free system
address is 0x100B. Move TrilithAnchor there. The signing golden's fixed To
address follows.
The startup guards (no debug CORS, required faucet key, required data dir and
validator key) and the DEX-admin validation keyed off ChainId <= 2, so a public
testnet on chain id 4242 slipped through with devnet-relaxed rules. Add
ChainParameters.IsPublicNetwork (true for 1, 2, and 4242) and gate the guards on
it, so the incentivized testnet gets the same protection as mainnet and the
built-in testnet. Private devnets (e.g. 31337) stay exempt.
IsPublicNetwork now covers chain 4242, and the public-network validation
requires a DexAdminAddress, so FromConfiguration(4242) fell through to the
generic default and failed Validate(). Give 4242 its own testnet-scale
parameter set (2s blocks, 32 validators, 500-block epochs, 12h unbonding)
with the DEX governance address wired in, matching the built-in testnet
shape rather than the devnet one.
The diagnostic builds on chain 4242 and paid GasPrice = MinGasPrice, which
defaults to 1. That only ever passed because 4242 had no parameter set of its
own and fell through to the devnet default, where InitialBaseFee is also 1.
Giving 4242 real testnet parameters raised its InitialBaseFee to 100000000, so
BlockBuilder correctly dropped the transaction as underpriced and the block
came back empty.

MinGasPrice is a TransactionValidator floor and has nothing to do with the
EIP-1559 base fee that BlockBuilder enforces, so the test now pays what
FaucetEndpoint already pays, the higher of MinGasPrice and the block base fee.
The production faucet path was never affected.
A prune sweep that hits a node referenced by a retained root reported the same
message whichever fault caused it, and the two need opposite fixes. Either the
node exists and the pinned snapshot cannot see it, which is a visibility
problem, or it is gone from the live store too, which means it was deleted or
never written.

The mark now throws a typed MissingTrieNodeException carrying the hash, and the
pruner catches it, probes the live store, and rethrows with the verdict stated.
The probe swallows its own errors so it can never mask the original fault.

No behaviour change: the sweep still aborts and still deletes nothing.
Knowing a node is gone does not say whose state needs it, and that is the
question that matters: the tip failing means the live chain is broken, an older
root failing means some historical state was never persisted while the tip is
fine. The walk now carries the originating root down the stack and reports it.

Carrying it costs one extra field on the work stack and nothing on the happy
path, since it is only read when the walk fails.
A block applied through consensus never had its state root computed. The header
carries the root the proposer computed, and ApplyBlock called the single-argument
AddBlock, which does not check it, so the applying node executed the block and
moved on without ever asking what state it had produced.

That is not only a missing check. ComputeStateRoot is what flushes pending
storage-trie changes back into account states and writes the resulting nodes,
the root among them. Skipping it meant the applying node never created a node
for that block's state root at all. Tip state survived because recovery computes
a root on startup, but historical roots inside the retention window did not, so
they were unreachable.

Trie pruning is what finally noticed. Its retention set assumes every state root
in the window is walkable, and its abort-on-missing-node guard fired and refused
to delete anything, which is exactly right: the guard turned a silent durability
hole into a loud, harmless stall rather than letting a sweep delete live state.
It also explains why the reported hash kept changing, since several roots were
missing and the walk reports whichever it reaches first.

The computed root is now compared against the header and a divergence is logged
at Critical and counted in basalt_state_root_divergences_total, but the block is
still applied. This path runs only after BFT agreement, so rejecting here would
halt block production on a divergence that has never actually been observed.
Measure first, and let the counter decide whether this should become fatal.

Cost is bounded: TrieStateDb caches the root and returns it directly when
nothing has been written since the last computation.
Only a consensus node builds the pruner and runs the sweep loop, but the env
handler logged "Trie pruning enabled" on any node that set the variable. An RPC
or standalone node therefore reported pruning as on while nothing swept, which
is the dangerous direction to be wrong in, since the operator plans disk on the
assumption that something is reclaiming it.

Verified on a live archive node: it logged the env line, created no pruner, and
sat at basalt_trie_prune_sweeps_total 0 with no errors for ten minutes.

A consensus node logs as before. Anything else now warns that nothing will prune
and that trie_nodes will grow without bound.

This makes the limitation visible, it does not lift it. Pruning on archive nodes
needs its own sweep loop serialised against the sync apply path, which has no
equivalent of the coordinator's state mutation lock.
The abort test matched on the old exception message and broke when the pruner
started reporting which store the node was missing from. Rather than loosen the
match, assert the richer contract: the diagnosis type, the node hash, the
retained root the walk was following, and that the node was absent from the live
store, which is the case this test actually constructs.

Found only by CI. This is one of the five RocksDB-backed tests that skip on
arm64, so the local sweep passed while the branch was red, for the second time
today. Verified by running the suite on real RocksDB on a linux x64 host:
252 passed, 0 skipped.
0xZunia and others added 30 commits July 26, 2026 13:45
AttestClaim and ClaimReserved accepted any 20 bytes. Assigning a name deletes
its reservation, so a name handed to an address nobody holds is gone for good,
and a placeholder left in a DNS template was enough to burn google.bslt forever.

Both paths now go through one guard. The attester skips such a record too, so an
unedited template costs a domain owner a delay rather than a transaction the
chain would refuse anyway.
Reports whether a domain's DNS says who should receive the matching .bslt name.
It decides nothing: it reads one TXT record per reserved label and says what it
saw, and the contract hands the name over once enough independent attesters agree.

Resolution goes to the domain's own authoritative nameservers rather than a
shared recursive resolver. If every attester asks the same public resolver, one
poisoned cache produces several agreeing wrong answers and the threshold protects
nothing while appearing to.

There is no request step. A claimant publishes and waits, and the attester sweeps
the whole list, which removes a public endpoint anyone could spam.

Call data goes through the SDK encoder rather than a local copy of the layout,
which is why the tool lives here. The tests pin down the method name and argument
order, since getting either wrong compiles, signs, submits, and never counts.

deploy/names/fetch-reserved-list.sh builds the list from Cloudflare Radar and
records the ranking date, so anyone can re-run it and get the same list. It needs
a token with Radar Read, which is not yet available.
A soak on the VPS showed disk growing about 14KB per block and never falling,
on both networks, while pruning reported hundreds of thousands of deleted nodes.
The breakdown explains it: the devnet held 713MB of write-ahead log against 45MB
of real data, across 229 files, the oldest 27 hours old.

RocksDB cannot delete a WAL file while any column family still holds data written
in it. Only trie_nodes, blocks and receipts had ever flushed. Metadata,
block_index, state, staking and default take a few bytes per block each, never
fill a 16MB buffer, never flush, and so pin every WAL file the node has written.

max_total_wal_size is the mechanism for exactly this: past the ceiling RocksDB
flushes whichever column families hold the oldest files so they can be dropped.

Verified on x86_64 where the native library actually loads, with a control: the
test fails without the change and passes with it. It skips on arm64 like the rest
of the RocksDB suite, so a green run on a dev laptop says nothing here.
Contracts declare events with fields and emit them with values, and the bridge
throws the object away: every log reaches a receipt as the type name, no fields,
no topics. A transfer says a transfer happened and not who received what.

Nothing caught it because the SDK test host records the object itself, so
assertions on event fields pass while the receipt carries none of them.

The test is skipped rather than left red. Fixing it means the generator emitting
an encoder per event type, 82 of them, and log data feeds logsHash which feeds
the receipts root, so the encoding is consensus and the change is a decision
rather than a patch.
Every log reached a receipt as its type name, no fields, no topics. A transfer
recorded that a transfer had happened without saying who received what, and the
explorer, any indexer, and the evidence trail behind a name claim all read that
same empty record.

The runtime had no way to read the event object. It arrives as object, and
reflection is closed off by the trim and AOT analysers, so the bridge logged the
one thing it could reach.

So the event now encodes itself. A generator keyed on [BasaltEvent] writes
ToLogData and ToLogTopics from the properties it already reads for the ABI, 81
types, no encoder written by hand. Data carries every field in declaration order.
Topics carry one hash per [Indexed] field, which makes filtering work without
decoding and behaves the same for a long string as for an address.

Context.Emit takes IBasaltEvent rather than class, which is the part that matters
beyond this fix: an event that cannot encode itself is now a compile error at the
line that emits it, not an empty log nobody notices. It found one immediately.
SubdomainDelegatedEvent had never been marked [BasaltEvent], so it was missing
from the ABI too, and nothing had said so.

Generating from the event declaration rather than from the contracts that emit it
means a type shared by several contracts is written once.

This changes log encoding, which feeds logsHash, which feeds the receipts root.
Chains carrying the old encoding cannot follow.
Every contract test called contracts as C# objects, through the SDK test host or
the runtime directly. Nothing drove one the way a node does, with a signed
transaction through TransactionExecutor, which is why three defects in that path
survived a green suite.

Three cases now do. A token deploys and transfers, with the balance read back out
of committed state rather than taken from the receipt. A receipt carries the
event fields. A sanctions policy refuses a transfer to a sanctioned account,
reached by a cross-contract call from the token, with an unsanctioned recipient
going through first so the refusal is the policy deciding rather than the call
failing for everyone alike.

That last one is the compliance feature the chain is built around, and until
today it could not run at all.
Transfer existed and moved ownership, the address record and the reverse record.
Two things it did not do.

It accepted the zero address, like AttestClaim and ClaimReserved did before. The
same permanence applies: assignment is the whole record, so a name sent to an
address nobody holds is gone, and a placeholder should not cost it.

And subdomain delegations survived the sale. They are keyed by name, so a seller
kept publishing under blog.acme after handing over acme, and the buyer had no way
to enumerate them to revoke them. Delegations are now scoped to an epoch per
label, which a transfer bumps, retiring all of them in one write. The buyer can
re-delegate the same names immediately.

Found because a name registry with no transfer at all would be one where a
company that proves control of a domain can never move it to the treasury that
should hold it. It turned out to be there, and incomplete.
The testnet showed every node computing one state root while the proposer's
header claimed another. The nodes agreed with each other and disagreed with the
header, which is what two code paths diverging looks like rather than one node
being broken.

These two tests hold the paths against each other. A block is proposed through
BuildBlock, then applied to an identical starting state the way any other node
reaches its own view, and the roots are compared. The second adds the step the
applier performs and the builder does not, ApplyDexSettlement, since running
something on every applied block that never ran while proposing is a divergence
waiting for the first thing it touches.

Both pass, so a plain transfer is not the case that breaks and settlement is not
the cause. What the running chain has and these do not: genesis contracts, a
staking state, a compliance verifier on the executor, and chain 4242's
parameters. Recorded here so the next attempt starts from what has been ruled
out rather than from the beginning.
They ran against InMemoryStateDb, whose root covers account records only. Its own
remarks say so: SetStorage never touches StorageRoot, so contract storage is
invisible to it. Tests written against it cannot see a divergence that lives in
contract storage, which is where this one lives, so their passing meant nothing.

They now use TrieStateDb over an in-memory node store, which folds storage roots
into accounts the way a node does. A third case deploys a contract, so a block
that writes contract storage is covered rather than transfers alone.

All three still pass, and that is worth stating plainly rather than reading as
absolution: each starts from a fresh state, so both sides begin with an identical
and empty storage-trie cache. Reproducing what the testnet shows needs a state
carried across several blocks, which a single-block test cannot produce.
Four more candidates for the testnet divergence, each closed by a test rather
than by reasoning, after three earlier guesses turned out wrong.

Over blocks, on RocksDB: a database asked for its root every block reaches the
same root as one asked only at the end, and serving a read between blocks does
not change it either. That closes the storage-trie cache, which looked like the
answer because ComputeStateRoot folds only the tries it currently holds and
releases them afterwards.

Across the wire: a block serialised and decoded the way the network does still
reaches the state its header claims, and a transaction survives the round trip
field for field. The codec was worth checking because the proposer is the one
node that never decodes its own block, so it is the only step absent from the
path that produces the number every other path is checked against.

None of them reproduce it. Recorded so the next attempt starts from a smaller
space, and because these are invariants worth holding regardless.
Computing the state root is also what folds each contract's storage root back
into its account record, and TrieStateDb writes those records into the world trie
itself. FlatStateDb wraps it with an account cache and never heard about it, so
every read after a root computation handed back an account carrying the storage
root from before the last block. The storage trie rebuilt from it was a block
behind, and the contract read and wrote the wrong slots.

That is the testnet divergence. A proposer forks its live state, builds, and
publishes the root the fork reached; the block is then applied to the live state,
and the two caches were stale in different places, so the two roots differed. It
only showed on blocks carrying a contract call, because a transfer never moves a
storage root, and every node logged it while accepting the block anyway.

The touched contracts are dropped from the cache once the fold has happened, so
the next read reloads what the trie actually holds.

The test that pins it is the shape a node runs and nothing before it was: a live
FlatStateDb over a trie, genesis contracts deployed, a fork per block, built
through BuildBlockWithDex, applied to the live state. It fails without this
change at the first block that writes contract storage.

Still to do: consensus accepts a block whose header state root does not match
what the node computed. It logs and continues. That has to be enforced, and could
not be before this, since enforcing it would have halted the chain.
The check logged and applied the block anyway, on the reasoning that the block
was already BFT-agreed and that refusing would halt production over a divergence
never observed. The comment said to let the evidence decide. It has: the
divergence appeared on every block carrying a contract call, and the cause was a
stale account cache rather than anything about the blocks.

With that fixed the roots agree, so this can bite without halting anything. A
node that reaches a different state than the header claims cannot check anyone's
work, and one that continues regardless turns the state root into decoration
rather than a commitment. Halting is the intended outcome.

The sync path has always refused. Both paths now answer the same anomaly the
same way, which is what let the chain look healthy while its RPC node was stuck.
Second of the five prerequisites. The retention window was pruned once per block
from exactly one place, the consensus callback, so replay never advanced it. A
node replaying with a verifier would grow the nullifier set without bound and
never apply its window, and a nullifier the proposer had legitimately forgotten
would read as a duplicate. A transaction that finalized as success would replay
as failure, which is a state divergence wearing a compliance decision as a
disguise.

BlockApplier now takes the verifier and resets before each block's transactions,
matching the order finalization uses. This also fixes it for a validator's own
catch-up sync, which shares the replay path.

The compliance engine moved above the mode switch. It was built inside the
validator case, so an RPC or standalone node had none at all and could not have
advanced anything.

The verifier is still not wired into the RPC or standalone executor. That is the
last step and stays unsafe until points 3, 4 and 5 are done: the VK lookup still
resolves against the live canonical reference rather than the state being
executed, and nullifier consumption is still not transactional with the fork.
An RPC or standalone node built its executor without a verifier, so it and a
validator disagreed about which transactions succeed. Harmless only while nothing
registered a proof requirement, and the reason this was deferred rather than
wired: doing it naively introduces a silent state divergence.

The three remaining prerequisites, with the first two done earlier:

Key lookups now resolve against the state being executed. ExecutionStateRef holds
which one that is, canonical by default and the sync fork while a batch replays.
The closure read the live canonical reference, so a key registered by an earlier
block in the same batch was invisible to a later one, and compliance failed on a
transaction that had finalized as success.

Consumed nullifiers now roll back with a refused batch, on both paths: the
execution failure and the state-root gate. They live on the verifier rather than
in the fork, so a discarded batch used to keep them and every retry replayed as a
duplicate, which is a batch that can never succeed again.

Replay is the last one and comes for free: an RPC node syncs from genesis on
every start, so standing one up against the running chain with the verifier wired
is exactly the replay of existing history this asked for.
Point 5 asked for existing chain data to be replayed before enabling, to confirm
no historical block would now fail. It was run and it failed: an RPC node with an
empty data directory refuses its first batch, ending #100, on a state-root
divergence, and never advances past genesis.

A negative control on the commit before any of this work reproduces it exactly,
the same expected and computed roots on the same block. So it predates COMPL-C01
and is not caused by it.

It had never been seen because a node that has followed since genesis applies
blocks one at a time. Replaying history uses the batch path instead, and nothing
had exercised that from an empty state until this prerequisite asked for it.

Which is what the prerequisite was for. The wiring stays, since points 1 to 4
hold and the checks are correct, but the note now says plainly that the gate has
not passed and what has to be resolved before this is relied on.
Replaying history executes a batch of blocks on one fork and computes the root
once at the end. Following the chain live computes after each block. Computing
the root is also what folds each contract's storage root into its account, so the
two paths folded at different moments and landed on different states from the
same blocks.

The consequence was that a node with an empty data directory refused its first
batch and could never join the chain. It stayed invisible because every node that
had followed since genesis used the per-block path, and nothing replayed from
nothing until the COMPL-C01 replay gate asked for exactly that.

ExecuteBlock now folds before moving on, so the two paths agree by construction
rather than by coincidence. The test builds one history and replays it both ways;
it fails without this and passes with it.
The replay gate passes now that the batch path folds per block. An RPC node
replays the chain from an empty volume with no divergence and catches up live,
which is the check point 5 asked for.

All five prerequisites hold, so the note reads as done rather than as a warning.
A sender had to wait for one transaction to be mined before offering the next,
so anything doing two things in a row paid a block of latency per action. The
mempool was already able to handle it: it groups by sender, sorts by nonce and
serves only the contiguous run from the on-chain nonce, stopping at the first
gap. Admission refused what the queue was built to hold.

Validation now takes whether a future nonce is acceptable. True at admission,
false when building a block, where only the exact next nonce can go in or the
block is invalid. A spent nonce is still refused outright, since it can never
become valid again.

Bounded at 64 ahead, because a queue nobody can fill is a queue anyone can flood.
The mempool held them and the REST endpoint refused them, so nothing changed for
anyone actually sending a transaction. Submission is the admission point, and it
validated with the strict overload that block building needs.

Found by sending five registrations back to back against the deployed testnet:
the first landed, the other four came back "Expected nonce 0, got 1" and so on.
The unit tests passed throughout, because they exercised the mempool directly and
nothing covered the path a client uses.
On the testnet, four registrations landed in one block, all four reported
success, and only the last one left a trace. The first, alone in the previous
block, survived. Both nodes agree the other three are simply not in the state, so
this is not a read-path problem: three transactions reported success and wrote
nothing.

The test executes four contract calls against one block header and checks each
one is still readable afterwards. It passes, so applying them in sequence is not
where they are lost, and the loss is upstream of that in the path that produced
the block.

Kept because the invariant is worth holding regardless, and because it narrows
where to look next: the proposer executes on a fork of a fork, one per contract
call, and merges each back.
Forking the inner trie computes its root, and that fold rewrites each contract's
account record with its current storage root. FlatStateDb.Fork copied its account
cache regardless, so the fork carried storage roots from before the fold and
rebuilt a contract's storage trie a transaction behind. This is the same defect
already found and fixed in ComputeStateRoot, in its sibling method.

Stated plainly: this is a fix by symmetry, not a verified one. The test added
here builds four registrations into one block through the proposer path and
checks all four are still readable, which is what the testnet broke. It passes
with the change and also without it, so it does not discriminate and the chain
behaviour is not proven fixed by it.

What the reproduction still lacks against the running chain is history: a live
state that has carried a thousand blocks has a populated account cache, and a
freshly built one does not. The real control is the chain itself, four
registrations in one block after deploying this.
The fold that writes each contract's storage root into its account happens inside
the trie, so the cache in front of it never sees it. That was fixed once in
ComputeStateRoot and left in Fork, where it cost a block of registrations that
reported success and wrote nothing: every contract call forks, a fork that could
not see the previous call's writes overwrote them on merge, and only the last
call in a block survived.

Both paths now go through one helper that folds and invalidates together, so a
third caller added later gets it by construction rather than by remembering.

The tests assert the invariant instead of the call sites: a cached account may
never disagree with the trie, a fork must start from the folded record, and
writes merged through successive forks must all survive. The fork case fails
without the change and passes with it, which the first version of these tests did
not, because it compared the fork against the parent's cache and both were stale
in the same way.
The testnet halted at block #1746. Three validators computed one state root, the proposer's header
claimed another, and the block was refused on all three. Replaying it against a refusing node's own
data settles which side was right: from the trie alone, every execution shape reproduces the header
exactly. The block was valid and the nodes that rejected it were the ones in the wrong.

What they read it against was not the trie. FlatStateDb caches accounts and persists that cache, and
the persisted copy is never truncated: a flush upserts what the cache holds and deletes what was
deleted, so an entry the cache no longer holds keeps whatever version it had when it was last
written. Two things drop entries. The fold drops an account by design, so the next read reloads the
storage root it just wrote into the trie. And finishing a sync batch installs a fresh state over the
same store, with an empty cache that never refreshes anything it does not touch. Both leave records
on disk that the trie has moved past, and the next start reloads them and reads them in preference
to the trie for the life of the process.

Nothing caught it. The startup consistency check compares ComputeStateRoot against the block header,
and ComputeStateRoot reads the trie alone, so a cache that contradicts the trie passes it every time.
It stayed invisible while the blocks were empty and surfaced on the first one carrying contract
calls.

So a reloaded account that disagrees with the trie is dropped rather than trusted, which costs one
trie read per warm entry, once, and heals a copy that has already gone stale. A dropped entry is
also deleted from the persisted copy, so the stale record stops being left behind in the first place.

Refusing a block no longer lets the state it refused reach the disk either. The check runs after
execution, so the mutations are already applied while the chain stays a block behind them, and the
shutdown flush was making that permanent: the node reloaded into a state no peer shared and could
never rejoin. That is why three validators are wedged rather than merely stopped. Marking the state
inconsistent leaves the trie on disk as the last thing that was actually checked against a header.

Verified against the incident: with this, all four execution shapes reproduce #1746's header root
from both a refusing node's data and the proposer's, and neither data directory reloads an account
that disagrees with its trie.
The drop already happened, silently: the FlatStateDb the node rebuilds on startup is constructed
without a logger, so the warning could never reach a log. That is the one signal that would have
named this before it halted the chain, and it was the signal least able to appear.

LoadFromPersistence now returns how many it dropped and startup logs it. Anything but zero means the
node was carrying a persisted cache the trie had moved past, which is what a state root divergence
looks like a few blocks before it becomes one.
Two things were left standing after the halt, and both were held up by reasoning rather than by a test.

The first was the claim that the persisted storage cache could not go stale the way the account cache
had. It can, and by a shorter route. DeleteStorage records the slot so the next flush can remove the
persisted copy, and the trie forgets it at once. But CompactDeletedSets runs on every block, to keep
those sets from growing without bound, so by shutdown there is nothing left to tell the persisted copy
about. Its record stays. Reloading it hands back a value for a slot the trie says is empty. TWAP prunes
storage every block, so this is the common shape rather than a corner of it.

Validating storage on load the way accounts are validated is not affordable, because there can be
hundreds of thousands of slots and each check is a trie read. So storage is no longer persisted at all.
What the reload bought was a warm read cache after a restart, which the trie repopulates on demand
anyway, and that is a transient cost against reading a slot the chain deleted. Whatever an older build
left on disk is deleted on the next flush.

A resurrected slot is invisible to a write, because SetStorage reaches the trie whatever the cache held.
It surfaces when something reads before deciding, which is what a contract does, so the test makes the
write depend on the read the way the name service depends on the owner slot before accepting a name.

The second was the fold deciding which cached accounts to drop by reading the dirty-key set. That set
is cleared every block by ClearDirtyTracking, so what the comment called a safe superset was a superset
only by the grace of the order those two calls happen in. The trie now names the accounts its fold
rewrote, which is exact and owes nothing to call ordering. The fold is the one path that changes an
account record without going through SetAccount, so it is the one path a cache in front of it cannot
otherwise see.
The count went nowhere. LoadFromPersistence logged it through the instance's own logger, and the
instance startup recovery builds has none, which is the same blindness that kept the dropped-account
count invisible until it was returned to the caller instead. Both numbers now come back and startup
logs them.

Either being non-zero says the node was carrying a persisted cache the trie had moved past, which is
what a state root divergence looks like a few blocks before it becomes one.
…o it

Dropping a superseded record on load made reading safe, which is a guard and not a cure. The record
was still written, still reloaded, and still had to be recognised as wrong on every start. Two nodes
did exactly that on this deploy, which is the guard working and also the source still running.

The source is that a flush upserted. A cache that drops entries cannot name what it is no longer
responsible for: the fold drops an account so the next read reloads from the trie, and finishing a
sync batch installs a fresh cache holding nothing at all. Anything either of them stops speaking for
stayed on disk at whatever version it had when it was last written, so what came back from a reload
was a union of records from different moments rather than a snapshot of one.

A flush now leaves behind exactly what the cache holds. The persisted set is one moment again, and
the guard has nothing left to catch. It stays anyway, because it is what heals a copy an older build
already spoiled, and because a guard that never fires is the cheapest kind.

Two smaller things fall out. The eviction tracking added to delete dropped records is dead and gone,
since the flush no longer needs telling. And the fold drops cached accounts by the list the trie
gives of what it rewrote, rather than by the dirty-key set, which other code clears every block: the
old version was a superset only by the grace of the order those calls happen in.
…keeps

Deleting the storage an older build left behind depended on the instance that read it at startup
still being the instance that writes at shutdown. On a node that syncs it is not: finishing a batch
swaps in a fresh state, and everything the loader remembered went with the instance it replaced. So
the cleanup ran on validators and never on the RPC node, which reported the same leftover slot on
two restarts in a row.

A flush now clears the account and storage records straight out of the store before writing the
accounts, so it owes nothing to what any instance happens to remember. That also lets the interface
say what it means: Flush takes the accounts and nothing else, because deletion lists were never
something a cache that drops entries could honestly supply.

The tests seed storage the way a build that persisted it would have left it, since nothing writes it
any more and they would otherwise pass for the wrong reason.
Caddy has served caldera.basalt.foundation since the routing commit and there has never been a
container to answer. The hostname resolved, the proxy answered, and the reply was 502, which reads
as an outage rather than as something that was never deployed.

Caldera has its own repository and already carries a Dockerfile, so this only wires it into the
chain: a service built from a checkout beside this one, joined to the same network, and named in
Caddy's depends_on so the proxy does not come up before its backend exists. CALDERA_PATH overrides
where the checkout is.

Both build arguments are NEXT_PUBLIC_, meaning they are fixed at image build time. The API URL is a
container name deliberately, because the browser never uses it: REST goes through the Next rewrite at
/api/node, which runs server side. The WebSocket URL is the public one, because the browser dials
that itself, and Caddy has routed /ws/* to the RPC node with buffering off all along.
The Caldera deploy commit also carried three "<name> 2.cs" copies of files that were already in the
tree: FlatStateDb, IFlatStatePersistence, and RocksDbFlatStatePersistence. They are byte-identical to
their originals, so the compiler sees every type and member declared twice and the build stops on
CS0101 and CS0111 before a single test runs. That is why all four shards failed at once rather than
one of them reporting a real regression.

Removing the copies leaves the compiled sources exactly as they stood at 25ce7f9, the last commit
whose CI was green; only the deploy files, which nothing compiles, differ from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uLBYa467u3A5QPXkcCsQM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants