Skip to content

feat(bridge,vault): add PSBT covenant migration debt and reveal guard#957

Open
lrsaturnino wants to merge 12 commits into
mainfrom
feat/psbt-covenant-bridge-port
Open

feat(bridge,vault): add PSBT covenant migration debt and reveal guard#957
lrsaturnino wants to merge 12 commits into
mainfrom
feat/psbt-covenant-bridge-port

Conversation

@lrsaturnino
Copy link
Copy Markdown
Member

@lrsaturnino lrsaturnino commented May 4, 2026

Audit positioning

This PR is the Bridge + Vault audit baseline for the Trail of Bits PSBT covenant assessment. Per the engagement-letter amendment of 2026-05-09, the SOW Scope URLs were rewritten to:

  • tlabs-xyz/tbtc-v2-ac#263 — AC smart contracts (consolidated baseline; supersedes tbtc/#132 and tbtc/#217)
  • this PR — Bridge migration-debt and reveal-guard work, including TBTCVault changes
  • threshold-network/keep-core#3882 — covenant signer

Coverage of this PR: Bridge migration-debt accounting and reveal guard (Bridge.sol, BridgeState.sol, BridgeGovernance.sol, Deposit.sol, DepositSweep.sol, MigrationExtraData.sol); vault migration-sweep hooks and optimistic-minting migration guard (TBTCVault.sol, TBTCMigrationDebtOperations.sol, TBTCOptimisticMinting.sol); storage-layout delta for proxy upgrade — BridgeState.__gap 48 → 47, new migrationDebtVault field.

Where to start reading

  1. solidity/docs/BRIDGE_STORAGE_UPGRADE_NOTES.md — required reading for SOW Smart-Contracts Q8 (storage upgrade considerations: compromised GOVERNANCE_ROLE upgrade, ERC-7201 namespace collisions, _revertIfLegacyStorageOccupied)
  2. solidity/contracts/bridge/MigrationExtraData.sol — encoding of extra-data fields used during covenant migration
  3. solidity/contracts/bridge/Deposit.sol — new migration-aware reveal guard with MigrationRevealRejected(bytes32) reason codes
  4. solidity/contracts/bridge/BridgeState.sol — storage layout delta
  5. solidity/contracts/bridge/Bridge.sol — migration-debt vault wiring, setMigrationDebtVault / rotateMigrationDebtVault
  6. solidity/contracts/vault/TBTCMigrationDebtOperations.sol — migration-debt accounting library
  7. solidity/contracts/vault/TBTCOptimisticMinting.solMIN_OPTIMISTIC_MINTING_FEE_DIVISOR = 10, migration-aware mint gate
  8. solidity/contracts/bridge/DepositSweep.sol — migration-sweep callback path with bounded fan-out

Invariants and reason codes (to verify)

Invariants asserted by this PR:

  • For every revealer, outstandingMigrationDebt(revealer) = sum(registered_deposits.amount) − sum(repaid_deposits.amount) (per-revealer accounting in TBTCMigrationDebtOperations).
  • setVaultStatus(canonicalMigrationDebtVault, untrusted) always reverts while it is the canonical pointer.
  • Any vault still reporting outstanding migration debt cannot be untrusted — fail-open staticcall (vaults not implementing the interface are unaffected).
  • rotateMigrationDebtVault refuses to leave the prior canonical vault holding outstanding debt.
  • Optimistic-minting fee divisor lower bound: actual divisor ≥ MIN_OPTIMISTIC_MINTING_FEE_DIVISOR (= 10) — caps the maximum minting fee at 10%.
  • MAX_MIGRATION_SWEEP_BATCH_SIZE = 100 caps per-sweep callback fan-out.
  • migrationSweepNotifier is informed exactly once when a revealer's migration debt reaches zero, with pending-completion bookkeeping that survives partial batch outcomes.

MigrationRevealRejected(bytes32) reason codes (raised in Deposit.revealDeposit):

Reason Trigger
missing debt Deposit carries the migration tag but the per-revealer outstanding debt is zero
missing tag Deposit comes from a registered migration revealer but lacks the migration tag
mismatched revealer Migration tag references a revealer that does not match the per-deposit vault registration
unauthorised revealer Migration revealer is not registered in the per-deposit vault
vault call failure Staticcall to the vault's ITBTCVaultMigrationDebt interface reverts
bad vault response Vault returns malformed data for the debt query

