Testnet readiness: sync durability, state-growth control, ZK compliance proving, and the Trilith integration surface - #76
Draft
0xZunia wants to merge 75 commits into
Draft
Testnet readiness: sync durability, state-growth control, ZK compliance proving, and the Trilith integration surface#760xZunia wants to merge 75 commits into
0xZunia wants to merge 75 commits into
Conversation
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
integration/testnet-prepis the branch the incentivized testnet has been built on. It carries 25 commits sincemainand touches 57 files (+5988 / -85).It has never been through CI.
ci.ymltriggers only on pushes tomainand pull requests targetingmain, 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.
GenesisContractDeployernow deploys a ninth system contract,TrilithAnchor(type0x0109, address0x100B), andBasaltNameServicegained 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.IsPublicNetworkreturns true for chain ids 1, 2, and 4242, and the node startup guards key off it rather than the oldChainId <= 2. A 4242 deployment that previously booted with development defaults will now refuse to start unless it suppliesBASALT_FAUCET_KEY,BASALT_DATA_DIR, andBASALT_VALIDATOR_KEYfor validators, and it will rejectBASALT_DEBUG=1outright because debug mode enablesAllowAnyOriginCORS. Private devnets such as 31337 are unaffected.A
v*tag now publishes five additional packages.nuget-publish.ymlpacksBasalt.Core,Codec,Crypto,Confidentiality, andCompliancealongside 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
63ba20efixes a durability bug in the sync path.BlockApplier.ApplyBatchleft 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 toCF.TrieNodesafter 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.6dc34c2anda42930bclose a silent-fork hole. The replay path called the single-argumentChainManager.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.a42930broutes 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.
fa3dceaadds the pruner as a library inBasalt.Storage, built around a storage-aware reachability walk that descends every account'sStorageRootrather 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 belowMaxRollbackDepth, and trips a circuit breaker if a sweep would delete more than 95 percent of nodes.7d13c3awires it intoNodeCoordinatorbehindEnableTriePruning, off by default. When disabled the state mutation path is a direct call, so consensus timing is identical to today.9c26f88exposes 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_nodesplateaued at 30, but the multi-day soak under real transaction churn has not happened yet. The retention window keeps genesis, the tip, and the lastTriePruneWindowSizeblocks, with the window floored atMaxRollbackDepthso a sweep can never delete state that a rollback still needs.ZK compliance and the prove-then-forget flagship
0c0d7c1retires 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, andeb7636abuild 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.3eb0bc9adds the matching verifier-side enforcement (COMPL-C03) for credential expiry, tier, and nullifier binding.6ddc563binds 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.51af21eseparates trusted setup from proving so the verifying key is stable and registerable on chain, rather than regenerated per run.f346083upgrades 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 bumpedBasalt.Complianceto0.5.0-testnet.5ac4ec5rebinds the golden vector to a canonical content id.Trilith integration surface
dc9d4f5adds BNS v2 (content records pointing attrilith://URIs, expiry, renewal, grace period, and reclaim) and theTrilithAnchornotary, a first-writer-wins registry for 32-byte digests with primitive-only view returns so downstream consumers need no Basalt types.83a6269exposesGET /v1/names/{label}andGET /v1/anchors/{digest}.7498d0fpins theContractCallsigning payload and signature as a golden, so an external signer reimplementing the payload layout cannot silently drift from it.5ab6816movesTrilithAnchorfrom0x100Ato0x100Bbecause0x100Acollided with the DEX governance address.Public network parameters
4eb06b6introducesIsPublicNetworkand re-gates the startup guards.a2fff66gives chain 4242 its own parameter set, which the public-network validation requires because it demands aDexAdminAddressand 4242 was previously falling through to the generic default and failingValidate().fcc9cd4fixes the one test that regression surfaced, covered below.Also included
4404489adds theOracleNetworkcontract at type0x0108. 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
MaxRollbackDepthof 1000. Both were caught before implementation, and the shipped design carries seven guards as a result.Genesis constructs system contracts with
IsDeploying = false.GenesisContractDeployercallsCreateInstancedirectly rather than going throughManagedContractRuntime.Deploy, so constructor initialization blocks guarded byif (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 withIsDeploying-guarded initialization are latently affected and were left alone as out of scope.The faucet diagnostic regression.
a2fff66brokeFaucetDiagnostic_BlockBuilderIncludesTx, which had been passing onmain. The test builds on chain 4242 and paidGasPrice = MinGasPrice, which defaults to 1. That only worked because 4242 had no parameters of its own and inherited the devnet default, whereInitialBaseFeeis also 1. Real testnet parameters raise it to 100000000, soBlockBuildercorrectly dropped the transaction as underpriced.MinGasPriceis aTransactionValidatorfloor and unrelated to the EIP-1559 base fee, sofcc9cd4makes the test pay whatFaucetEndpointalready pays, the higher ofMinGasPriceand 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:
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-arm64andosx-arm64instead of a real native. Those five were validated separately in alinux/amd64container.Not covered by this sweep, and the reason the branch is not yet proven for production duration:
tools/basalt-proveis 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.
63ba20e,6dc34c2,a42930bfor the sync and durability correctness fixes.fa3dcea,7d13c3afor the pruner, concentrating on the reachability walk and the locking.f346083for the request-binding fix, which is the security-relevant one in the compliance track.dc9d4f5,83a6269for the contract and API surface.4eb06b6,a2fff66for the public network gating.