feat(supply): add off-consensus IOTX total-supply conservation observer - #4971
feat(supply): add off-consensus IOTX total-supply conservation observer#4971raullenchai wants to merge 5 commits into
Conversation
Add a read-only, off-consensus observer that periodically recomputes the IOTX total supply from the three accounting reservoirs - primary account balances (Account namespace), the rewarding fund total balance (Rewarding namespace), and the staking bucket pool total (Staking namespace) - and reports when it exceeds the genesis endowment cap. This follows the 2026-08 Harmony "empty block" incident audit, where a protocol-level bug allowed an attacker to mint IOTX out of thin air. The observer is deliberately observational: - it never rejects blocks or alters consensus, so it cannot brick the chain; - it only logs and exposes Prometheus gauges on violation; - it introduces no state-transition change, so it needs no hardfork. Because IOTX supply legitimately decreases (EIP-1559 burn, slashing), the check is an upper bound (total <= genesis cap) rather than an exact equality, making it sound: legal states never trip it, while unauthorized minting spikes above the cap immediately. Wire the observer into ChainService.Start as a periodic goroutine.
Codecov Report❌ Patch coverage is ❌ Your project check has failed because the head coverage (61.30%) is below the target coverage (85.00%). You can increase the head coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## master #4971 +/- ##
===========================================
- Coverage 74.83% 61.30% -13.54%
===========================================
Files 378 469 +91
Lines 31624 45775 +14151
===========================================
+ Hits 23666 28061 +4395
- Misses 6747 14350 +7603
- Partials 1211 3364 +2153 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Before the Greenland v2-storage layout, the rewarding fund and staking bucket-pool totals were not stored under their v2 namespace keys, so a node whose current height predates that layout would fail the reserve reads and spam errors. Treat a missing reserve state as a zero balance: this is the same conservative (sound) direction as any other under-count - it cannot create a false violation, only weaken the check - and keeps the observer usable regardless of the node's current height.
Address the Codecov and SonarQube quality-gate feedback on the new code: - Supplychecker coverage raised to ~90%: added tests for genesis-cap derivation with malformed strings, nil/deleted-account traversal, reserve-read failures, and the Run ticker (success and error paths). - Chainservice: extract startSupplyObserver so the observer wiring is unit-testable (nil-factory no-op and factory-present paths) with gomock mocks, instead of leaving the Start wiring untested. - Refactor the two on-chain state deserializers onto a shared parseDecimal helper to reduce the new-code duplication flagged by SonarQube.
envestcc
left a comment
There was a problem hiding this comment.
Nice motivation — supply conservation is absolutely worth monitoring. But I think the current approach has a couple of problems that make it unsafe to merge as-is, and I'd suggest a different shape for the check.
The full scan is too expensive to run every minute.
States(NamespaceOption(AccountKVNamespace)) walks the entire Account bucket. readStates calls Filter(ns, alwaysTrue, nil, nil), and BoltDB.Filter copies every key and value into memory inside a single bolt read transaction. On mainnet that's millions of entries, allocated fresh every 60 seconds.
And it does that while holding a lock that blocks block commit.
stateDB.States holds sdb.mutex.RLock() for the whole scan (statedb.go:588), while PutBlock takes sdb.mutex.Lock() across ws.Commit() (statedb.go:528). So a long scan stalls block processing. The PR says it can't affect consensus — it doesn't reject blocks, but it can definitely stall them, and for liveness that's the same thing.
The Account namespace isn't only accounts — and this one actually crashes the node.
state/tables.go:14-24 documents that the Account namespace also holds legacy Poll and Rewarding states. Those were written pre-Greenland via putStateV1 and are never deleted (stateCheckLegacy still falls back to reading them).
Decoding them as state.Account doesn't error — it silently produces a bogus balance, because rewardingpb.Fund field 2 (unclaimedBalance) and accountpb.Account field 2 (balance) have the same field number and wire type. I tested it:
Fund{unclaimedBalance:"1e26"} -> err=<nil> Account.Balance = 1e26
Admin{epochReward:"12500", ...} -> err=<nil> Account.Balance = 12500
So R1 is over-counted, which inverts the "under-counting only, never a false positive" argument in the description.
Worse: the genesis rewarding admin state is written at height 0 (mainnet Greenland is 6544441, so UseV2Storage is false there) with productivityThreshold: 85. That's field 7, and accountpb.Account field 7 is the AccountType enum, which only allows {0,1}. Account.FromProto panics on anything else:
>>> PANIC: unknown account type
There's no recover() anywhere on that path and Run is launched as a bare goroutine, so this takes the whole process down. On mainnet the node would start, tick after 60s, hit that key, and die — in a loop.
If you keep any version of the scan, please don't hand raw bytes to state.Account.Deserialize — it's written for trusted input and panics by design. A local strict decoder works well; I tested one that checks len(pb.ProtoReflect().GetUnknown()) > 0, validates the enum and balance instead of panicking, and requires a canonical re-marshal round-trip. It accepts every real account shape (EOA, funded, contract with Root/CodeHash, ZERO_NONCE) and rejects Fund, Admin, rewardAccount, Exempt, CandidateList, and staking TotalAmount.
A suggestion on the check itself.
Beyond the implementation issues, total <= genesisCap has slack equal to the cumulative base-fee burn, which grows forever. An attacker minting less than that is invisible. It only catches Harmony-scale events.
IOTX has no post-genesis mint path at all — the rewarding fund is pre-seeded, fees are transfers, and base fee is the only burn (protocol.go:112). So you can check an exact equality per block instead of a loose bound, with no state scan:
L1 — journal ↔ ledger reconciliation (per block, O(block write set)). Every balance movement already emits a TransactionLog with a sender and a recipient: NATIVE_TRANSFER, IN_CONTRACT_TRANSFER, GAS_FEE, PRIORITY_FEE, BLOB_FEE, CREATE_BUCKET, DEPOSIT_TO_BUCKET, WITHDRAW_BUCKET, CANDIDATE_SELF_STAKE, CANDIDATE_REGISTRATION_FEE, DEPOSIT_TO_REWARDING_FUND, CLAIM_FROM_REWARDING_FUND. Two independent things to compare: the per-address deltas the logs say should happen, and the per-address deltas actually written to state. StateDiffCallback already hands you the latter as []WriteQueueEntry, and — importantly — it's invoked after sdb.mutex.Unlock() with its return value ignored, so it genuinely can't stall or brick anything. Any mismatch is a balance change no action authorized, which is exactly the ex-nihilo mint signature. Minimum detectable deviation: 1 rau.
One small thing needed: WriteQueueEntry only carries the new value, so CaptureWriteQueue would need to also capture the prior value for the delta. It's in the working set store cache already, and it's an observability-only change — still no hardfork.
L2 — running total (per block, O(1)). Maintain supply as a scalar updated by the L1-verified deltas, and assert supply(N) == supply(N-1) - Σ(baseFee × gasConsumed). Exact equality, monotonically non-increasing.
Then this PR's full scan becomes the right implementation for a third tier: a periodic reconciliation against L2's running total, run daily or per epoch on a dedicated auditor node rather than every minute on every validator.
Two things that would help either way:
- Replay mainnet from genesis and assert the invariant at every height, before shipping. That's the only way to know the accounting model is complete, and it would surface the namespace issue immediately.
- An integration test against a real
factory.Factory. ThefakeStateReaderonly ever putsstate.Accountinto the Account namespace, which is why all of the above passes CI. Note it'd need bolt —memKVStore.Filterreturns"in-memory KVStore does not support Filter()".
Happy to help with the L1/L2 piece if useful.
|
Thanks @envestcc — this is a thorough and correct review, and I've verified every point against the code. I'm going to rework the PR rather than merely patch around it. Concretely, confirming each:
Your proposed shape is the right one and the primitives already exist:
Two prerequisites I'll also take on:
I'll rework the PR along L1→L2→L3 and would welcome help with the |
Address the maintainer review that blocked the total-supply observer by fixing its two merge-blockers and recasting it as the L3 (periodic reconciliation) tier. - P0 crash / over-count: the Account namespace also holds legacy Poll/ Rewarding states (pre-Greenland) that are never deleted. Feeding their raw bytes to state.Account.Deserialize panics (unknown account type / invalid balance, state/account.go) and, where it does not panic, silently over-counts bogus balances (Fund/Admin wire-tag collisions inflate R1). sumPrimaryBalances now decodes entries with a strict local decoder that skips anything that is not a genuine, canonically-encoded account, so the scan can neither crash the node nor false-positive on those payloads. - L1 liveness stall: a full-namespace scan holds the state-factory read lock and would stall block commits if run every minute on every validator. The observer is now opt-in (blockchain.SupplyCheckConfig, default disabled) and runs at a configurable interval, intended for daily / per-epoch runs on a dedicated auditor node. Tests cover the strict decoder accepting every genuine account shape while rejecting legacy Fund / Admin / Exempt / rewardAccount / staking payloads, plus an end-to-end Observer.Check against a namespace poisoned with those legacy states proving it neither panics nor over-counts. The supplychecker, chainservice and blockchain suites all pass. L1 (per-block journal<->ledger reconcile) and L2 (per-block scalar running supply, O(1)) are intentionally left as the follow-up; StateDiffCallback is already invoked outside the state-factory mutex and will carry those tiers. A bolt-backed mainnet replay asserting the invariant at every height is the acceptance gate for them.
|
raullenspark1 seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
|
@envestcc — rework requested in your review is now pushed in 1. P0 crash + over-count → fixed.
So the scan can neither crash the node nor over-count the legacy states into R1. Added 2. Liveness stall → fixed. The full-namespace scan was moved out of the ordinary-validator path entirely. It's now opt-in via As discussed, this PR is the L3 tier. L1 (per-block journal↔ledger reconcile via
|
…queue Per the review feedback on iotexproject#4971, the L3 periodic reconciliation alone is not enough: `total <= genesisCap` has slack equal to the cumulative base-fee burn, so a sub-Harmony-scale mint is invisible. This adds the L2 tier that catches an unauthorized mint down to 1 rau. L2 per-block tracker (supplychecker/tracker.go): - Consumes the already-produced state-diff write queue via the StateDiff callback (invoked after the factory mutex is released, so it cannot stall block commits). - Maintains the running total (R1 accounts + R2 rewarding fund + R3 staking pool) and asserts the per-block delta is never positive. IOTX has no post-genesis mint path, so any single-block net increase is the ex-nihilo mint signature. - Off-consensus and non-fatal: only logs and exports Prometheus gauges. - Deployment caveats documented (v2/post-Greenland namespace layout for the fund key; absolute gauge seeded at genesis cap when starting mid-history; the per-block delta is exact regardless). WriteQueueEntry prior-value capture (state/factory): - CaptureWriteQueue now also records each key's pre-block base-store value so a consumer can compute exact per-key deltas without an extra state read at callback time. - The base reads are gated behind whether a state-diff consumer is registered (capturePriorValue), so an ordinary validator with no auditor pays zero extra disk lookups per block on the commit path. - AddDiffCallback chains observers so the supply tracker coexists with ioSwarm's SetDiffCallback instead of clobbering it. Chainservice wiring: - create the L2 tracker from genesis and register its OnBlockCommitted alongside the L3 periodic observer. Tests: - bolt-backed integration test against a real factory.Factory (TestBoltBackedSupplyConservation) validating both tracker (L2) and observer (L3) plus the v2 namespace keys. - unit tests for per-block delta conservation (transfer/reward/staking/ base-fee burn), mint detection, same-key aggregation, and legacy-state skipping. - factory-level test for the PriorValue gating (captured vs skipped).
|
|
Thanks @envestcc — following your L1/L2 review shape, the loose-bound concern is now addressed and pushed ( L2 (per-block exact delta) — now in this PR. WriteQueueEntry prior-value capture — in, as you suggested. Real-factory integration gap — closed. L1 (journal↔ledger reconcile) remains the next phase; the prior-value plumbing it needs is now in place. Open to your help there.
|


Summary
This PR adds two tiers of IoTeX's total-supply conservation monitoring, all off-consensus and non-fatal:
Motivation: the 2026-08 Harmony "empty block" incident let an attacker mint ~40B native tokens (≈26% of supply) out of thin air. IoTeX's supply can never exceed its genesis endowment, so recomputing the circulating supply against that cap is a cheap early-warning for the same class of bug.
Why a gated observer, not a per-block scan
This observer walks the entire Account namespace, which holds the state-factory read lock and would stall block commits (liveness) if run every minute on every validator. It is therefore opt-in via
blockchain.SupplyCheckConfig(default disabled) and intended to run daily / per-epoch on a dedicated auditor node — not on ordinary validators.It is deliberately off-consensus and non-fatal:
Errorand exposes Prometheus gauges on violation;Invariant
genesisTotalis derived programmatically from genesis config (not hardcoded).Review-driven rework (per @envestcc's review on this PR)
The reviewer flagged two merge-blockers in the first version; both are fixed:
P0 — crash / over-count. The
Accountnamespace also holds legacy Poll/Rewarding states (pre-Greenland, never deleted). Feeding them tostate.Account.Deserializepanics (unknown account type / invalid balance) and, where it doesn't, silently over-counts bogus balances via wire-tag collisions (Fund/Admin→ inflated R1).sumPrimaryBalancesnow decodes entries with a strict, non-panicking local decoder (decodeAccount) that skips anything that isn't a genuine, canonically-encoded account — so the scan can neither crash the node nor false-positive on those legacy payloads.Liveness stall. The full scan now runs only when enabled in config, at a configurable interval, on opt-in auditor nodes — not every minute on every validator.
Tests cover the strict decoder accepting every genuine account shape (EOA / funded / ZERO_NONCE / contract) while rejecting legacy
Fund,Admin,Exempt,rewardAccountand stakingTotalAmount, plus an end-to-endObserver.Checkagainst a namespace poisoned with those legacy states proving it neither panics nor over-counts.L2 (in this PR) / L1 (next phase), per @envestcc's review shape
supplychecker.SupplyTrackeris wired into the state factory'sStateDiffCallback(invoked after the factory mutex is released, so it can't stall commit) and maintains the running R1+R2+R3 total, asserting each block's net delta is<= 0.WriteQueueEntrynow carries the prior value (gated behind a registered consumer so ordinary validators pay no extra disk lookups), which is what lets the exact per-key delta be computed with no extra state read.TransactionLogdeltas against the actual state writes. Any mismatch = a balance change no action authorized. The prior-value capture it needs is now in place.Acceptance gate for L1: a bolt-backed mainnet replay asserting the invariant at every height. This PR adds a real-
factory.Factorybolt integration test (TestBoltBackedSupplyConservation) closing the earlier gap that the in-memoryfakeStateReaderonly ever insertedstate.Account.Changes
supplychecker/: new package —Check,Run), strict non-panicking account decoder, genesis-cap derivation, per-reservoir readers.tracker.go) wired to the factory's state-diff callback.blockchain/config.go:SupplyCheckConfig(opt-in L3 flag + interval, default off).chainservice/: wire the L2 tracker + L3 observer, gated bySupplyCheckConfig.state/factory/:WriteQueueEntrynow captures the pre-blockPriorValue(enabling exact per-address deltas);AddDiffCallbacklets the tracker coexist with ioSwarm's callback.Tests
All pass — including the bolt-backed
TestBoltBackedSupplyConservationagainst a realfactory.Factory(L2 tracker + L3 observer + v2 namespace keys), per-block delta conservation/mint-detection unit tests, and a factory-level PriorValue-gating test.Follow-ups / self-review
parseDecimalhelper removed.