Fail-open staticcall semantics: when a vault does not implement ITBTCVaultMigrationDebt at all (no selector), the setVaultStatus and migration-debt callback paths treat the vault as having zero migration debt — by design, so non-migration vaults are not punished. Vaults that do implement the interface but return malformed data are caught by bad vault response.

Cross-repo coupling

This PR is the ABI source-of-truth that tlabs-xyz/tbtc-v2-ac vendors as external/tbtc-v2/<sha>/. The interfaces touched here (ITBTCVaultMigrationDebt, ITBTCVaultMigrationSweepHook, ITBTCVaultMigrationSweepNotifier) are consumed by covenant code in the sibling repo. After this PR merges, tbtc-v2-ac will re-pin its Bridge artifact tarball to the merged SHA.

Deferred items (intentionally out of scope of this PR)

  • setMigrationDebtVault rotation-guard bypass — flagged during PR feat(bridge,vault): add PSBT covenant migration debt and reveal guard #957 development and explicitly deferred to a follow-up. Auditors should note the current rotation flow and flag any new exploit path beyond this known concern; the known concern itself is tracked separately.
  • Bridge contract size exceeds EIP-170Bridge compiles to 26.156 KB vs the 24.576 KB cap. Resolution (delegatecall library extraction or contractSizer carve-out) is a separate change; see Known issues for upstream policy below.
  • 3 test failures in TBTCVault.OptimisticMinting.test.ts — divisor type(uint32).max does not actually truncate to zero against the fixture amount; documented in detail below.

Introduction

This PR ports the on-chain machinery for the PSBT covenant migration flow into the canonical threshold-network/tbtc-v2 source tree. The mechanism enables a one-way migration channel where a designated vault accumulates migration debt as deposits are revealed under a tagged extra-data byte string, and that debt is repaid when the matching sweep transaction is proven on-chain. The intent is for this repository to be the source of truth and audit baseline for the Bridge half of the covenant stack, in parallel with the covenant contracts and services that land in tlabs-xyz/tbtc-v2-ac.

Provenance

The same code and tests merged into tlabs-xyz/tbtc on PR tlabs-xyz/tbtc#132 at merge commit tlabs-xyz/tbtc@02ee4bf1762ea2b8000b988345849645b15b672a. This PR ports the 37 files that live under contracts/tbtc-v2/... in the monorepo to their canonical paths under solidity/... here.

Bridge contracts that diverged between the monorepo's vendored copy and threshold-network/tbtc-v2/main (Bridge.sol, Deposit.sol) were three-way merged so that the TIP-109 hotfix (PR #948) is preserved alongside the migration debt additions; verified by grep on initializeV5_RepairRebateStaking, RebateStakingRepaired, TreasuryFeeType.Deposit and the new MigrationDebtVaultUpdated / MigrationRevealRejected symbols.

Details

Bridge changes

Five contracts modified (Bridge.sol, BridgeGovernance.sol, BridgeState.sol, Deposit.sol, DepositSweep.sol) plus one new (MigrationExtraData.sol).

A canonical migration debt vault pointer is maintained by governance via setMigrationDebtVault and an atomic rotateMigrationDebtVault that refuses to leave the prior canonical vault holding outstanding debt. setVaultStatus is tightened so the canonical migration debt vault cannot be untrusted while it is the canonical pointer, and any vault that still reports outstanding migration debt cannot be untrusted (fail-open staticcall — vaults not implementing the interface are unaffected).

A reveal-time guard is added in Deposit.revealDeposit: deposits whose extra data carries the migration tag must come from a registered migration revealer in the per-deposit vault and must have outstanding debt to repay; deposits from a registered migration revealer that lack the tag are rejected. Failures surface explicit reasons via MigrationRevealRejected(bytes32) with reason codes covering missing debt, missing tag, mismatched revealer, unauthorised revealer, vault call failure and bad vault response.

__gap in BridgeState is reduced from 48 to 47 to introduce migrationDebtVault while preserving storage layout continuity for proxy upgrades; documented in solidity/docs/BRIDGE_STORAGE_UPGRADE_NOTES.md.

DepositSweep changes

After a sweep is proven, the canonical migration vault is notified via ITBTCVaultMigrationSweepHook with a fail-open batch call followed by bounded per-revealer fallback retries so a single bad entry cannot strand a completion batch.

Vault changes

Two contracts modified (TBTCOptimisticMinting.sol, TBTCVault.sol) plus three new interfaces and one library.

TBTCOptimisticMinting now implements ITBTCVaultMigrationDebt with per-revealer migration debt tracking, a registration counter that enforces hasOutstandingMigrationDebt across rotations, and a TBTCMigrationDebtOperations library that isolates the debt state transitions (register, repay, residual clear). MIN_OPTIMISTIC_MINTING_FEE_DIVISOR = 10 is introduced to bound the maximum optimistic minting fee rate at 10%, and MAX_MIGRATION_SWEEP_BATCH_SIZE = 100 caps per-sweep callback fan-out.

migrationSweepNotifier and migrationSweepReserve allow a downstream contract to be informed exactly once when a revealer's migration debt reaches zero, with pending-completion bookkeeping that survives partial batch outcomes.

Test infrastructure

Eleven harness contracts are added under solidity/contracts/test/ so the migration paths can be exercised in isolation from the full Bridge fixture (BridgeForVaultHarness, TBTCVaultHarness, the Deposit* and DepositSweep* harnesses, plus mocks for migration debt vault, sweep notifier, sweep vault and a regular comparison vault). A NatSpec verification script at solidity/test/verify-migration-debt-natspec.sh asserts that the required documentation surface for the migration debt interface remains present.

Solution

The 37-file diff decomposes as 22 added files and 15 modified files. For 13 of the 15 modified files the monorepo's pre-PR-132 vendored copy was byte-identical to threshold-network/tbtc-v2/main, so they were applied as direct overwrites. The remaining 2 (Bridge.sol, Deposit.sol) were three-way merged with git merge-file against the common base; both merges were clean (zero conflict markers) and verified by symbol grep to contain both branches' additions.

One deliberate adaptation versus the monorepo source: the rewrite of solidity/test/fixtures/bridge.ts introduced by PR tlabs-xyz/tbtc#132 was an accommodation for the monorepo's deploy-script chain. In threshold-network/tbtc-v2/main the existing fixture works against the in-tree deploy chain, so this PR keeps the bare-main fixture and only adapts what the migrated tests actually need. None of the migrated tests destructure additional fixture fields, so no signature change was required. Verified locally: with PR tlabs-xyz/tbtc#132's fixture rewrite, all modified tests fail in the before all hook with a JSON-RPC QUANTITY validator error from the inline helpers.upgrades.deployProxy call; with the bare-main fixture they execute cleanly.

Tests

Local verification on Node 14.21.3 with yarn 1.22.22 against solidity/.

yarn lint reports 0 errors and 363 warnings (warnings are pre-existing). yarn format (root) reports clean ("All matched files use Prettier code style!"). npx hardhat compile is clean. solidity/test/verify-migration-debt-natspec.sh reports 12/12 PASS.

The five new migration test files (Bridge.CanonicalMigrationDebtVaultGuard.test.ts, Deposit.MigrationRevealGuard.Harness.test.ts, DepositSweep.MigrationCallback.test.ts, TBTCVault.MigrationDebt.test.ts, TBTCVault.OptimisticMintMigrationGuard.test.ts) report 52/52 PASS in 15s.

The three modified test files (Bridge.Governance.test.ts, Bridge.Parameters.test.ts, TBTCVault.OptimisticMinting.test.ts) report 539/542 PASS, 3 FAIL — see Known issues for upstream policy below.

Known issues for upstream policy

These two conditions exist in the source PR tlabs-xyz/tbtc#132 and are surfaced here intentionally rather than papered over. Both are policy decisions for this repository's reviewers.

1. Bridge contract size

Bridge compiles to 26.156 KB with this PR vs 23.939 KB on bare main. The added migration-vault rotation, reveal-guard helpers, custom errors and event accounting add ~2.2 KB. EIP-170 caps mainnet deployable code at 24.576 KB, so hardhat-contract-sizer (which is configured strict: true in hardhat.config.ts) fails the build.

The options are to extract a chunk of Bridge into a delegatecall library (preferred for long-term carrying capacity, but a non-trivial refactor); to accept a temporary except: ["Bridge$"] entry in contractSizer with a tracked follow-up issue; or to leave the build red until extraction lands. This PR does not modify the contractSizer configuration. The deployment blocker is real and should be reviewed alongside the rest of the change.

2. Three test failures in TBTCVault.OptimisticMinting.test.ts

PR tlabs-xyz/tbtc#132 introduced MIN_OPTIMISTIC_MINTING_FEE_DIVISOR = 10 and rewrote the long-standing "when the optimistic minting fee is zero" context (which previously set divisor 0) into a new "when the optimistic minting fee rounds to zero" context with divisor = type(uint32).max = 4294967295. The test comments assert that integer division truncates the fee to zero. The arithmetic does not actually truncate to zero for the deposit amount used in the fixture: 199_900_000_000_000 / 4_294_967_295 = 46_542, so the treasury receives 46 542 wei and the depositor receives that amount less than the assertion expects. The three failing assertions are should send no optimistic mint fee to treasury (expected 0, got 46542), should mint TBTC to depositor (expected 199_900_000_000_000, got 199_899_999_953_458), and a second should mint TBTC to depositor (expected 200_000_000_000_000, got 199_999_999_953_434).

This was not caught upstream because the monorepo CI's ci:core script only runs pnpm --filter @keep-network/tbtc-v2 run build for the contracts package, not run test. Resolution requires either picking a divisor that actually truncates against the fixture amount (e.g. one larger than the deposit amount) or revisiting the MIN_OPTIMISTIC_MINTING_FEE_DIVISOR enforcement. This PR keeps the tests as PR tlabs-xyz/tbtc#132 wrote them so the situation is visible to upstream.

Storage upgrade considerations

BridgeState.Storage adds migrationDebtVault and reduces the trailing __gap from uint256[48] to uint256[47]. Any future storage-layout diff against current proxies must reflect this and any proxy upgrade plan should include a layout review.

Introduces the on-chain machinery that lets a migration vault settle
deposit obligations against the Bridge in lockstep with the Bitcoin
sweep flow. The mechanism enables a one-way migration channel where a
designated vault accumulates "migration debt" as deposits are revealed
under a tagged extra-data byte string, and that debt is repaid when the
matching sweep transaction is proven on-chain.

Bridge changes:
- Adds a canonical migration debt vault pointer maintained by governance
  via setMigrationDebtVault and an atomic rotateMigrationDebtVault that
  refuses to leave the prior canonical vault holding outstanding debt.
- Tightens setVaultStatus so the canonical vault cannot be untrusted
  while it is the canonical pointer, and so any vault that still reports
  outstanding migration debt cannot be untrusted (fail-open staticcall;
  vaults not implementing the interface are unaffected).
- Adds a reveal-time guard in Deposit.revealDeposit: deposits whose
  extra-data carries the migration tag must come from a registered
  migration revealer in the per-deposit vault and must have outstanding
  debt to repay; conversely, deposits from a registered migration
  revealer that lack the tag are rejected.
- Exposes explicit failure reasons via MigrationRevealRejected with
  reason codes covering missing debt, missing tag, mismatched revealer,
  unauthorised revealer, vault call failure and bad vault response.
- Reduces __gap from 48 to 47 to introduce migrationDebtVault while
  preserving storage layout continuity for proxy upgrades; documented in
  docs/BRIDGE_STORAGE_UPGRADE_NOTES.md.

DepositSweep changes:
- After a sweep is proven, notifies the canonical migration vault via
  ITBTCVaultMigrationSweepHook with a fail-open batch call followed by
  bounded per-revealer fallback retries so a single bad entry cannot
  strand a completion batch.

Vault changes:
- TBTCOptimisticMinting now implements ITBTCVaultMigrationDebt with
  per-revealer migration debt tracking, a registration counter that
  enforces hasOutstandingMigrationDebt across rotations, and a
  TBTCMigrationDebtOperations library that isolates the debt state
  transitions (register, repay, residual clear).
- Adds MIN_OPTIMISTIC_MINTING_FEE_DIVISOR = 10 to bound the maximum
  optimistic minting fee rate at 10%.
- Adds MAX_MIGRATION_SWEEP_BATCH_SIZE = 100 to cap per-sweep callback
  fan-out.
- Wires migrationSweepNotifier and migrationSweepReserve so a downstream
  contract can be informed exactly once when a revealer's migration debt
  reaches zero, with pending-completion bookkeeping that survives
  partial batch outcomes.

Test infrastructure:
- Adds harness contracts (BridgeForVaultHarness, TBTCVaultHarness,
  DepositRevealGuardHarness, DepositSweepCallbackHarness, plus mocks for
  migration debt vault, sweep notifier, sweep vault and a regular
  comparison vault) so the migration paths can be exercised in isolation
  from the full Bridge fixture.
- Adds a NatSpec verification script that asserts the required
  documentation surface for the migration debt interface remains
  present.

Deploy:
- Resolves wallet registry and reimbursement pool against deployment
  artifacts when present so downstream networks can wire the new debt
  vault pointer through the bridge governance flow.
Bridge deployed bytecode exceeded the EIP-170 24,576-byte limit. Two
public reinitializers — initializeV2_FixVaultZeroDeposit and
initializeV5_RepairRebateStaking — were permanently consumed on every
live proxy: slot 50 _initialized reads 0x05 on mainnet and Sepolia,
with Initialized events at mainnet blocks 24282346 and 24800704.
OpenZeppelin Initializable does not decrement, so the functions could
never run again.

Removing both reinitializers plus a Bridge-only optimizer override
(runs 500) brings deployed bytecode to 23,522 bytes — 1,054 under the
limit. Storage layout is unchanged. The DepositVaultFixed and
RebateStakingRepaired event declarations are retained so historical
logs decode through the current ABI.

Deploy scripts that targeted the removed selectors are hard-disabled
(func.skip = true) with on-chain evidence captured in the deprecation
headers. The exported encoder helpers in script 85 remain importable
as historical calldata-format regression coverage. The
Bridge.RebateRecovery test is deleted — it exercised the removed
reinitializer through helpers.upgrades.upgradeProxy.

Incidental: prettier collapsed the PreviousMigrationDebtVaultMismatch
declaration into a single line.
Copy link
Copy Markdown
Contributor

@piotr-roslaniec piotr-roslaniec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(superseded by inline comments)

revert MigrationRevealRejected(MIGRATION_DEBT_MISSING_REASON);
}
} else {
if (isRegisteredMigrationRevealer(self, reveal.vault, msg.sender)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NatSpec says fail-closed is intentional because a failed canonical vault call makes "migration-tag enforcement indeterminate." Makes sense for the migration grant path. But this function is also called in the non-migration else branch, so a broken canonical vault locks out regular depositors too, not just migration ones. Was that consequence considered, or should the blocking check be fail-open?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional fail-closed — covered by solidity/test/bridge/Deposit.MigrationRevealGuard.Harness.test.ts:445-478. The non-migration-depositor lockout observation is correct, but a failed canonical lookup makes migration-tag enforcement indeterminate, so reverting is the safer policy. A future hardening could be a set-time interface probe on setMigrationDebtVault to convert this to a deploy-time check; tracking as a separate follow-up.


// Best-effort fallback: retry each revealer in isolation so one bad
// entry cannot strand the whole completion batch.
for (uint256 i = 0; i < revealers.length; i++) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The raw revealers array gets passed to the vault, which rejects batches over 100. There is no cap on sweep inputs at the Bridge level (inputsCount comes straight from the Bitcoin tx varint), so a sweep with 101+ deposits always fails the batch and falls through to O(N) individual vault calls inside submitDepositSweepProof. The callback tests only cover 1 revealer -- was this tested for gas with large migration sweeps?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit d14c2e4 — chunked at MAX_MIGRATION_SWEEP_BATCH_SIZE = 100 (solidity/contracts/bridge/DepositSweep.sol:51,307-380). Tests at solidity/test/bridge/DepositSweep.MigrationCallback.test.ts:93,125 cover the 150-revealer happy path and the chunked fallback (2 batch failures + 150 single-hook successes).

emit MigrationSweepNotifierUpdated(previousNotifier, notifier);
}

function setMigrationSweepReserve(address revealer, address reserve)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dedicated MigrationSweepCompletionPendingCleared event makes it clear the coupling is intentional. But setMigrationSweepReserve has no NatSpec at all, so an operator zeroing the reserve during an incident would have no hint that it also kills the pending callback. Worth adding a note there.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit d14c2e4 — NatSpec block added above solidity/contracts/vault/TBTCOptimisticMinting.sol:799 documenting that reserve == address(0) clears pendingMigrationSweepCompletion[revealer] and emits MigrationSweepCompletionPendingCleared.

"Migration debt already registered"
);

uint256 nextDebt = migrationDebt[revealer] + amount;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NatSpec return value already says "equals amount since registration starts from zero," and the require(migrationDebt[revealer] == 0) three lines above guarantees it. Safe to drop the addition and assign directly?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit d14c2e4solidity/contracts/vault/TBTCMigrationDebtOperations.sol:37 now assigns amount directly. The existing require(migrationDebt[revealer] == 0) makes the prior addition equivalent.

);
}

function isMigrationReveal(bytes32 extraData) internal pure returns (bool) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both Deposit.sol (line 478) and this file have an identical isMigrationReveal helper. MigrationExtraData already owns the tag constant; was putting the helper there considered?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit d14c2e4 — helper moved into solidity/contracts/bridge/MigrationExtraData.sol:13-15. Callers updated at Deposit.sol:227, TBTCOptimisticMinting.sol:337,399, and DepositRevealGuardHarness.sol:73.

emit MigrationRevealerSet(revealer, allowed);
}

function setMigrationSweepNotifier(address notifier) external onlyOwner {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No code.length > 0 check on the setter (no other setter in vault/bridge does this either, so may be accepted practice). Worth noting: an EOA notifier causes every notifyMigrationSweep to revert inside the vault via Solidity's EXTCODESIZE check. Those reverts get swallowed by the low-level .call in notifyMigrationSweepCallback, so MigrationSweepCallbackRetryFailed fires for every revealer silently. Intentional risk or worth a guard at set-time?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit d14c2e4solidity/contracts/vault/TBTCOptimisticMinting.sol:766 now requires notifier == address(0) || notifier.code.length > 0. EOA notifier reverts at set-time; address(0) remains a valid disable state. Tests at solidity/test/vault/TBTCVault.MigrationDebt.test.ts:458,464.

- Chunk migration sweep callback batches at the vault's 100-revealer
  limit to avoid the O(N) per-revealer fallback path on oversized sweeps
  (DepositSweep.notifyMigrationSweepCallback, MAX_MIGRATION_SWEEP_BATCH_SIZE).
- Reject EOA / non-contract addresses at setMigrationSweepNotifier
  set time so misconfiguration surfaces before sweep callbacks fire;
  address(0) remains a valid disable state.
- Guard registerMigrationDebt against re-registration while a pending
  sweep completion is unresolved, closing a state-machine gap where a
  stale callback could clear a fresh reserve.
- Document the side effect of zeroing setMigrationSweepReserve (clears
  pendingMigrationSweepCompletion) via NatSpec.
- Simplify registerDebt in TBTCMigrationDebtOperations to a direct
  assign; the existing require(migrationDebt == 0) makes the prior
  addition equivalent.
- Consolidate the duplicate isMigrationReveal helper into
  MigrationExtraData; update callers in Deposit, TBTCOptimisticMinting,
  and DepositRevealGuardHarness.

Tests: 4 new in TBTCVault.MigrationDebt (EOA notifier reverts, zero
disable, pending-sweep guard, re-register after pending clears) and 2
new in DepositSweep.MigrationCallback (chunking happy path, chunked
fallback). Local yarn build clean; all 48 migration-tagged tests pass.
Pre-existing unrelated failures (cross-chain/Wormhole, OM divisor-math)
remain untouched.
…ivisor

Restores divisor=0 as the explicit "fee disabled" governance state alongside
the existing non-zero minimum bound. The previous guard rejected zero, but
the fee formula at TBTCOptimisticMinting.sol still retained the
`divisor > 0 ? amount / divisor : 0` ternary — leaving the zero branch as
permanently dead code while three test contexts that asserted truncation-
to-zero with `divisor = type(uint32).max` empirically failed (e.g.
199_900_000_000_000 / 4_294_967_295 = 46_542, not 0).

Both setter guards (begin and finalize) now accept
`divisor == 0 || divisor >= MIN_OPTIMISTIC_MINTING_FEE_DIVISOR`, aligning
the setter with the fee formula's existing zero-handling branch. The
non-zero floor of 10 (capping non-zero fee rate at 10%) is preserved, so
divisors in [1, 9] remain blocked.

Test changes:
- Two zero-fee test contexts restored to their pre-port `divisor = 0`
  baseline; arithmetic comments and labels updated.
- The setter test that asserted `divisor = 0` reverts is replaced with a
  boundary test asserting `divisor = 1` reverts, matching the new guard
  shape.
- AbstractBTCDepositor and the OM fee formula already handle `divisor = 0`
  correctly via `divisor > 0 ? ... : 0`; no other code paths needed
  changes.

NatSpec on MIN_OPTIMISTIC_MINTING_FEE_DIVISOR and on the finalize setter
documents that 0 is a valid out-of-band sentinel meaning "no fee".

Local CI: yarn build clean, yarn lint 0 errors, 101/101 tests pass in
TBTCVault.OptimisticMinting.test.ts (was 98/101 with 3 failures).
TBTCVault contract size 19.449 KB (well under EIP-170).
Mechanical prettier reformatting on PR-#132 ported migration files plus
targeted slither-disable directives with one-line rationale on the
intentional fail-open vault.call sites and the bounded migration sweep
loop.

Slither suppressions:
- TBTCOptimisticMinting.registerMigrationDebt -> registerDebt: unused-return
  is intentional; the library returns the registered amount which equals
  the caller's input by invariant.
- TBTCOptimisticMinting.repayDebt counter decrement: costly-loop suppressed
  with rationale that per-revealer atomicity is the contract's guarantee.
- TBTCOptimisticMinting notifier dispatch + emit: calls-loop and
  reentrancy-events suppressed; notifier is governance-controlled, the
  array is bounded by MAX_MIGRATION_SWEEP_BATCH_SIZE, partial failure is
  the intended fail-open semantic.
- DepositSweep.notifyMigrationSweepCallback batch + per-revealer fallback
  emits: reentrancy-events suppressed; the sweep proof is finalized
  before these informational retry signals fire.

Format:
- yarn format:fix applied across migration files; multi-line return
  tuples and call expressions reformatted to prettier defaults.
- One whitespace fix in test/verify-migration-debt-natspec.sh.

No behavior changes. yarn build, yarn format, and the full test suite
pass: 2973 passing / 0 failing / 31 pending. Bridge bytecode 23,522
bytes (1,054 under EIP-170). 44 lint warnings remain (function ordering
and max-states-count) but yarn format exits 0 — they are non-blocking
and pre-existing across the repo, not specific to this port.
@lrsaturnino lrsaturnino marked this pull request as ready for review May 8, 2026 13:46
…ry NatSpec

Add a @dev note to setMigrationDebtVault explaining it skips the
outstanding-debt guard (use rotateMigrationDebtVault for live
rotations). Add @notice/@param NatSpec to setRevealer and
setSweepReserve in TBTCMigrationDebtOperations, consistent with
registerDebt above them.
"Vault is not trusted"
);

