Implement turnstile and migration#598
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 5b4e863. Configure here.
| fn on_runtime_upgrade() -> Weight { | ||
| let seed = T::Currency::total_issuance(); | ||
|
|
||
| PotentialWormholeBalance::<T>::put(seed); |
There was a problem hiding this comment.
Migration omits historical exit total
High Severity
The v0→v1 migration seeds PotentialWormholeBalance from total_issuance() but leaves TotalWormholeExits at its default of zero. On a chain that already processed wormhole exits, the turnstile treats cumulative exits as starting at upgrade, so exits_after <= potential_balance can allow roughly another full issuance worth of exits before rejection, undermining the stated lifetime cap.
Reviewed by Cursor Bugbot for commit 5b4e863. Configure here.
Review: Wormhole soundness turnstileReviewed the full diff plus surrounding code ( VerdictThe core idea is sound and the implementation is careful and well-tested. The invariant Strengths
Findings1. The backstop loosens monotonically — keyless accounts never "reveal" (most important)The PR claims the pool "tightens toward the true ambiguous sum as accounts reveal themselves." That's only true for accounts that can sign. Keyless/derived accounts never sign, so their inflows enter Concrete, every block: Net effect: Suggestion: decrement 2. Add-side and subtract-side use different sources of truthDeposits add the transferred
The huge seed + 3. Counter is mutated from two crates (encapsulation / DRY)The deposit increment lives in the pallet ( pallet_wormhole::PotentialWormholeBalance::<Runtime>::mutate(|total| {
*total = total.saturating_sub(balance);
});The counter's invariant is split across the crate boundary. Cleaner to expose one pallet method that encapsulates read-balance-and-subtract, e.g. Nits
|
Review: Wormhole soundness turnstile — verdictReviewed the full diff plus surrounding code ( VerdictThe design is sound and safe in the failure direction: the invariant On the existing reviews
Findings1. Backstop loosens monotonically — keyless accounts never reveal (most important). 2. Add-side and subtract-side use different sources of truth. 3. Storage mutation inside if let Ok(signer) = ensure_signed(origin.clone()) {
if pallet_wormhole::Pallet::<Runtime>::is_ambiguous_account(&signer) {
let balance = <Balances as Currency<AccountId>>::free_balance(&signer);
if balance > 0 {
pallet_wormhole::PotentialWormholeBalance::<Runtime>::mutate(|t| *t = t.saturating_sub(balance));
}
}
}
Related: the new integration tests are good but they call 4. Counter is mutated from two crates (encapsulation / DRY). Concrete suggestions (ranked)
Nits
Verified correct
|
|
If I read this correctly
|
Re-review (commits
|


Wormhole soundness turnstile
Summary
Adds a Zcash-style turnstile to the zk-wormhole so the chain can never mint more
value out of the wormhole than could possibly have been deposited into it. The
invariant enforced on every exit is:
If a ZK soundness bug ever let a forged proof through, this backstop caps the
damage at the currently-available margin and rejects the exit instead of minting
unbacked tokens.
How it works
PotentialWormholeBalance— running total of value held by ambiguousaddresses (accounts with
nonce == 0, i.e. that have never signed atransaction). These are the addresses that could be wormhole deposits.
TotalWormholeExits— cumulative value minted via wormhole exits.record_transferadds to the pool whenever a nativetransfer lands in an ambiguous address (covers ordinary transfers, genesis
endowments, mining rewards, and exits that flow back into the wormhole).
ambiguous, so
WormholeProofRecorderExtension::validate()subtracts itsbalance from the pool.
verify_aggregated_proofchecksexits_after <= potential_balancebefore minting any exit output, and rejects with
SoundnessInvariantViolationotherwise.
A shared
is_ambiguous_account()helper is the single source of truth for thenonce == 0heuristic, used by both the deposit-tracking and reveal paths.Seeding an already-running chain
On a chain that predates this tracking, wormhole deposits made earlier aren't
reflected in
PotentialWormholeBalance(it would default to0), so the firstlegitimate exit would trip the invariant and brick the wormhole.
A one-shot versioned migration (
migrations::v1::InitSoundnessCounters, wired asMigrateV0ToV1) seeds:Every balance in an ambiguous address is backed by issued tokens, so total
issuance is a safe upper bound on what could legitimately be exited — seeding to
it can never block a valid exit. The pool then tightens toward the true ambiguous
sum as accounts reveal themselves. (We intentionally avoid an extra configurable
buffer: it could only loosen the bound, never make it safer.) On a fresh chain
the migration is skipped and the pool is seeded by the block-1 genesis-endowment
record_transfercalls instead.Changes
pallets/wormhole/src/lib.rs—PotentialWormholeBalance/TotalWormholeExitsstorage,
SoundnessInvariantViolationerror,STORAGE_VERSION = 1,deposit tracking in
record_transfer, invariant enforcement inverify_aggregated_proof, and theis_ambiguous_account()helper.pallets/wormhole/src/migrations.rs— v0 -> v1 seeding migration (withtry-runtime pre/post checks).
runtime/src/lib.rs— wireMigrateV0ToV1intoExecutiveand bumpspec_version131 -> 132.runtime/src/transaction_extensions.rs— reveal logic in the proof-recorderextension's
validate(), weight accounting, and extensive integration tests.Cargo.tomls —try-runtimefeature plumbing.docs/wormhole-soundness-detection-plan.md— design write-up.Testing
pallet-wormhole(25 pass) and the runtimetransaction_extensionssuite(27 pass) are green. Coverage includes:
lifecycle (validate -> prepare -> dispatch -> post_dispatch), including batch
transfers (sender revealed once, each ambiguous recipient counted).
treasury are both ambiguous).
SoundnessInvariantViolationwhen the margin is exhausted (P == E), even whenthe exit targets an ambiguous address (exiting into another wormhole is valid,
but must still respect the margin), plus the cold-start
P == 0case.Notes
nonce == 0heuristic is a deliberate over-approximation of "wormholedeposit address": unused accounts are also counted, which only makes the bound
looser (safe). Revealing tightens it back down.
exit output legitimately re-counts toward the pool because it's still inside the
wormhole system. Margin is only consumed when value leaves to a revealed account.
Note
High Risk
Changes wormhole exit minting and supply accounting with a runtime migration; a bug in counters or reveal timing could block legitimate exits or weaken the backstop.
Overview
Adds an on-chain wormhole soundness turnstile so cumulative ZK exits cannot exceed value tracked as possibly wormhole-backed. The enforced invariant is
TotalWormholeExits <= PotentialWormholeBalance.PotentialWormholeBalancegrows when native transfers (viarecord_transfer) land on ambiguous accounts (nonce == 0, viais_ambiguous_account).TotalWormholeExitsincreases only after successful aggregated exits.verify_aggregated_proofchecks the invariant before minting and fails withSoundnessInvariantViolationotherwise.First-time signers reveal as non-wormhole:
WormholeProofRecorderExtension::validatesubtracts the signer’s free balance from the pool (with updated weight accounting). Pallet storage version bumps to v1; runtimespec_version132 wiresMigrateV0ToV1, which one-shot seedsPotentialWormholeBalance = total_issuance()so pre-tracking deposits are not bricked on live chains.Includes design doc, try-runtime feature plumbing, and broad pallet/runtime tests (deposit/reveal matrix, mining rewards, margin-exhausted exits).
Reviewed by Cursor Bugbot for commit 5b4e863. Configure here.