feat(btc-mapping): vault-rotation-v2 — dual-generation vault, migration sweep, fund-gated retirement - #29
Open
lordbutterfly-hive wants to merge 45 commits into
Open
feat(btc-mapping): vault-rotation-v2 — dual-generation vault, migration sweep, fund-gated retirement#29lordbutterfly-hive wants to merge 45 commits into
lordbutterfly-hive wants to merge 45 commits into
Conversation
…ncil fixes S1 (contract dual-generation Vault state model) slices 0-2, plus the fix batch from the 5-lens super-rigorous failure-state council. Inert/byte-identical while only gen-0 exists (rotation begins at S1.3); fully deterministic; fold is atomic. - S1.0 vault-list schema (v/vn/va + Vault struct + marshal, mirrors r/i idiom). - S1.1 gen-0 fold (migrate v2, byte-matches live key, fail-safe empty-list guard) + per-UTXO Generation tag (appended, pre-S1 blobs read as gen-0). - S1.2 read-switch (active-vault resolution w/ legacy fallback; per-input witness keys + per-gen keyId; vault list is the source of truth). Council fixes: numeric migration-version compare (no v10+ regression of the non-idempotent v1 re-key); change-output UTXOs tagged with the active generation (prevents post-rotation change lock); buildSpendTransaction aborts when a UTXO's generation is absent from a populated vault list (symmetric with vaultKeyId, no silently-unspendable tx). Build green (tinygo wasm); new tests pass (fold fidelity/fail-safe, vault-list source-of-truth, numeric-version-no-regression, marshal round-trip incl. heights, UTXO backward-compat, keyId/found-bool). Pre-existing TestAllOperations (addBlocks/EOF) fails identically at base 6ae65c9 — not a regression. Deferred + tracked: S1.3 (mint/activate/lineage + fresh-deploy gen-0 seed + active-gen Status assert), S1.4 (deposit tagging/NR-4), S2 (sweep/C-B), S3 (NN#1 output-scoping), S5 (fund-gated retire + empty-check). Council: S1-COUNCIL.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… N-1 hardening
Round-2 of the fix-verification council (fresh, decorrelated) found the code fixes
clean (fix-correctness: FIXES-CLEAN; failure-state: NEVER-BRICK-STILL-HOLDS) but
flagged the spend-path fixes as untested end-to-end. Closes that gap:
- 3 internal tests directly drive the changed functions with a generation-1 UTXO:
* TestBuildSpendUsesInputGenerationKeys — gen-1 input's witness built from gen-1
keys, not gen-0/active fallback (per-gen wiring).
* TestBuildSpendAbortsOnMissingGeneration — gen absent from a populated list
aborts; empty pre-fold list falls back (no over-eager abort).
* TestChangeOutputTaggedWithActiveGen — change output tagged with the active gen.
- N-1 hardening: a non-numeric migration-version value is treated as beyond all
migrations (skip) rather than parsing to 0, so a corrupt mv can never re-run the
non-idempotent v1 re-key (defense-in-depth; unreachable since only the contract
writes mv).
All internal + harness tests pass; wasm builds. S5 acceptance criteria updated:
compaction must be gated on "no UTXO references this generation" (append-only until
then). Council: S1-COUNCIL-VERIFY.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… test + clamp Round-3 fix-verification: both lenses agree the code is clean (fix-correctness + non-vacuity: all 4 tests fail-when-reverted). Addresses round-3 findings: - Strengthen TestBuildSpendUsesInputGenerationKeys to a MIXED-generation spend: a RETIRING gen-0 input + an ACTIVE gen-1 input, asserting each input's witness uses ITS OWN generation's keys. The prior single-gen-1-input test (input==active gen) could not distinguish per-input resolution from per-active-gen; empirically verified the new test FAILS when the witness is resolved by cs.ActiveGen instead of utxo.Generation (the migration-sweep case, the fix's whole purpose). - N-1 hardening completed: also clamp a negative-decimal migration version (parses without error) → skip, so it can never re-run the non-idempotent v1 (unreachable, defense-in-depth). Not closable at the contract-test layer (verified by source, → S3 node-side): signSpendTransaction's per-gen keyId (sdk.TssSignKey is a host stub; keyId not in SigningData output); HandleUnmap passing cs.ActiveGen (handlers.go:201). Build green; all internal + harness tests pass. Council trail: S1-COUNCIL-VERIFY.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds the pending->active->retiring state machine that drives TSS vault rotation (S1 owns these three; S2/S5 own draining->purged). New mapping/ vault_lifecycle.go holds pure-ish state transitions over the append-only vault list, with LoadVaultState/SaveVaultState touching ONLY the three vault keys (never re-marshals UTXO/supply/balances — minimal blast radius). Ceremony (main.go exports): - createKey -> MintNextGeneration: appends a PENDING successor bound to the active gen as predecessor (NN#12 lineage); genesis mints gen-0. Never activates, never touches the live vault. Refuses a second keygen in flight. - registerPublicKey -> RegisterVaultKeys: routes keys into the pending vault; genesis gen-0 auto-activates once both keys are set. Rewritten to gate the legacy flat-key write to gen-0 only (a later gen must not clobber gen-0's flat keys, which remain the empty-list fallback). All existing RegisterPublicKey_* harness subtests still pass — backward compatible. - activateKey (new) -> ActivatePendingGeneration: cuts over. Predecessor -> RETIRING (keeps keys + funds, still spendable; NEVER purged here). Guards: keygen complete (both real pubkeys), active-counter agrees with the Active vault, lineage predecessor == active gen, post-condition exactly one active. Any guard failure aborts the tx -> live vault untouched (never-brick). - discardPendingKey (new) -> DiscardPendingGeneration: escape hatch for a stalled/ failed keygen. Removes only a PENDING vault (holds no funds); NextGen is NOT rolled back, so gen numbers stay monotonic and a partially-completed keygen can't collide with the re-mint. - renewKey now renews the ACTIVE gen's key, not always the retiring "main". Also: init.go S1.2 resolution now matches Generation AND Status==Active (so a genesis PENDING gen-0 with zero keys can't be resolved to a zero key); byte- identical for every existing deploy (post-fold gen-0 is Active). LATENT BRICK CAUGHT BY THE HARNESS: the successor keyId was "main-v<N>", but the runtime's tss create_key / tss_v2.create_key / renew_key host bindings validate keyName against ^[a-zA-Z0-9]+$ (verified in the pinned go-vsc-node 1ac9bb1404ee, sdk.go:446/498/529). A hyphenated id is rejected with ErrInvalidArgument -> keygen fails -> rotation impossible. Changed to "mainv<N>" (alphanumeric, still prefix-"main" for the node's isBtcVaultKey gate). Added a VaultKeyId alphanumeric-invariant unit test. Tests: 8 new S1.3 harness tests over the real WASM cover the full ceremony plus every never-brick failure state (second-pending refused, incomplete keygen refused, broken lineage refused, discard+re-mint monotonic, discard-with-no- pending refused, renew targets active gen). All pass. Only pre-existing failure remains TestAllOperations addBlocks/replaceBlocks EOF (fixture issue at base 6ae65c9, not S1). WASM builds green (712415 B). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Six decorrelated council lenses over a1f4b8f found three CONFIRMED CRITICALs (all harness-proven) + mediums. Fixes, each with a revert-verified test: C-1 (CRIT fund-loss): indexOutputs left deposit UTXOs Generation=0. After a rotation a gen-1 deposit was recorded gen-0 -> spend built a gen-0 witness for a gen-1-locked UTXO -> unspendable while the balance was deducted. Added Generation to AddressMetadata (set = active gen in parseInstructions), tag deposits with it. Forward-compatible with S1.4 multi-gen address matching. D-1 (CRIT governance-attack): gen>=1 keys had no TSS attestation and no immutability, so a compromised owner (Hive multisig) could register a self-generated non-TSS key, activate it, and steal all future deposits on L1 — inverting the documented "mainnet keys immutable" guarantee. Now every activation (explicit + genesis auto) attests the primary against the TSS ceremony output via TssGetKey (consensus-deterministic: the keystore is written on the state-processing path). Registered keys are set-once immutable. The backup is the operator CSV- recovery key (not TSS) -> immutable-once-set; residual: a compromised owner+backup can spend via the backup path only AFTER the CSV timelock (by design). B-1 (CRIT fund-brick): MintNextGeneration genesis-detected via len(vaults)==0, true on an UPGRADED funded deploy before the (separate, non-auto) migrate ran, so a createKey-before-migrate minted a divergent gen-0 and stranded every legacy UTXO. Extracted the fold into mapping.FoldLegacyGen0IfNeeded and run it FIRST in every ceremony op — genesis now only fires on a truly fresh deploy. A-1/F-1 (HIGH bootstrap): genesis discard+re-mint reused gen-0/"main" (duplicate keygen for a possibly-live keyId). Genesis is now marked by a self-referential predecessor (Predecessor==Generation) and mints the next monotonic gen number, so a re-mint gets a fresh keyId. This also removes the gen==0 special-case in activate (A-2 lineage-skip closed). D-2 (MED never-brick): renewKey now renews EVERY non-purged gen (active + retiring), so a retiring key can't expire while it still custodies unswept funds. E-1 (MED testnet): seedBlocks only sets MigrateVersionKey when unset, so a reseed can't permanently disable pending migrations. A-3/F-2 (LOW): flat-key write now gated on isGenesis, never rewrites gen-0's flat keys on a rotation. Tests: +6 harness tests (createKey-before-migrate folds; genesis discard->fresh gen; activate rejects unattested key; register rejects overwrite; seedBlocks keeps version; renew covers retiring gen) + 1 mapping unit test (deposit gen-tagging) + attestation-aware key seeding. 118 harness tests pass; only pre-existing TestAllOperations addBlocks/replaceBlocks EOF (base 6ae65c9) remains. WASM 716527 B. Council trail: S1.3-COUNCIL-{A..F}-*.md + S1.3-COUNCIL-ADJUDICATION.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation (HIGH)
Round-2 council (3 fresh lenses) verified the round-1 fixes: attestation is SOUND
and DETERMINISTIC (keystore written on the BLS-threshold-verified state-processing
path — no consensus-fork window), all 7 fixes non-vacuous, no regression. Two lenses
independently converged on ONE new HIGH regression in the D-2 renew-all loop:
sdk.TssRenewKey ABORTS THE WHOLE TX on any un-renewable key (missing / retired /
Active-with-no-expiry). renewKey looped over every non-purged gen with no isolation,
and vaults are gen-ascending (retiring before active), so a single bad key in any
generation blocked renewing the ACTIVE fund-signing key forever — the exact
never-brick violation D-2 exists to prevent (backup CSV path still works, so not
fund loss, but a signing-liveness brick).
Fix: renewKey now renews only keys whose TSS status is "active" (RenewableVaultKeyIds
pre-checks via TssGetKey, which never traps). That is exactly the fund-holding set —
a retiring vault's TSS key is still status-active — so every key that must stay
renewable is renewed, while any un-renewable key is skipped instead of trapping the
whole call. Vault keys always carry a lifespan (createKey epochs=365), so an "active"
key is always renewable.
Test: TestRenewKeySkipsUnrenewableKey poisons the retiring gen-0 key (status retired)
and proves renewKey still succeeds and renews the active gen-1 key (revert -> trap ->
whole call fails). 119 harness tests pass; only pre-existing TestAllOperations EOF
remains.
Round-2 residual (accepted, LOW): FoldLegacyGen0IfNeeded sets gen-0 active without
attestation — reachable only by a fresh-deploy genesis deployer (the trust anchor)
and immutable-once-set on mainnet; the security-critical ROTATION attestation is
solid. Recommended follow-up: route fresh genesis through createKey->attest.
Council trail: S1.3-COUNCIL-R2-{G,H,I}-*.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…keys too (R3-1)
Round-3 (1 Opus lens) verified the round-2 renewKey isolation fix is correct and
non-vacuous, and confirmed the load-bearing assumption end-to-end: every vault key is
created with a lifespan (createKey epochs=365; the legacy "main" too), and a
hypothetical no-expiry legacy key is bulk-deprecated at StateEngine.Init before it
could ever reach renewal as "active" — so the active-with-no-expiry TssRenewKey trap
is unreachable for contract keys.
It found one gap (R3-1, MEDIUM): filtering renewal to status=="active" ONLY was too
narrow. A fund-holding key that EXPIRES becomes "deprecated" and stays there
(KeyRetirementEnabled=false). TssRenewKey does NOT trap on deprecated — it REACTIVATES
it, which is precisely the D-2 recovery — yet the "active"-only filter skipped it, so
renewKey (the never-brick recovery) couldn't revive an expired retiring key.
Fix: tssKeyIsActive -> tssKeyIsRenewable, renewable set = {active, deprecated}. Both
are trap-safe (active always has expiry; deprecated reactivates); retired / missing /
active-no-expiry stay excluded, preserving the round-2 error isolation.
Tests: TestRenewKeyRevivesDeprecatedKey (deprecated retiring key IS renewed) added;
TestRenewKeySkipsUnrenewableKey (retired still skipped, no trap) still passes. 120
harness tests pass; only pre-existing TestAllOperations EOF remains.
Council trail: S1.3-COUNCIL-R3-renewfix.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…R-4/C-2)
parseInstructions now derives a deposit address for EVERY fund-holding
generation (active + retiring + draining), each tagged with its own
generation, so a late deposit to a superseded generation's address still
credits and is tagged with THAT generation instead of being lost. Closes
the C-2/NR-4 residual deferred from S1.3.
- new depositAddressGenerations() enumerates matchable generations,
active-gen first (deterministic collision precedence), with a pre-fold /
fresh-deploy fallback to the single resolved key pair (byte-identical to
pre-S1.4 when only gen-0 exists — inert by construction).
- shared isFundHoldingStatus predicate locks the deposit-matchable set
identical to RenewableVaultKeyIds' renewable set {active,retiring,draining}
— a gen we credit MUST stay renewable or a late deposit could outlive its
signable key.
Tests: 3 native unit tests (helper set/ordering/fallback + predicate pin) +
1 harness end-to-end (rotate, deposit to retiring gen-0 address -> credited
+ UTXO tagged gen 0; its own revert-verify). Full suite green except the
3 pre-existing TestAllOperations blocklist EOF subtests (base 6ae65c9).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…im + doc hazards 3-lens council (fund-safety/adversarial/completeness) over 4ddce33. Mechanism sound; findings were false/over-claimed comments + a doc hazard + a test gap. - Finding A (adversarial, was HIGH → adjudicated MED): the "deposit-matchable and renewable sets are provably identical" claim is FALSE — RenewableVaultKeyIds additionally filters on tssKeyIsRenewable, so renewable is a strict subset. A retiring gen whose key fully retires (renewKey lapses) is dropped from renewal but still deposit-matched → a late deposit is credited but primary-unspendable (recoverable via CSV backup, never-brick #4 — degraded, not lost). Rewrote the isFundHoldingStatus / RenewableVaultKeyIds comments to the TRUE invariant: "matched ⇒ RECOVERABLE" (renew-kept primary OR CSV backup), not "⇒ renewable". - Inactive exclusion (all 3 lenses): corrected the misleading "SPV-proven-empty" justification; made the S5 re-inclusion a load-bearing ★ handoff in-code. - R6 over-claim (completeness+adversarial): comments cited an unimplemented cross-gen pubkey-uniqueness rule; restated collision-safety as attestation (D-1) + distinct-keys→distinct-addresses, and flagged R6-unimplemented. - F4: de-stale the types.go AddressMetadata.Generation comment (S1.4 is done). - F3 test gap: + TestDepositAddressGenerationsTwoSupersededGens (double rotation, two superseded gens matched at once). No mechanism change; WASM byte-identical (718823 B). Unit + harness tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…backup key too depositAddressGenerations gated on !isZeroKey(Primary) but not Backup: a would-be fund-holding gen with a real primary but zero backup would derive a deposit address whose CSV recovery path is unspendable. Unreachable in normal flow (activation requires both keys) but defense-in-depth per the fail-safe lens. + TestDepositAddressGenerationsSkipsZeroBackup (revert-sensitive). Fail-safe lens D-1 (generation-blind unmap input selection → a retired-gen UTXO can be dragged into an unmap → sync debit + delete before fire-and-forget sign) is a WITHDRAWAL-PATH issue tracked as MUST-FIX-BEFORE-ACTIVATION for S2/S3 (not S1.4 deposit mechanics; not exposed pre-activation behind the deploy gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…GH) + 3 defensive Adjudicated fixes from the S1-close pruned methodology (8 lenses). Fixed the real contract-side findings now (not backlogged): - F1 (trust-boundary, HIGH): a rotation successor's BACKUP key was owner-chosen, un-attested, and not lineage-pinned — a compromised owner could rotate with the honest attested primary but an owner-controlled backup, pause to block the committee's primary-path evacuation, and after the CSV window drain all post-rotation deposits via the backup branch. MintNextGeneration now PINS a rotation successor's backup to the active predecessor's (lineage-immutable); the set-once guard rejects any different backup. + TestRotationSuccessorBackup PinnedToPredecessor (revert-sensitive). Genesis keeps owner-set backup (G-1). - money-math L-1 (LOW): indexUnconfimedOutputs now caps change at MaxUtxoAmount like the deposit path — an uncapped change ≥ 2^48 (reachable once S2 consolidates) would truncate the uint48 registry and diverge from the blob. - lifecycle L-1 (LOW): RenewableVaultKeyIds returns the SKIPPED fund-holding gens; renewKey surfaces them (log + result) instead of silently reporting success — a never-brick #1 precursor (retiring gen's key about to die) is now visible. - determinism D-CLOSE-1 (LOW): UnmarshalUtxo fails closed on a malformed (1-3 or >4 byte) generation tail instead of silently reading gen 0. Tests updated: rotation tests register the successor primary only (backup now inherited). Full suite green except the 3 pre-existing TestAllOperations blocklist EOF subtests (base 6ae65c9). WASM builds green. Tracked (not contract-fixable now): D-1 (gen-blind unmap, S2/S3), X-1/NEW-A (check-sig gate, M1.3b/S3), X-2 (value-cap/bond-slash, S2/node), V-1 (TssRenewKey trap, devnet), D-CLOSE-2 (node Mongo read), B-2 (legacy-key external consumers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…export The fund-migration path (Subsystem 2): sweep a superseded generation's UTXOs to the successor (active) vault so the old key can later be destroyed (S5). - S2.0 getMigrationInputs: enumerate CONFIRMED UTXOs of a retiring gen, capped by MaxMigrationInputs (each input = 1 TSS sig; unbounded sweep-all blows the TSS budget + BTC standardness = the C-F brick) AND by MaxUtxoAmount tranche value (money-math pre-mortem: a tranche summing > 2^48 could never index its single output → brick); >cap drains in successive tranches (moreRemain). - S2.1 buildMigrationTransaction: single output paying the successor's address, per-input retiring-gen witness (shared addInputsWithWitnesses helper, factored from buildSpendTransaction so the two can't diverge), migration fee, and NN#1 LAYER-1 assertOutputsPaySuccessor (consensus-re-executed → consensus-valid). - S2.2 HandleMigrateVault + migrateVault export (owner-only, NOT pause-gated — rotation/recovery must run while paused; NN#1 keeps it successor-only): select a tranche, sign with the retiring gen's keys, index the sweep output UNCONFIRMED tagged the successor gen (addresses C-B), delete swept inputs, record the pending sweep, ActiveSupply -= fee (internal transfer otherwise Supply-neutral), retiring -> draining. Every failure aborts atomically (retiring gen keeps funds). E2E test: rotate -> migrateVault -> gen-0 swept to gen-1, draining, output tagged gen-1, fee deducted. Full suite green except the 3 pre-existing TestAllOperations blocklist EOF subtests (base 6ae65c9). WASM builds green. Deferred to S2.3/S2.4 (tracked): confirm-side draining->inactive on SPV-empty; pending-migrate-skip / N-round / RBF (V-5) / reorg-reversibility (U-10) / migration-fee ceiling (V5-4) / D-1 generation-signability-aware selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ring -> inactive When a superseded generation has no confirmed UTXOs left to sweep AND is registry-empty (no confirmed or unconfirmed UTXO tagged it) AND no sweeps are in flight, migrateVault finalizes it retiring/draining -> INACTIVE. - new generationHasUtxos(gen): registry-based drained check (THORChain HasFunds style; S5 hardens with an SPV zero-L1 proof so a corrupted registry can't falsely report empty). - the transition is gated on TxSpendsList being empty — a conservative stand-in that waits for every in-flight sweep to confirm before declaring drained (guards against a reorg un-emptying the gen); S2.4 refines to per-gen tracking. - INACTIVE marks "drained" only — NO shares destroyed (that is S5, fund-gated on SPV + grace>=reorg). The gen keeps its keys. Tests: empty retiring gen -> inactive; a gen with a pending sweep stays draining (not prematurely inactive). Full suite green except pre-existing TestAllOperations. NEXT S2.4: reorg-reversal (U-10) + RBF/idempotency (V-5) + pending-migrate-skip / N-round + migration-fee ceiling (V5-4) + D-1 generation-signability-aware selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildMigrationTransaction rejects a sweep whose miner fee exceeds half the tranche value, so a rogue/glitched oracle BaseFeeRate (already clamped to MaxBaseFeeRate) cannot burn most of a migration on fees. Fail-safe: abort leaves the retiring gen's UTXOs untouched and recoverable. + fail-safe test. Remaining S2.4 guards are DEFERRED with rationale (tracked for the S2-close council / later milestones), not silently dropped: - D-1 generation-signability-aware input selection -> S3: needs the node's TSS deprecated-vs-retired *signing* semantics verified first; choosing the skip predicate blind could wrongly block spendable funds (the dumb version). - U-10 reorg-reversal + V-5 RBF -> architectural: the unmap path has the same "inputs deleted before confirm" gap, so this is a cross-cutting effort, not migration-only. - N-round partials -> fee optimization, not fund-critical. - tighter economical-fraction fee ceiling + V-1 dust-burn/reserve-subsidy escape -> S5-coupled refinement. S2 sweep mechanism (S2.0-S2.4-partial) is functionally code-complete: select -> build (NN#1) -> sign -> index successor output (C-B) -> confirm -> drained -> inactive, with fee/value/input caps and atomic fail-safe throughout. Full suite green except pre-existing TestAllOperations. WASM builds green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6-lens S2-close council over 2411201..1e4dc7c. Fixed the real findings now: - F-1 (HIGH, 3 lenses converged: fail-safe/state-machine/trust-boundary): S2.3 produced Inactive, but isFundHoldingStatus excludes it → a drained gen dropped out of deposit-matching + key renewal, reopening the C-2/NR-4 late-deposit loss. REMOVED the premature draining->inactive transition; drained gens stay retiring/draining (fund-holding, matchable, renewable) until S5's fund-gated + match-until-purged finalization. (Also dissolves the state-machine F-3 shadowing + F-4 registry-not-SPV findings.) - NN#3 (HIGH, completeness): "no rotation N+1 while gen N is funded" was unenforced → funded old keys pile up, defeating rotation. createKey now gates on AnyFundedSupersededGen (no retiring/draining gen still holds a registry UTXO); genesis/clean-rotation pass trivially. - V-8/F-2 (HIGH, completeness): migrateVault was pause-exempt but confirmSpend (the only sweep-output promoter) is pause-gated → a sweep during pause strands its output. migrateVault is now PAUSE-GATED (consistent); true evacuation-during-pause is the deferred V-8 design (S3). - money-math F-2 (LOW): safeSubtract64 doesn't catch below-zero; added an explicit newActive<0 guard so a fee>ActiveSupply aborts (not silent negative). Tests: sweep test now also asserts NN#3-refusal + pause-gating; empty-gen test renamed + asserts stays-retiring (not inactive). Full suite green except pre-existing TestAllOperations. WASM builds green. DEFERRED (tracked, verified still correct by the council): X-2 unfunded migration fee (money-math F-1, hard-gated before activation); dust-DoS folds into V-1 (a dust residual now deadlocks rotation via NN#3 — the V-1 escape); chained-unconfirmed fund-freeze (= U-10/V-5 architectural, pre-existing in unmap); D-1 gen-signability selection -> S3; E2E test gaps (confirm-of-sweep [confirm side verified correct but untested], multi-tranche, double-rotation, reorg). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…preserving) The S2-close money-math + trust-boundary lenses flagged the migration miner fee as UNFUNDED (decremented from ActiveSupply with no user paying it) → eroded the ActiveSupply>=UserSupply solvency relation by btcFee/tranche, socialized to the last withdrawer. Fix: fund the fee from FeeSupply (the protocol reserve accrued from unmap vscFees) instead of ActiveSupply. The invariant Sum(UTXO)==ActiveSupply+FeeSupply still holds (both the sweep and FeeSupply drop by btcFee); ActiveSupply is untouched so solvency can never be breached. If the reserve can't cover the fee, the sweep ABORTS (fail-safe: the gen keeps its UTXOs) rather than silently socializing a principal loss. safeSubtract64 (int64 wrap) + explicit <0 guard (money-math F-2, below-zero) both covered. Tests: sweep test seeds a FeeSupply reserve; new TestMigrateVaultRejectsWithout FeeReserve proves the no-reserve abort is fail-safe (gen stays retiring, input not deleted). Full suite green except pre-existing TestAllOperations. The fuller coverage model (a rotation fee charged to users, or an explicit reserve subsidy) remains a refinement; this is the minimal version that never lets the books lie. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes part of the S2-close F-4 test gap: proves the NN#3-gated rotation lifecycle end-to-end — a second rotation is refused while gen-0 still holds the deposit, then allowed once migrateVault drains it (gen-2 minted). Funded old keys cannot pile up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unmap selection Contract-side addendum that the node's S3 output-scoped signing depends on. - UnsignedSigHash gains Amount (msg "am"), populated from utxo.Amount in signSpendTransaction; msgp codec regenerated (v1.6.3, matches go.mod). The go-vsc-node TSS layer needs the spent-input value to INDEPENDENTLY recompute each input's BIP143 sighash and confirm it matches what a retiring-gen key is asked to sign, before contributing a share (NN#1). Optional on the wire (msgp map) so old blobs decode as 0; a lied amount cannot match the real sighash, so the node fails closed, never a theft. - D-1: getInputUtxoIds selects ONLY active-generation UTXOs for user unmaps. A retiring/draining gen's UTXOs leave the vault ONLY via a migration sweep; dragging one into an ordinary unmap makes the retiring key sign a user-address output, which the node refuses, stranding the already-debited withdrawal. The filter engages ONLY while a superseded fund-holding generation exists (mid- rotation); the common single-active-gen and pre-vault paths are byte-identical. Fails closed if a UTXO's generation cannot be confirmed. Tests: new TestUnmapExcludesRetiringGenUtxo (post-rotation unmap refused with NO debit, retiring UTXO untouched) + all rotation/migration tests pass. WASM rebuilt; full suite green bar the 3 pre-existing TestAllOperations blocklist failures (base 7ecbf9f, unrelated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…en count alone (MED) getInputUtxoIds gated filterGen on `activeIdx >= 0 && ...`, which silently disabled the active-gen-only unmap filter in a corrupt 0-Active state — reopening the D-1 silent-debit-without-delivery hole (methodology money-math MED). Now filterGen derives from the superseded (retiring/draining) count alone, and if superseded gens exist with NO active successor to serve the unmap from, the unmap is hard-refused (fail closed, no debit) rather than falling through to unfiltered selection. Honest single-active-gen and pre-vault paths are unchanged. WASM rebuilt; full suite green bar the 3 pre-existing TestAllOperations blocklist failures; TestUnmapExcludesRetiringGenUtxo still passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…exempt pending confirm - BRK-4a (FS-1/FS-2/FS-4/FS-5 H-3): raise MaxBlockRetention 1080 -> 4608 (>= mainnet CSV 4320 + ~2-day reorg margin). A CSV-backup recovery or a pause-outlasting pending sweep otherwise references block headers that get pruned before it can be SPV-verified/reconciled — a recoverable freeze that would degrade past the primary path. Single usage (blocklist prune); ~369 KB header storage. - BRK-4b (FS-1/V-8): confirmSpend of an ALREADY-PENDING spend (in the TxSpends registry) is now EXEMPT from pause — it reconciles an already-authorized, already-broadcast spend (no new funds move), so pausing it merely strands an in-flight migration/withdrawal. Any other confirm stays pause-gated. Check moved into HandleConfirmSpend (has cs.TxSpendsList); export no longer blanket- pauses. Test: TestConfirmSpendPendingExemptFromPause (pending confirm succeeds while paused). Full suite green bar the 3 pre-existing TestAllOperations blocklist failures; the retention raise does not affect them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HandleMigrateVault previously settled at BUILD what is only true at CONFIRM: it indexed the sweep output, deleted the swept inputs, and debited the miner fee before the async sweep could confirm (or reorg out) — a phantom output, orphaned inputs, over-stated supply, and (guard-5/A-F2) a superseded gen that looked drained before its funds moved. BRK-1 defers all three to confirmSpend under the sweep's SPV proof: - BUILD writes an "ms-"+txId record (input ids, reserved fee, successor addr+gen) + the "d-" signing data; it touches neither the UTXO set nor supply, so Sigma(UTXO) == ActiveSupply + FeeSupply holds trivially and the inputs STAY in the registry (AnyFundedSupersededGen keeps NN#3 blocking the next rotation until the sweep confirms — guard-5 + A-F2 closed for free). - CONFIRM (settleMigrationSweep) does the atomic swap: index the output(s) to the successor, delete the inputs, debit the fee — a single conserving step. - Fee is CHECK-at-build (FeeSupply >= sum of pending sweep fees + this), DEBIT-at-confirm, so every deferred debit is guaranteed (no post-L1 brick). - getMigrationInputs excludes in-flight-sweep inputs (no double-sweep). - Confirm TRUSTS the recorded successor (SPV txid commits to the outputs), guarded by a conservation assert (outputs == inputs - fee, >=1 output). - Node side unchanged (reads only "p"/"d-", never "ms-"). Pre-mortem (3 lenses) drove the design: cut the owner-abort footgun, moved the fee to a build-time reserve check, trust-not-re-derive the successor. Tests extended to the full migrate->confirm cycle and the stronger NN#3 (a gen is drained only when its sweep CONFIRMS). Inert behind the rotation flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…scan) The BRK-1 council (5 lenses) found the shipped pendingMigrationState scanned the general TxSpendsList — which an unprivileged unmap flood can inflate without bound (never-broadcast spends can't be confirmed away) — so a cheap attacker could gas-blow-up the owner-only migrateVault, stop gens draining, and (via NN#3) PERMANENTLY freeze key rotation. Funds stay safe (never-brick holds) but rotation liveness is DoS'd — the exact property this work hardens. This was a deviation from BRK-1's own revised design, which specified a dedicated list. Fix: add cs.MigrationSweeps (TxSpendsRegistry, state key "msl") — a strict subset of TxSpendsList holding only migration-sweep txids, written 1:1 with the "ms-" record (appended at build, removed at confirm), same proven idiom as TxSpendsList. pendingMigrationState now iterates this bounded list, so the exclusion + fee-reserve scan cost is O(concurrent sweeps), independent of TxSpendsList size. Loaded/saved in init.go alongside TxSpendsList. Also adds a conservation-DELTA assertion to the migrate->confirm test (council conservation-lens N3): ActiveSupply untouched (X-2) and Sigma(UTXO) drops in lockstep with FeeSupply across confirm. Council verdict (all 5 lenses): conservation holds, fully deterministic, no double-settle, no reachable permanent brick / fund loss; A-1 was the only actionable finding. Full suite green. Inert behind the rotation flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he map path BRK-1 pruned methodology (6 lenses over BRK-1 + connections) — composition (M1) and drift (M4) independently found S2-1 (LOW, funds-safe liveness): HandleMap -> updateUtxoSpends strips ANY tx with a "d-" signing record from "d-"/TxSpendsList, including a migration sweep, but does not settle it or touch "ms-"/MigrationSweeps. If the confirmed sweep tx is submitted via the permissionless map path first, the sweep vanishes from TxSpendsList while still unsettled in "ms-"/MigrationSweeps: the later confirmSpend loses its BRK-4b pause-exemption (isPending=false) and a TxSpendsList-keyed monitor reads it as reconciled -> confirmSpend may never fire -> NN#3 rotation freeze. No fund loss / no double-sweep (exclusion+fee key off the intact "ms-"/MigrationSweeps), recoverable, but a liveness hazard. Root fix (M4): updateUtxoSpends refuses to strip a txid with a live "ms-" record - a migration sweep reconciles ONLY via confirmSpend's settle. Defense-in-depth: the pause-exemption also recognizes the "ms-" record directly. Regression test TestMigrateSweepSurvivesMapThenPause: map the confirmed sweep as an unrelated caller -> assert it stays pending -> pause -> confirm still settles. Also: corrected the settleMigrationSweep idempotency comment (idempotency is the "ms-" absence gate one layer up, not the input-missing guard), and added the MigrationSweeps-cleanup assertion to the migrate->confirm cycle test. Methodology verdict (all 6 lenses): fingerprint (unmap safe-by-difference, no sibling), node-seam (genuinely unchanged, fail-closed), determinism, order, and state-drift all clean; S2-1 was the only actionable finding. Full suite green. Inert behind the rotation flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/g/h) Pairs with the node slice (gvn-vault-rotation): the node produces + on-chain- verifies a canonical check-signature with a fresh vault key and exposes the result as a 4th TssGetKey field (only when vault-rotation-v2 is chain-active). This slice makes the contract REQUIRE it before activating a generation, so funds can never route into an agreed-but-unsignable vault (brick council FS3-1). - (f) attestPrimaryKey: after the pubkey-match attestation, require the check-sig flag via checkSigVerified(parts). Gated by field PRESENCE — a v2-off node returns the legacy 3-field string (no requirement), so pre-v2 operation and the inert path are unchanged; a present-but-not-"1" flag fails closed. - (g) genesis gated / gen-0-fold exempt (structural, no new code): genesis activation flows through attestPrimaryKey (RegisterVaultKeys + Activate...) so it inherits the gate; FoldLegacyGen0IfNeeded sets Active directly (never via attest) so the legacy fold is correctly exempt. - (h) signSpendTransaction: assertInputsSignable defense-in-depth — never request a real spend-sig for a non-fund-holding (e.g. Pending) generation. Inert-safe: absent registry = no-op; folded gen-0 Active passes; only an input whose gen is present AND not fund-holding aborts. Inert in the current suite (the pinned test-node's TssGetKey returns 3 fields); the flag-REQUIRED end-to-end path needs a local vsc-node replace with BRK-2 = integration/devnet debt. TinyGo WASM builds; full tests/current suite 87/87 + new native TestCheckSigVerified green. NOT pushed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…IVE→PURGED) The S5 tail S1/S2 deliberately left unbuilt. New owner-only, pause-gated retireVault op → ReconcileRetiringVaults reconciles every superseded gen's status against the live UTXO registry + BTC height: - DRAINING & registry-empty → INACTIVE (records InactiveHeight grace anchor) [leg a] - INACTIVE & funded → DRAINING (revert; a late deposit re-funded it; match-until-purged) - INACTIVE & empty & grace≥144 & attestation → PURGED [legs a+c(+d stub); b=address retire] S5.0 destroys NO keys (S5.1's job); PURGED only retires the gen's address out of the matchable set. isFundHoldingStatus now includes INACTIVE. VaultEntrySize 87→91 for the InactiveHeight anchor. leg (d) (zero-balance oracle attestation) is a tracked stub; the hard deploy gate blocks any pin until it is real + devnet-proven. Brick council (6 decorrelated lenses) fixes folded in: - HIGH: unmap hasSuperseded now includes INACTIVE — the widened isFundHoldingStatus had reopened the D-1 silent debit-without-delivery hole for INACTIVE gens. - MED: NN#3 AnyFundedSupersededGen now includes INACTIVE (rotation gate must see a re-funded emptied gen). - LOW: retireVault aborts on an unavailable block height instead of anchoring at 0. - doc: stale VaultEntrySize=87 comment. Tracked (not fixed here): un-sweepable-dust griefing blocks purge + freezes rotation (the deferred V-1, now security-load-bearing); schema needs a live "v"-length check before any in-place deploy; leg (d) + S5.1 fail-stop. 8 retireVault integration tests + unmap-excludes-INACTIVE regression + fold-inert test (tests/current, run under WasmEdge in CI). TinyGo WASM build green; mapping unit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Vault-derived
Permissionless reportUnauthorizedSpend(spvProof): SPV-verify a confirmed BTC tx; if it
spends a CURRENTLY-REGISTERED vault UTXO whose txid is NOT an authorised in-flight spend
(∉ cs.TxSpendsList), it TRIPS the deterministic BtcTheftHaltKey ("th") the node keysign
gate reads. Owner-only clearTheftHalt resumes after resolution. Magi's SPV advantage =
one honest proof suffices (no 2/3 observation vote, unlike THORChain).
★ No append-only authorised-tx ledger needed (the V-7 requirement is obviated): a spend's
inputs LEAVE the registry when spent (unmap at build, migration at confirm), so a confirmed
legit spend can't false-flag. 3-lens council: false-positive claim PROVEN (no legit spend
trips); determinism fork-safe; faithful to SlashVault for the primary key-theft case.
Council fixes folded: corrected the overclaim (M1.1b covers REGISTERED-UTXO theft, not "the
whole anti-theft guarantee" — the never-destroy pivot is safe on its own); FN-5 keep-detector-
alive on a bad UTXO load; documented the coverage boundary (un-mapped deposits / pre-confirm
outputs / stuck-unmaps need the solvency watch + unmap-delete-at-confirm).
classifyReportedSpend 6-case unit test + full mapping suite green; TinyGo WASM builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A gen's FIRST migration tranche (taken while RETIRING, before the RETIRING→DRAINING flip) is capped to MigrationCanaryValue (0.01 BTC) so only a small "test" amount moves to the new successor vault first; subsequent DRAINING tranches drain the bulk at MaxUtxoAmount. Bounds first-move exposure; defense-in-depth atop BRK-2's pre-activation sign proof + NN#1 output- scoping. getMigrationInputs parameterized with maxTrancheValue. Governance-tunable (set to MaxUtxoAmount to disable — a clean no-op equivalence). Council fixes folded: - F1 (MEDIUM, a regression the canary INTRODUCED): a small canary tranche of several small UTXOs can trip fee>half/dust at a high fee rate → the gen would wedge RETIRING forever (NN#3 then blocks rotation). FIXED: on a canary build failure, FALL BACK to a full-cap tranche (amortizes the fee); the full tranche also failing = the genuine V-1 dust residual, abort as before. The canary can no longer block a migration. - F2 (LOW): corrected the overclaim — the bulk is NOT contract-gated on the canary confirming (BRK-1 only excludes the canary's own inputs); confirm-before-bulk is operator discipline. A confirm-gate (serialise canary before any DRAINING tranche) is a tracked stronger variant. Verified: exactly one canary per gen (RETIRING→DRAINING is the only producing transition); determinism preserved (cap from consensus status, slice-order selection). TinyGo WASM green. Canary integration test (F1 threshold + transition) tracked for tests/current (CI/WasmEdge). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-lens council fixes) The pre-pin fee-reserve blocker plus the unmapped half of BRK-1 delete-at-confirm, hardened by a 5-lens council (topup-conservation, guard1-conservation, exclusion/theft-gate, adversarial/determinism, fail-safe/never-brick). topUpFeeReserve (fee_reserve.go): FeeSupply's only credit was unmap vscFee (hardcoded 0), so the migration reserve gate would wedge rotation 1. Operator SPV-proves a real BTC deposit to the active vault untagged address; it is indexed as a confirmed active-gen UTXO and credits FeeSupply by the value (conservation exact; ActiveSupply==UserSupply untouched). Permissionless + pause-exempt; never-terminal (a fee-starved migration writes zero state, so a later top-up re-drives the identical tranche). Guard-1 unmap delete-at-confirm: HandleUnmap keeps inputs registered + reserved, stores a "us-" record, and defers input-delete + change-index to settleUnmap at confirm. Closes debit-without-delivery (stranded inputs), M1.1b FN-3 (a rogue re-sign of a still-registered input is now theft-detected), and S5.0 F1/F2. Council fixes: - D-1/C-1 (HIGH, 2 decorrelated lenses): topUpFeeReserve double-credited vault-own outputs -- an unmap change / migration sweep pays the SAME untagged vault address, and the settle paths never wrote the observed list, so anyone could re-submit a confirmed change/sweep output and inflate FeeSupply + double-index the outpoint (Sigma==Active+Fee stays balanced, so no assert caught it). Fix: settle + promotion paths now record their outputs observed (markOutpointsObserved) [post-settle window] AND topUp refuses a tx carrying a live us-/ms-/TxSpendsList entry [pre-settle window]. - B-1 (MED, upgrade-path): the confirmSpend + updateUtxoSpends promotion loops re-id'd a reserved UTXO out from under its "us-" record (strand + double-select). Fix: skip reserved ids in both promotion loops. - B-2 (LOW): settleUnmap now carries the change<=swept-inputs conservation sanity assert its migration twin (settleMigrationSweep) already had. - C-3 (LOW): document that the unmap selector excludes in-flight migration inputs via the generation filter (not the reservation marker). Tests: full tests/current suite green (97 pass). The 3 unmap-build tests' RcLimit raised 10000->20000 -- delete-at-confirm raises unmap build cost ~24% (1.09e9 gas vs base 8.8e8; ~11% of the 100k-RC / 1e10-gas hard max), an inherent bounded cost of the fund-safety mechanism, NOT a brick. This regression was missed by the council (which did not run tests) and caught by the suite. All INERT behind the unchanged hard deploy gate (VaultRotationV2ActivationHeight=0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o slashing)
Monotonic terminal wind-down. The `ragnarok` export (checkOwner / Hive-multisig-
gated, the SAME authority as pause) sets RagnarokModeKey "rg"="1"; no un-setter and
no code path deletes it ("no going back", mirrors THORChain RagnarokInProgress).
While set, checkNotRagnarok freezes 12 ops: map, unmap/unmapFrom, transfer/
transferFrom, approve, increase/decreaseAllowance, and the rotation-mint ops
registerPublicKey/createKey/activateKey plus discardPendingKey/registerRouter.
Kept LIVE: migrateVault, confirmSpend, topUpFeeReserve, reportUnauthorizedSpend,
renewKey, and the oracle header path — so depositors can be made whole and the
vault consolidated during wind-down.
claimRagnarok export (ragnarok-REQUIRED, pause-EXEMPT) -> HandleClaimRagnarok (new
contract/mapping/ragnarok.go): returns the caller's ENTIRE flat balance 1:1 by
delegating to the existing HandleUnmap (DeductFee=true, From=""), inheriting the
Guard-1 delete-at-confirm mechanics (us- record, reserved inputs, settle-at-confirm)
verbatim. Self-serve, no cursor / no exchange rate (mapped BTC is a flat balance).
Byte-INERT when "rg" is absent (every gate short-circuits; the 97 pre-existing tests
pass unmodified, proving inertness) and behind the unchanged hard deploy gate
(VaultRotationV2ActivationHeight=0). NO slashing (deferred by directive).
Tests: +9 (tests/current/ragnarok_test.go) — flag auth/monotonicity, all-gated-ops
freeze, stays-live set, claim gate / dust-floor / happy-path+settle / pause-exemption
/ rotation-residual interaction. Full suite green (106 pass, 0 fail).
Tracked build-map open items (none block; all inert): D-5 DEX-pooled BTC is NOT
reachable by a flat-balance claim (the wind-down must first return LP-pooled BTC to
depositor flat balances — an orchestration step in the separate DEX router contract);
D-4 committee-below-threshold auto-trigger is not contract-observable (governance-set
only); a claim mid-rotation is payable only from ACTIVE-gen backing (keep calling
migrateVault to drain superseded gens first — the D-1 active-gen filter); topUp /
reportUnauthorizedSpend staying-live is verified by code inspection (SPV-fixture
runtime test is test-debt for the final methodology).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion, no slashing) An un-drainable sub-dust residual on a superseded generation deadlocks rotation (NN#3 never sees the gen empty). Fixed per the THORChain-anchored build-map. writeOffDust (owner-only + pause-gated, new dust_writeoff.go): force-retires a superseded gen whose ENTIRE residual is provably un-sweepable at the FIXED 1 sat/vB floor (isResidualUnsweepableAtMinFee mirrors buildMigrationTransaction's abort conditions; NEVER reads the oracle BaseFeeRate — a rogue rate must not inflate the "dust" ceiling and destroy real UTXOs). Count-aware (prices the actual sweep), so a gen mixing dust with real funds is left for the normal sweep. Deletes the dust UTXOs + debits ActiveSupply+UserSupply in lock-step (Sigma(UTXO)==Active+Fee and Active==User held EXACT); excludes in-flight ms-/ru- inputs (no stranding); flips Retiring->Draining so the existing reconciler carries it to Purged. Deterministic (map for keyed lookup only; deletion iterates the vault slice). MinDepositSats=1000 floor in indexOutputs: never credits a future sub-floor deposit, GATED on hasSupersededGen (inert-until-first-rotation — pre-rotation map behavior is byte- identical). A single 1000-sat deposit is sweepable with margin at any rate>=1 (1000-144 > dustThreshold 546). Accounting (Magi has no Reserve, unlike THORChain): prevention (floor) never-credits future dust so conservation holds trivially; the rare LEGACY (pre-floor) dust write-off accepts a bounded, sub-dust UNWITHDRAWABLE I3 slack (Sigma(balances)==UserSupply+D) since the Utxo blob carries no recipient for exact per-account claw-back. Protocol solvency (I1) is preserved. [TRACKED decision: engage the floor from deploy instead of gating it, to eliminate the legacy slack entirely — trades dark-launch purity for zero slack.] Tests +4 (dust_writeoff_test.go): grief sequence defeated, legacy write-off conservation, inert-pre-rotation floor, in-flight-input exclusion. Full suite green (110 pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…inert, no slashing)" This reverts commit 1558f5c.
FULL-PRUNED (2026-07-09), verified by two 3-lens councils. Suite green.
- L1-1 unmap charges the TRUE miner fee (inputTotal - Sum(outputs)) so a sub-dust
change dropped to the miner no longer under-collateralizes the vault; the
built tx is byte-identical (unmapping.go).
- L10-1 topUp + the confirmSpend pause-exempt gate use an O(1) keyed "d-<txid>"
membership check instead of an O(N) scan of the permissionless-inflatable
TxSpendsList (invariant: "d-" record <=> list entry, verified lockstep
across all 4 mutation sites). Removes an unprivileged O(N) gas-DoS
amplifier (fee_reserve.go, handlers.go).
Deferred (pre-existing base, don't-fix-mainnet): L1-1 deduct-fee sub-dust residual
(<=546 sat, absorbed by the fee reserve, I1 preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ult spends Foundation for the stuck-tx re-drive (L7-01, spec v2 council-approved). Every input of every unmap + migration sweep (shared addInputsWithWitnesses) now carries nSequence 0xfffffffd so a never-confirming spend can be reliably fee-bumped instead of wedging rotation forever. 0xfffffffd: (a) BIP-125 opt-in RBF signal; (b) bit 31 set => BIP-68 relative-locktime DISABLED, so no interaction with the vault's OP_CSV backup branch (verified: separate spend path / separate tx; 4 independent reasons in the RBF spec-judge). The node's output-scoping gate recomputes the BIP143 sighash from the transmitted tx bytes (RecomputeSegwitV0Sighash -> ParseTx -> NewTxSigHashes), so it reads this nSequence rather than assuming a default -> node side unchanged, no self-brick (verified at source: script.go:124, output_scoping.go:351). Full contract suite green (self-consistent test proofs absorb the txid change; no golden real-txid fixtures exist). Inert behind the deploy gate. The redriveSpend action + spend-group settle-on-either that USE this signal are the next increments (spec v2 sections 3b-3f + the P1/P2/H2 corrections). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Data-layer foundation for the stuck-tx re-drive (spec v2). Behaviour UNCHANGED (new fields populated at build, settle still per-txid until the next increment). - PendingUnmap += BtcFee (true fee this tx pays; the re-drive delta/refund basis) and BuildHeight; MigrationSweep += BuildHeight (the deterministic re-drive staleness clock, from LastHeight "h"). Hand-packed marshal/unmarshal updated (new fixed fields before the variable-length address tail; branch unreleased so no on-chain records exist to migrate). - SpendGroup struct + "g-"+<minInputId> codec (MarshalSpendGroup) + spendGroupKey helper: the D1-B single group object keyed by the minimum reserved input id (deterministic, stable across an original + its RBF replacements, unique per live spend). Written lazily on the first re-drive (unused until then). - currentLastHeight() reads "h" for the BuildHeight clock; populated at both build sites (HandleUnmap, HandleMigrateVault). Validated against a FRESHLY REBUILT bin/dev.wasm (the suite embeds it): full contract suite green. NOTE: the suite embeds a prebuilt dev.wasm, so the WASM MUST be rebuilt before testing (scratchpad/build-wasm.sh) — prior L1-1/L10-1/nSequence runs were re-validated here against fresh bytecode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refactor the delete-at-confirm cleanup so a settle of ANY spend-group member clears
the WHOLE group atomically (spec v2 H2). Behaviour UNCHANGED until re-drive exists:
with no group object every settle is a group-of-one, byte-identical to the prior
per-txid cleanup.
- clearSpendGroup(confirmedTxId, inputIds): reads the lazy "g-"+<minInputId> group
object (absent ⇒ {confirmedTxId}); deletes every member's us-/ms-/d- record and
removes it from TxSpendsList + MigrationSweeps; deletes the group object. Defensive:
always clears the confirmed txid's own records even if a corrupt group omits it.
- HandleConfirmSpend captures the settled record's InputIds (the group key) and calls
clearSpendGroup; a confirm with no pending record (idempotent replay / non-vault tx)
cleans only that txid's stray signing data, as before.
- removeTxid helper (shared swap-remove).
Why H2 matters: an incomplete cleanup would leave a dangling ms-/d- record + list
entry with its inputs already deleted — inflating pendingMigrationState forever
(re-arming the NN#3 freeze) and uncleanable via confirmSpend's fail-closed input guard.
Full contract suite green against a freshly rebuilt bin/dev.wasm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plumbing for the sweep re-drive; behaviour-preserving for the normal migrate path. - buildMigrationTransaction gains a prevFee arg. When >0 (a re-drive of a stuck sweep) it floors the fee at max(current oracle fee, prevFee + minBump) where minBump = RedriveIncRelayFeeRate * vSize (BIP-125 rule 4; vSize = fee/rate, exact), so the replacement reliably out-fees the stuck original whether the fee spike persists or eased. The V5-4 fee<=totalInputs/2 ceiling doubles as the re-drive affordability gate. Normal migrate passes prevFee=0 → byte-identical. - pendingMigrationState reserves ONE fee per spend group (the MAX committed member, keyed by min-input-id) instead of summing every member (D6/H6): a stuck original + its higher-fee replacement are one group of which only one can confirm, so summing both would double-hold FeeSupply and wedge other migrations. Group-of-one unchanged. - Constants RedriveStaleBlocks=12, RedriveIncRelayFeeRate=2. Full contract suite green against a freshly rebuilt bin/dev.wasm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…IGH)
The fund-critical core: an owner-gated, pause-gated `redriveSpend` that re-signs a
STUCK, never-confirming migration sweep into a higher-fee BIP-125 RBF replacement
over the IDENTICAL reserved inputs — so a fee-spiked / mempool-evicted sweep can
finally confirm instead of wedging all future BTC vault rotation forever (L7-01).
- HandleRedriveSweep (contract/mapping/migration.go): reads the stuck "ms-" record;
staleness gate (>= RedriveStaleBlocks since BuildHeight); rebuilds over EXACTLY the
recorded inputs + successor (H4/P6, never the selectors → no non-conflicting
double-spend); fee floored at max(oracle, groupHighest + BIP-125 minBump);
FeeSupply reserve check for the bump delta (H1/D6 — settle's deferred debit can
never underflow); re-signs via the UNCHANGED "d-"/TxSpends path (node side
untouched, like BRK-1); writes the replacement "ms-"/group and updates the D1-B
spend group (O(1), one group-object write).
- redriveSpend wasmexport (contract/main.go): owner + pause gated (D2/D5).
Fund-safety: identical inputs ⇒ Bitcoin confirms AT MOST ONE of {original,
replacements}; a settle of EITHER clears the whole group atomically (A2/H2), so no
double-spend and no dangling record can re-arm the freeze. The bumped fee is debited
only at settle for the confirmed member's own recorded fee (H3/P1).
Tested end-to-end (TestRedriveSweep_BumpsFeeAndSettlesEither): staleness refusal,
fee bump, identical inputs, group of {original, replacement}, and confirming the
replacement clears BOTH ms-/d- records + the group object + drains the gen. Full
contract suite green against a freshly rebuilt bin/dev.wasm.
Remaining L7-01 (lower severity than this HIGH): the UNMAP re-drive (a stuck user
withdrawal is user-liveness, NOT the network freeze — unmap inputs are active-gen,
never superseded) and the sub-dust forced-retire residual — plus the build council
+ full pruned re-run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-drive support for a stuck WITHDRAWAL (lower severity than the sweep freeze: a stuck unmap strands only that user's own withdrawal — its inputs are active-gen, never superseded, so NN#3 is not triggered — user-liveness, not a network freeze). - HandleRedriveUnmap (unmapping.go): CLONES the stuck unmap's outputs and reduces ONLY the change output (identified by pkScript), NEVER the user destination (spec v2 RBF-2/H4); re-signs over the identical inputs with a higher BIP-125 fee. If the bumped change falls to/under dust it is OMITTED (residual → fee, no loss); a no-change stuck unmap can't be re-driven this way (surfaced, not the freeze). - Fee model (differs from the sweep, which reserves+debits): the unmap's fee was DEBITED from the user at build, so the bump is vault BTC the operator covers — FeeSupply is CHARGED the incremental bump at re-drive (fail-closed), and the unused portion is REFUNDED at settle if a cheaper member confirms (H1/P2). Refund lives in HandleConfirmSpend's unmap branch (unmap groups only; sweeps reserve+debit). - HandleRedrive dispatcher + the redriveSpend wasmexport now routes ms-/us- records. Tests (l7_redrive_test.go, both green against fresh bin/dev.wasm): - Sweep: end-to-end incl. createKey UNWEDGING after the re-driven sweep settles (closes build-council gap #1 — proves the freeze actually lifts, not just the mechanism). - Unmap: fee bump, user destination PRESERVED byte-for-byte, only change reduced, bump charged from FeeSupply. Build council on the sweep re-drive: determinism/conservation SOUND ("ship", I1 holds exactly), correctness/integration FIXES-IT (NN#3 unwedge traced end-to-end); the only weakness they flagged was test coverage, partly closed here. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l-closed group Build council (fund-safety lens) on the L7-01 re-drive found the sweep re-drive SOUND and two defects in the unmap re-drive (C); both fixed here. - MEDIUM (NN#3 re-arm): HandleRedriveUnmap DEBITED FeeSupply gated only on FeeSupply >= charge, ignoring the pending migration-sweep reserve. A stuck-unmap re-drive could drain FeeSupply below a pending sweep's reserve → settleMigrationSweep's deferred debit underflows → the confirmed sweep can't settle → the superseded gen never drains → the freeze re-arms. FIX: gate the charge on the FREE reserve (FeeSupply − pendingSweepReserve >= charge), mirroring the sweep build's reserve check. - LOW (H2): both re-drive handlers re-seeded a fresh spend group when the existing group object failed to decode, DROPPING prior members → dangling records. FIX: fail CLOSED (refuse the re-drive on a corrupt group object) in both handlers. Sweep re-drive confirmed SOUND across all 5 attack classes (double-spend, double-settle, reserve underflow, griefing, replay/reorg). Full contract suite + both re-drive tests green against a freshly rebuilt bin/dev.wasm. §3f dust forced-retire analyzed as UNNECESSARY (no safe reachable trigger — see the spec BUILD OUTCOME section): the V-1 dust abort prevents sub-dust sweeps, and un-re-drivable = transient fee spike covered by a later re-drive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pruned re-run's fresh cold-scan confirmed the fund-critical core SOUND (I1 holds every branch, reserve invariant preserved, group key collision-free, rogue owner can't redirect a sweep). It surfaced four owner-gated / robustness items, all closed here: - A [LOW-MED]: HandleRedriveUnmap now mirrors HandleUnmap's D-1 guard — refuses BEFORE charging if the stuck unmap's inputs are no longer on the ACTIVE gen (the active gen rotated to retiring while it was pending), since the node output-scopes a retiring-gen keysign to its successor and would refuse a user-output replacement. Prevents a wasted re-drive + transient FeeSupply over-charge. - B [LOW]: MaxSpendGroupMembers=64 cap on both re-drive handlers — bounds a rogue-owner re-drive storm below the uint16 member-count truncation (65535, which could dangle siblings) and the O(members) settle cost (RC-limit brick). 64 >> any honest need. - C [LOW/INFO]: unmap re-drive now enforces the sweep's 50%-of-inputs fee ceiling — bounds a rogue-owner change-burn / runaway bump. - F [INFO]: the two minBump multiplies now use safeMultiply64 (belt-and-braces; the inputs are already bounded). D (FeeSupply exhaustion via many stuck unmaps — topUp-recoverable) and E (spendGroupKey empty-panic — unreachable without corruption) accepted as noted. Pruned re-run verdict: conservation SOUND, cross-module composition SOUND (all 8 seams), cold-scan SOUND on funds. Full suite + both re-drive tests green vs fresh bin/dev.wasm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branch was 21 commits behind origin/main. Resolved 7 conflict hunks keeping BOTH the upstream fixes and this branch's vault-rotation changes: - handlers.go: keep the upstream per-block unmap rate-limit + our TSS-signing comment; keep the upstream empty-indices confirmSpend guard AND our BRK-4b pause-exempt logic; track promotedVouts (D-1/C-1 observed-guard) AND reject when nothing was promoted (upstream). - unmapping.go: keep our true-fee recompute (equivalent to the upstream actual-fee fix, kept once) + assertInputsSignable; combine per-generation VaultKeyId signing with the upstream revert-on-signing-fail guard. - confirm_spend_test.go: keep our pause-exempt test + the upstream updated comment. Verified: gofmt -e clean + `go build ./contract/mapping/` clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…anding every sweep and withdrawal
HandleConfirmSpend promotes UTXOs from the UNCONFIRMED pool and then aborts
with "no unconfirmed outputs matched the provided indices" when it promoted
none. But a delete-at-confirm spend indexes nothing at build BY DESIGN, and a
fresh deploy creates no unconfirmed UTXOs at all (as the comment on the
isUtxoReserved guard already states). So on a fresh deployment the abort always
fires and the two settle branches below it are unreachable:
- settleMigrationSweep (handlers.go:455) — a retiring generation can never
drain: the sweep is signed, broadcast and mined, but the swept inputs are
never deleted and the successor never receives the outputs. Rotation cannot
complete, and the fund-gated retire/purge never releases.
- settleUnmap (handlers.go:470) — an ordinary user withdrawal can never
settle either: the BTC leaves the vault on-chain while the contract keeps
the spent UTXO on its books.
Fire the empty-promotion guard only when there is ALSO no pending spend record
("ms-" migration sweep / "us-" unmap) to settle, so a legitimate
delete-at-confirm settle proceeds while the anti-griefing behaviour the guard
was added for (BTC-L-CONFIRMSPEND: never wipe signing data on a no-op confirm)
is preserved for confirms that match nothing. markOutpointsObserved is now only
called when something was actually promoted.
Devnet-proven on a 5-node devnet against regtest BTC (TestVaultStage4Rotation),
before vs after, node PR #236 unchanged:
before: confirmSpend FAILED; gen-0 kept its 100000000-sat deposit UTXO (u-400)
after: confirmSpend CONFIRMED; u-400 deleted; new u-402 owned by GENERATION 1
holding 99998570 sats (= 100000000 - 1430 miner fee), gen-0 drained
All existing confirmSpend unit tests still pass, including the empty-indices and
non-matching-indices griefing reverts.
The rotation-drive ops migrateVault, retireVault, writeOffDust and redriveSpend
were all owner-only (checkOwner), but the mapping-bot driver submits L2
transactions as its own did:pkh identity, and the mainnet owner (hive:vsc.dao)
is a threshold-1 single-key governance account. So a plain bot could not drive
the rotation at all, and the drain depended on one key being online — a liveness
single point of failure on the money-egress path.
Add a scoped operator (NOT a second owner: a full second owner would also gain
pause / router / key-registration):
- checkOperator() authorizes the owner OR an optional stored operator identity,
mirroring the existing checkAdmin / OracleAddress pattern.
- VaultOperatorKey ("vaultop") holds the operator identity (a hive: account or a
did: identity — a VSC caller is one of those two).
- setVaultOperator: owner-only, and deliberately REPLACEABLE (unlike the set-once
registerRouter) so governance can rotate or revoke the operator without a
redeploy; an empty payload clears it.
- checkOwner -> checkOperator swapped on those four ops ONLY; the other ten
owner-gated actions are untouched.
Safety: every operator-callable op is deterministic and self-validating
regardless of caller — a migration sweep's every output must pay the
consensus-committed successor, retireVault is fund-gated, writeOffDust is gated
on provable un-sweepability, redriveSpend only raises the fee on an already
authorized spend. A compromised operator key can move funds nowhere except
toward the elected successor vault, and gains no governance power; worst case is
fee-reserve griefing.
Proven by TestVaultOperatorAuthorization (owner appoints, operator passes the
four ops, stranger refused, governance stays owner-only, rotate + clear, both
hive: and did: accepted, prefixless rejected) and end-to-end on devnet by
TestVaultOperatorDrivenDrain (a non-owner drove the full drain + retire). No new
regressions in the contract suite.
Deployment: after deploy, governance calls setVaultOperator with the bot's
identity (and funds it for RC).
…tle) Adversarial guard on the confirmSpend fix (5176bb9), which made an already-settled spend's promotion legitimately empty. The risk that introduces is a REPLAY: settling the same SPV-confirmed tx twice must not double-index the successor output or double-debit the fee reserve. Builds a migration sweep, captures the exact confirmSpend payload BEFORE settling (after settle the ms-/d- records are gone and it could not be rebuilt), settles once, then resubmits the identical payload. Asserts the replay is REFUSED and that the UTXO registry and Supply are byte-unchanged by it — clearSpendGroup deletes the ms-/us-/d- records at settle, so the second call sees no record and no unconfirmed output to promote, and falls through to the empty-promotion rejection.
…se to this suite
`make test` in btc-mapping-contract could not produce a meaningful result. Three
independent causes, none of them in the contract logic.
1. The suite never built what it tested. tests/current loads its contracts from
artifacts embedded via //go:embed bin (contract_bytes.go) and registers two of
them: ContractWasm = DevWasm at 83 call sites, and Testnet3Wasm at 3 in
blocklist_test.go, which needs a testnet3-mode build because it replays real
BTC testnet3 block headers at heights 4888515-4888517. `make test` had no
dependency on either build, so `test` now depends on `dev` and `testnet3`.
Both targets already do their own staleness detection.
The artifacts are gitignored, not committed (.gitignore excludes *.wasm; a
fresh clone's bin/ holds only the tinyjson helper), which makes the failure
mode worse rather than better and gives it two shapes. On a fresh clone every
artifact is absent and contract_bytes.go's init() discards the load error
(`DevWasm, _ = loadWasmFile(...)`), so the vars are silently nil. On a
developer machine the suite runs against whatever was last built locally --
here a 2026-06-04 dev.wasm predating vault-rotation-v2 entirely, and a
2026-03-25 testnet3.wasm with no replaceBlocks export, so those subtests
failed with wasm_function_not_found.
2. `make dev` could not build for a non-root user at all: TINYGO_CMD runs the
container as the host UID while the image ships HOME=/, so Go resolves its
module cache to /go and the build dies with "mkdir /go: permission denied".
That is why stale artifacts survived unnoticed -- the documented rebuild
command did not work. Fixed with -e HOME=/tmp, matching go-vsc-node's
tests/devnet/contracts/{call-tss,btc-stub}/Makefile. /tmp deliberately rather
than the bind-mounted workdir: pointing HOME at $(WORKDIR) scatters go/,
.cache/ and .config/ (~355MB, mode 444, so a plain rm -rf cannot remove it)
across the repo.
3. Accounts invented by this suite hold ~0 HBD, so their gas -- min(availableRCs,
tx.RcLimit) -- falls back to params.RC_HIVE_FREE_AMOUNT, whose production
default of 10_000 is not enough for the SPV-heavy ops (map, migrateVault).
16 tests aborted with gas_limit_hit for reasons unrelated to the contract.
A TestMain now raises it for this test binary only.
That raise belongs here and not in go-vsc-node. RC_HIVE_FREE_AMOUNT is a
process-wide mutable global with no reset path, so raising it inside
MocknetConfig/DevnetConfig leaks into every later config built in the same
process, including Testnet and Mainnet, which never reset it. More decisively,
go-vsc-node's own modules/wasm/e2e TestContractTestUtil asserts the free-tier
boundary through the SAME test_utils.NewContractTest() harness and requires the
10_000 default: raising it there is not a trade-off but a contradiction, and
that test fails. Verified both directions by running it. Scoping the raise to
this binary leaves go-vsc-node untouched.
Verified by mutation from a fresh-clone state (bin/ emptied of every wasm),
against an UNMODIFIED go-vsc-node: `make test` emits "Building dev ..." and
"Building testnet3 ...", both artifacts come back exporting replaceBlocks, the
repo root gains nothing, and `git status` shows no stray files. Fixing only `dev`
was not sufficient -- that intermediate state still failed TestAllOperations,
which is what surfaced the second artifact.
Full suite: 118 of 119 pass. The single failure,
TestBTCC4_RouterFailureRefundsDepositor, reproduces identically on main with a
freshly built main wasm, so it is pre-existing and not from this branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiGdSKaTJMvSjBMKDcsfCp
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.
Summary
This PR introduces an explicit append-only, dual-generation Vault model with a full lifecycle, a fund-migration sweep that moves BTC from a retiring generation to its successor, and fund-gated retirement — so a rotated-away key becomes worthless once its generation is provably empty.
This is the contract half of a two-repo change (path:
btc-mapping-contract/in theutxo-mappingmonorepo). The node half (go-vsc-node) is a separate PR and deploys first. Both sides are gated onVaultRotationV2and ship inert — no behavior changes until the node emits the new ops and the activation height is pinned later, as a separate step.What's in this PR
(keyId, primary, backup, status, heights, balance); lifecyclepending → active → retiring → draining → inactive → purged; per-generation pubkey + UTXO sets. New ops:createKey,registerPublicKey,activateKey,discardPendingKey,renewKey.migrateVault) — enumerates all of a retiring generation's UTXOs, caps input count (each input = one TSS signature) with self-send consolidation when over the cap, signs old-generation inputs with the old generation's key while paying the successor's protocol-derived P2WSH, and indexes the successor-paid output into the new generation's registry via a dedicated internal-transfer path that never touches Supply/UserSupply.attestPrimaryKey) — a generation cannot be activated until a produced-and-verified test signature over a canonical domain-tagged message is on-chain (agreement on a pubkey ≠ ability to sign with it).confirmSpendafter SPV confirmation; the permissionlessmappath never strips an in-flight migration sweep or pending unmap (prevents premature deletion / false theft-halt).retireVault) — a generation is purged only after an SPV-proven zero L1 balance plus a grace window ≥ max BTC reorg depth; the time-based delete path is disabled under v2.Deploy order
Testing
tests/current/(run viamake test): vault lifecycle, deposit/dual-gen crediting, fold, migration/retire, confirm-spend promotion & empty-index rejection, allowance/auth, dust write-off, stuck-tx re-drive, and money-path edge cases — using real Bitcoin headers/transactions as fixtures. The full deposit → rotate → migrate → drain → retire cycle is additionally exercised end-to-end against a real regtest chain in the node repo's devnet harness.Reviewer guide
Note for the merger
This branch's base is behind current
main; rebase/mergemainin and resolve the handful of overlapping hunks inhandlers.go/unmapping.go/confirm_spend_test.gokeeping both the recent upstream fixes (unmap rate-limit, actual-fee recompute, empty-indices rejection, active-auth allowance, refund-on-revert) and this PR's changes before merge — several overlap the same functions. (The upstream actual-fee recompute and this PR's true-fee fix are the same fix.)