if (MigrationExtraData.isMigrationReveal(extraData)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration reveals gate on reveal.vault directly, bypassing the canonical vault check that the non-migration else branch runs via isRegisteredMigrationRevealer. So any trusted vault implementing ITBTCVaultMigrationDebt can authorize a migration reveal, not just the canonical one pointed to by migrationDebtVault. Is that the intended trust model, or should the canonical vault be the sole authority here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — pinned reveal.vault to self.migrationDebtVault in the migration branch in 02f448b; tests cover non-canonical (interface or not) and unset canonical.

The migration grant path in _revealDeposit authorized against
reveal.vault directly, so any trusted vault implementing
ITBTCVaultMigrationDebt could authorize a migration-tagged reveal -
not just the canonical migrationDebtVault. Pin reveal.vault to
self.migrationDebtVault inside the migration branch so the grant
path and the post-sweep callback (DepositSweep) share a single
source of truth, and migration reveals are rejected while migration
is disabled at the Bridge level (canonical address zero).

Adds tests for: trusted non-canonical vault, non-canonical
migration-debt vault with registered revealer + debt, and unset
canonical vault.
Four independently-scoped fixes from an audit pass on the migration
debt subsystem:

1. Bridge ETH rescue (Blocker)
   submitFraudChallenge accepts ETH that can be orphaned when the
   downstream Fraud library refunds via a bounded-gas call whose
   return value is not checked: the challenge is marked resolved
   regardless of payout success, leaving funds custodied in the
   Bridge with no exit path. Adds recoverETH(payable, uint256)
   onlyGovernance with a checked low-level transfer, plus a
   forwarder on BridgeGovernance.

2. Canonical migration debt vault setter (Must-fix)
   setMigrationDebtVault now (a) probes the target's
   ITBTCVaultMigrationDebt interface conformance fail-closed --
   the deposit-reveal guard fail-closes on staticcall failure to
   the canonical vault, so a non-conforming pointer would brick
   the pipeline -- and (b) rejects overwriting a canonical vault
   that still has outstanding migration debt, forcing governance
   to use rotateMigrationDebtVault (which atomically untrusts the
   previous vault). Emergency disable to address(0) still works
   because the previous-debt check uses a fail-open staticcall.
   The corresponding harness mirrors the same logic.

3. TBTCVault upgrade in-flight guard (Must-fix)
   finalizeUpgrade transferred TBTC ownership and the full Bank
   balance to a fresh new-vault contract that starts with empty
   migrationDebt, pendingMigrationSweepCompletion,
   migrationSweepReserve, isMigrationRevealer, and
   _outstandingMigrationDebtCount. Reverts when this vault is the
   canonical migration debt vault and hasOutstandingMigrationDebt
   is true so governance must drain debt before rotating
   ownership. Also widens hasOutstandingMigrationDebt from
   external to public so subclasses can read it without an
   external self-call.

4. Sweep solvency invariant on deposit parameters (Must-fix)
   BridgeState.updateDepositParameters only enforced
   dust > txMaxFee, but DepositSweep performs
   amount - treasuryFee - txFee and treasury fee is
   amount / treasuryFeeDivisor. A misconfigured divisor combined
   with the existing rule could create revealed deposits that
   always underflow at sweep time. Now also enforces
   dust > dust/treasuryFeeDivisor + txMaxFee when the divisor is
   non-zero, matching the worst-case sweep arithmetic.

Bytecode budget: trimmed Bridge.sol require strings to custom
errors to fit the EIP-170 limit after adding the rescue function
and the canonical-vault setter logic.
The slither-disable-next-line directive was followed by a separate
solhint-disable-next-line line, so slither's "next-line" applied to
the solhint comment and the low-level-calls detector still fired on
recipient.call. Inlines the solhint suppression on the call line and
keeps slither-disable-next-line directly above it.
Bridge was below the bytecode ceiling by only 0.063 KiB, so replace remaining Bridge revert strings with custom errors and share the migration-debt staticcall helper across strict and fail-open paths.

Rotation now probes non-zero target vaults for ITBTCVaultMigrationDebt, while setMigrationDebtVault fail-closes only on regular non-zero overwrites and preserves the emergency-disable lane.

Fraud challenge ETH is tracked as open escrow and recoverETH is capped to the non-escrowed balance so governance cannot drain live challenge refund backing.
Probe canonical migration debt vault targets for every static selector used by Bridge reveal paths, while keeping outgoing-debt checks fail-closed and the zero-address disable lane.

Add one-shot fraud challenge escrow seeding for upgraded deployments, gate rescue and new challenges until seeded, and skip legacy challenge decrements before the seed.

Track whether newly submitted challenges are counted so seeded legacy challenges and post-seed challenges both release escrow without underflow.

Update the hotfix deploy script to compute the seed from fraud challenge events and fail closed when a direct transaction value is insufficient evidence.

Cover partial migration vault rejection, seeded legacy resolution/rescue bounds, and post-seed fraud challenge tracking.
The hotfix script previously linked the new Bridge implementation to the legacy mainnet Deposit, DepositSweep, and Fraud libraries. Those libraries' source was modified earlier in this PR chain, so the migration-debt reveal guard, post-sweep callbacks, and fraud-challenge escrow accounting would all be unreachable at runtime even though the new Bridge bytecode references them.

Deploy fresh DepositTIP109Hotfix, DepositSweepTIP109Hotfix, and FraudTIP109Hotfix copies alongside the existing RedemptionTIP109Hotfix, and link the new Bridge implementation to the fresh addresses for every modified linked library.

Add a deploy-script test that asserts the linkage: each modified library resolves to a freshly-deployed address rather than the legacy mainnet deployment, and the Bridge implementation libraries map matches.
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