Skip to content

feat(btc-mapping): vault-rotation-v2 — dual-generation vault, migration sweep, fund-gated retirement - #29

Open
lordbutterfly-hive wants to merge 45 commits into
mainfrom
feat/btc-vault-rotation-v2
Open

feat(btc-mapping): vault-rotation-v2 — dual-generation vault, migration sweep, fund-gated retirement#29
lordbutterfly-hive wants to merge 45 commits into
mainfrom
feat/btc-vault-rotation-v2

Conversation

@lordbutterfly-hive

@lordbutterfly-hive lordbutterfly-hive commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

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 the utxo-mapping monorepo). The node half (go-vsc-node) is a separate PR and deploys first. Both sides are gated on VaultRotationV2 and 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

  • Dual-generation Vault state model — replaces the single overwritable pubkey slot with an append-only vault list (keyId, primary, backup, status, heights, balance); lifecycle pending → active → retiring → draining → inactive → purged; per-generation pubkey + UTXO sets. New ops: createKey, registerPublicKey, activateKey, discardPendingKey, renewKey.
  • Migration sweep builder (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.
  • Check-signature activation gate (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).
  • Delete-at-confirm — UTXO/vault records are removed only at confirmSpend after SPV confirmation; the permissionless map path never strips an in-flight migration sweep or pending unmap (prevents premature deletion / false theft-halt).
  • Fund-gated retirement (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.
  • Solvency / anti-theft — an unauthorized-spend auto-trip signal and pause exemptions for in-flight migration/withdrawal confirms (freezing them would strand funds, not protect them).
  • Stuck-transaction re-drive / RBF path — safely re-drives or fee-bumps a broadcast-but-unconfirmed spend without double-outflow (same-input replacement only; both txids registered).
  • Hardening: deposit-time dust floor, per-generation registry namespace, dust write-off accounting, fee-reserve top-up, and a one-time fold of the existing key + UTXOs into generation-0.

Deploy order

  1. Node PR (go-vsc-node) — first, inert.
  2. This PR — second, inert.
  3. Pin the activation height later, separately, after the full devnet cycle + testnet. Not in this PR.

Testing

tests/current/ (run via make 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

  • The contract is the entire signing-safety perimeter (the node signs a blind sighash). The migration builder asserting outputs==successor is consensus-re-executed by every node, so it is consensus-validated; it is paired with the node-side output check as defense-in-depth. Review the migration output-targeting and the delete-at-confirm ordering with that in mind.
  • Fail-closed everywhere: an absent/pre-fold registry is a no-op (legacy path unchanged); a corrupt/ambiguous vault registry fails closed, never fail-open.
  • Deferred (documented, not missing): dust-burn/reserve-subsidy, observation-matcher slash-on-mismatch, and retired-vault-recovery are dormant/stubbed follow-ups. None are load-bearing for the inert deploy.

Note for the merger

This branch's base is behind current main; rebase/merge main in and resolve the handful of overlapping hunks in handlers.go / unmapping.go / confirm_spend_test.go keeping 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.)

lordbutterfly-hive and others added 30 commits July 8, 2026 02:09
…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>
lordbutterfly-hive and others added 15 commits July 9, 2026 20:06
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants