From 4519aae17f2c0787b0d5c0da4d4b9492cd16e9e0 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 8 Aug 2026 10:15:04 -0400 Subject: [PATCH 01/14] feat(bridge): two-phase authorize-then-prove reservation settlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-phase reservation lifecycle (request -> prove, with all validity checked at proof time against live state) with the two-phase, nonce-bound state machine specified by RFC 13. Every Bitcoin action of a reservation's life — acceptance anchor, in-kind redemption, re-anchor, dissolution — is now an explicitly requested *generation*: - The request increments the position's monotonic request nonce, performs and RESERVES every capacity/lifecycle check (global total, per-wallet count, target-wallet liveness, the per-wallet dissolution lock), and snapshots every proof- and settlement-critical parameter into a ReservationAction record keyed by (reservationKey, nonce). A wallet that signs an authorized action can never find it unprovable because state moved after signing. - The SPV proof names its generation and validates exclusively against the record's snapshots. Reserved-redemption proofs additionally enforce the watchtower delay of the generation on-chain — an early-broadcasting wallet cannot finalize before the guardians' window closes, and a vetoed generation never settles (the unauthorized signature stays exposed to the fraud machinery). - Timeouts write terminal, late-proof-accepting records: a Bitcoin spend that confirmed before its proof landed still settles — consumed outpoints are marked honestly spent (defeating fraud challenges against the honest-but-late signature) and the anchor lineage closes — but no Bank movement is repeated: the timeout already refunded the escrow and slashed the wallet. If the position's current pending redemption also matches the transaction, the proof must settle that generation instead (burning the escrow, the correct accounting); a non-matching pending generation is unwound (Superseded) with its escrow refunded, because its anchor provably no longer exists. This closes the claim double-spend race, where a timeout/veto racing a confirmed redemption both refunded the claim on Ethereum and paid the BTC, with the late proof reverting. - Veto and objection state is keyed per generation, so objections never accumulate across generations and an unbanned owner's reservation is always processable again; the request path uses a reservation-scoped safety check (ban state only). - Re-anchor and dissolution gain explicit on-chain authorization: source wallet in MovingFunds (permissionless) or Live with governance consent; target Live, distinct, capacity-reserved; the proof requires the output to pay the recorded target — a same-wallet re-anchor no longer exists, removing the re-anchor/dissolution byte-ambiguity. Dissolutions take a per-wallet main-UTXO action lock and snapshot the expected main UTXO; registry drift (a sweep landing before a no-main-UTXO dissolution's proof) re-registers the output for the moved-funds sweep machinery. - Redemption requests are gated on a Live/MovingFunds wallet and the (snapshotted) grace bound; after grace only dissolution can be requested, so request/timeout cycling cannot defeat the stranding bound. A fee-paid generation that times out mints a single-use retry entitlement consumed by the fee-free retry path. A wallet with reservation anchors can no longer begin closing: with no main UTXO it enters MovingFunds to drain its anchors, and both redemption and dissolution timeouts slash like a pooled redemption timeout. The settlement side lives in a new external library, ReservationProofs, reached through Reservation so the ReservationRouter links exactly one library. Storage appends: reservationActionTimeout, reservationActions, walletPendingDissolution (gap 42 -> 39). Sizes: router 6,007 B, Reservation 14,760 B, ReservationProofs 19,416 B, Bridge unchanged at 22,403 B. --- .../contracts/bridge/BridgeGovernance.sol | 9 +- .../bridge/BridgeGovernanceParameters.sol | 7 +- solidity/contracts/bridge/BridgeState.sol | 22 +- .../contracts/bridge/IReservationBridge.sol | 42 +- solidity/contracts/bridge/Redemption.sol | 25 +- .../contracts/bridge/RedemptionWatchtower.sol | 145 +- solidity/contracts/bridge/Reservation.sol | 1487 ++++++++--------- .../contracts/bridge/ReservationProofs.sol | 1066 ++++++++++++ .../contracts/bridge/ReservationRouter.sol | 305 +++- .../bridge/WalletProposalValidator.sol | 200 ++- solidity/contracts/bridge/Wallets.sol | 26 +- solidity/contracts/test/BridgeStub.sol | 4 + solidity/contracts/vault/ReservationVault.sol | 14 +- solidity/deploy/06_deploy_bridge.ts | 13 +- .../deploy/40_deploy_redemption_watchtower.ts | 8 + 15 files changed, 2346 insertions(+), 1027 deletions(-) create mode 100644 solidity/contracts/bridge/ReservationProofs.sol diff --git a/solidity/contracts/bridge/BridgeGovernance.sol b/solidity/contracts/bridge/BridgeGovernance.sol index e221deb81..3eb0c50d5 100644 --- a/solidity/contracts/bridge/BridgeGovernance.sol +++ b/solidity/contracts/bridge/BridgeGovernance.sol @@ -1823,7 +1823,8 @@ contract BridgeGovernance is Ownable { uint32 _newReservationTermSeconds, uint32 _newReservationGracePeriod, uint64 _newReservationMaxTotalAmount, - uint32 _newMaxReservationsPerWallet + uint32 _newMaxReservationsPerWallet, + uint32 _newReservationActionTimeout ) external onlyOwner { reservationData.beginReservationParametersUpdate( _newReservationVault, @@ -1832,7 +1833,8 @@ contract BridgeGovernance is Ownable { _newReservationTermSeconds, _newReservationGracePeriod, _newReservationMaxTotalAmount, - _newMaxReservationsPerWallet + _newMaxReservationsPerWallet, + _newReservationActionTimeout ); } @@ -1850,7 +1852,8 @@ contract BridgeGovernance is Ownable { staged.newReservationTermSeconds, staged.newReservationGracePeriod, staged.newReservationMaxTotalAmount, - staged.newMaxReservationsPerWallet + staged.newMaxReservationsPerWallet, + staged.newReservationActionTimeout ); } } diff --git a/solidity/contracts/bridge/BridgeGovernanceParameters.sol b/solidity/contracts/bridge/BridgeGovernanceParameters.sol index 14589bafb..f7bd06a57 100644 --- a/solidity/contracts/bridge/BridgeGovernanceParameters.sol +++ b/solidity/contracts/bridge/BridgeGovernanceParameters.sol @@ -1580,6 +1580,7 @@ library BridgeGovernanceParameters { uint32 newReservationGracePeriod; uint64 newReservationMaxTotalAmount; uint32 newMaxReservationsPerWallet; + uint32 newReservationActionTimeout; uint256 reservationParametersChangeInitiated; } @@ -1591,6 +1592,7 @@ library BridgeGovernanceParameters { uint32 newReservationGracePeriod, uint64 newReservationMaxTotalAmount, uint32 newMaxReservationsPerWallet, + uint32 newReservationActionTimeout, uint256 timestamp ); @@ -1605,7 +1607,8 @@ library BridgeGovernanceParameters { uint32 _newReservationTermSeconds, uint32 _newReservationGracePeriod, uint64 _newReservationMaxTotalAmount, - uint32 _newMaxReservationsPerWallet + uint32 _newMaxReservationsPerWallet, + uint32 _newReservationActionTimeout ) external { /* solhint-disable not-rely-on-time */ self.newReservationVault = _newReservationVault; @@ -1615,6 +1618,7 @@ library BridgeGovernanceParameters { self.newReservationGracePeriod = _newReservationGracePeriod; self.newReservationMaxTotalAmount = _newReservationMaxTotalAmount; self.newMaxReservationsPerWallet = _newMaxReservationsPerWallet; + self.newReservationActionTimeout = _newReservationActionTimeout; self.reservationParametersChangeInitiated = block.timestamp; emit ReservationParametersUpdateStarted( _newReservationVault, @@ -1624,6 +1628,7 @@ library BridgeGovernanceParameters { _newReservationGracePeriod, _newReservationMaxTotalAmount, _newMaxReservationsPerWallet, + _newReservationActionTimeout, block.timestamp ); /* solhint-enable not-rely-on-time */ diff --git a/solidity/contracts/bridge/BridgeState.sol b/solidity/contracts/bridge/BridgeState.sol index c309f5366..ca66daa4f 100644 --- a/solidity/contracts/bridge/BridgeState.sol +++ b/solidity/contracts/bridge/BridgeState.sol @@ -374,6 +374,26 @@ library BridgeState { // the fallback delegatecall at new code is equivalent to a Bridge // implementation change. address reservationRouter; + // Time in seconds after which a requested reservation action + // (acceptance anchor, re-anchor, dissolution) can be reported + // timed out. Reserved redemptions use `redemptionTimeout` instead. + // Snapshotted into each action record at request time. + uint32 reservationActionTimeout; + // Collection of all reservation action generation records indexed + // by `keccak256(reservationKey | requestNonce)`. Each record + // snapshots every proof- and settlement-critical parameter of one + // requested action generation. Terminal records (TimedOut, Vetoed, + // Superseded, Settled) are never deleted: timed-out generations + // must keep accepting late proofs of their confirmed Bitcoin + // transactions. + mapping(uint256 => Reservation.ReservationAction) reservationActions; + // Per-wallet main-UTXO action lock: maps the 20-byte wallet public + // key hash to the reservation key of the wallet's in-flight + // dissolution, or zero when none is pending. At most one + // dissolution per wallet may be in flight — concurrent + // dissolutions of a no-main-UTXO wallet could all confirm on + // Bitcoin with only the first being provable. + mapping(bytes20 => uint256) walletPendingDissolution; // Reserved storage space in case we need to add more variables. // The convention from OpenZeppelin suggests the storage space should // add up to 50 slots. Here we want to have more slots as there are @@ -381,7 +401,7 @@ library BridgeState { // the struct in the upcoming versions we need to reduce the array size. // See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps // slither-disable-next-line unused-state - uint256[42] __gap; + uint256[39] __gap; } event DepositParametersUpdated( diff --git a/solidity/contracts/bridge/IReservationBridge.sol b/solidity/contracts/bridge/IReservationBridge.sol index 837c161d9..af8f9a1ed 100644 --- a/solidity/contracts/bridge/IReservationBridge.sol +++ b/solidity/contracts/bridge/IReservationBridge.sol @@ -23,18 +23,44 @@ import "./Reservation.sol"; /// `delegatecall`, so callers use this interface against the Bridge /// address itself — the Bridge contract type does not declare them. interface IReservationBridge { + /// @notice See `ReservationRouter.requestReservationAcceptance`. + function requestReservationAcceptance( + uint256 reservationKey, + bytes20 walletPubKeyHash + ) external; + /// @notice See `ReservationRouter.requestReservedRedemption`. function requestReservedRedemption( uint256 reservationKey, address redeemer, - bytes calldata redeemerOutputScript + bytes calldata redeemerOutputScript, + bool feePaid, + bool useRetryCredit + ) external; + + /// @notice See `ReservationRouter.requestReservationReanchor`. + function requestReservationReanchor( + uint256 reservationKey, + bytes20 targetWalletPubKeyHash + ) external; + + /// @notice See `ReservationRouter.requestReservationDissolution`. + function requestReservationDissolution(uint256 reservationKey) external; + + /// @notice See `ReservationRouter.notifyReservationActionTimeout`. + function notifyReservationActionTimeout( + uint256 reservationKey, + uint32[] calldata walletMembersIDs ) external; /// @notice See `ReservationRouter.extendReservation`. function extendReservation(uint256 reservationKey) external; /// @notice See `ReservationRouter.notifyReservedRedemptionVeto`. - function notifyReservedRedemptionVeto(uint256 reservationKey) external; + function notifyReservedRedemptionVeto( + uint256 reservationKey, + uint64 requestNonce + ) external; /// @notice See `ReservationRouter.updateReservationParameters`. function updateReservationParameters( @@ -44,7 +70,8 @@ interface IReservationBridge { uint32 reservationTermSeconds, uint32 reservationGracePeriod, uint64 reservationMaxTotalAmount, - uint32 maxReservationsPerWallet + uint32 maxReservationsPerWallet, + uint32 reservationActionTimeout ) external; /// @notice Bridge treasury address. Declared by the Bridge contract @@ -59,6 +86,12 @@ interface IReservationBridge { view returns (Reservation.ReservationRequest memory); + /// @notice See `ReservationRouter.reservationActions`. + function reservationActions(uint256 reservationKey, uint64 requestNonce) + external + view + returns (Reservation.ReservationAction memory); + /// @notice See `ReservationRouter.reservationParameters`. function reservationParameters() external @@ -71,6 +104,7 @@ interface IReservationBridge { uint32 reservationGracePeriod, uint64 reservationMaxTotalAmount, uint64 reservationTotalAmount, - uint32 maxReservationsPerWallet + uint32 maxReservationsPerWallet, + uint32 reservationActionTimeout ); } diff --git a/solidity/contracts/bridge/Redemption.sol b/solidity/contracts/bridge/Redemption.sol index 73960bc86..b1fbbca65 100644 --- a/solidity/contracts/bridge/Redemption.sol +++ b/solidity/contracts/bridge/Redemption.sol @@ -63,14 +63,31 @@ interface IRedemptionWatchtower { view returns (uint32); - /// @notice Returns the applicable veto delay for a pending reserved - /// redemption identified by the given reservation key. + /// @notice Returns the applicable veto delay for the given pending + /// reserved redemption generation. Zero when the watchtower is + /// not enabled (no guardians can object) or was permanently + /// disabled. /// @param reservationKey The key of the reservation. + /// @param requestNonce The redemption generation. /// @return Reserved redemption veto delay. - function getReservedRedemptionDelay(uint256 reservationKey) + function getReservedRedemptionDelay( + uint256 reservationKey, + uint64 requestNonce + ) external view returns (uint32); + + /// @notice Determines whether a reserved redemption request is + /// considered safe: neither the reservation owner nor the + /// redeemer may be banned. Objection state is per generation, + /// so — unlike the pooled `isSafeRedemption` — no historical + /// objection count is consulted: a fresh generation always + /// starts with a clean count. + /// @param owner The reservation owner. + /// @param redeemer The address requesting the redemption. + /// @return True if the reserved redemption request is safe. + function isSafeReservedRedemption(address owner, address redeemer) external view - returns (uint32); + returns (bool); } /// @notice Aggregates functions common to the redemption transaction proof diff --git a/solidity/contracts/bridge/RedemptionWatchtower.sol b/solidity/contracts/bridge/RedemptionWatchtower.sol index 5ecba8b0b..ee13f497a 100644 --- a/solidity/contracts/bridge/RedemptionWatchtower.sol +++ b/solidity/contracts/bridge/RedemptionWatchtower.sol @@ -404,32 +404,51 @@ contract RedemptionWatchtower is OwnableUpgradeable { } } + /// @notice Computes the veto-state key of a reserved redemption + /// generation. Veto and objection state is keyed per + /// generation, so objections never accumulate across + /// generations and a once-vetoed reservation starts every new + /// generation with a clean objection count. + /// @param reservationKey The key of the reservation. + /// @param requestNonce The redemption generation. + function reservedVetoKey(uint256 reservationKey, uint64 requestNonce) + public + pure + returns (uint256) + { + return + uint256(keccak256(abi.encodePacked(reservationKey, requestNonce))); + } + /// @notice Raises an objection to a pending reserved redemption - /// identified by its reservation key. Works the same way as - /// `raiseObjection` does for regular redemption requests: each - /// pending reserved redemption has a delay period during which - /// the wallet is not allowed to process it and the guardians can - /// raise objections to; the third objection vetoes it. A veto - /// detains the surrendered gross amount from the Bridge, freezes - /// it for the freeze period, burns the penalty fee, and bans the - /// redeemer. The reservation itself returns to the Active state - /// on the Bridge -- the anchor outpoint was not spent, so the - /// in-kind claim survives (though a banned owner cannot - /// re-request until unbanned). + /// generation. Works the same way as `raiseObjection` does for + /// regular redemption requests: each pending reserved + /// redemption has a delay period during which the wallet is not + /// allowed to process it and the guardians can raise objections + /// to; the third objection vetoes it. A veto detains the + /// escrowed gross amount from the Bridge, freezes it for the + /// freeze period, burns the penalty fee, and bans the redeemer. + /// The reservation itself returns to the Active state on the + /// Bridge -- the anchor outpoint was not spent, so the in-kind + /// claim survives (though a banned owner cannot re-request + /// until unbanned). The vetoed generation never accepts an SPV + /// proof. /// @param reservationKey The key of the reservation with the pending /// reserved redemption. + /// @param requestNonce The pending redemption generation. /// @dev Requirements: /// - The caller must be a redemption guardian, - /// - The reserved redemption must not have been vetoed already, + /// - The generation must not have been vetoed already, /// - The guardian must not have already objected to it, - /// - The reserved redemption must be pending, - /// - The reserved redemption must be within its veto delay period, - /// unless it was requested before the watchtower was enabled. - function raiseReservedObjection(uint256 reservationKey) + /// - The generation must be the reservation's pending redemption, + /// - The generation must be within its veto delay period, unless + /// it was requested before the watchtower was enabled. + function raiseReservedObjection(uint256 reservationKey, uint64 requestNonce) external onlyGuardian { - VetoProposal storage veto = vetoProposals[reservationKey]; + uint256 vetoKey = reservedVetoKey(reservationKey, requestNonce); + VetoProposal storage veto = vetoProposals[vetoKey]; require( veto.objectionsCount < REQUIRED_OBJECTIONS_COUNT, @@ -437,60 +456,58 @@ contract RedemptionWatchtower is OwnableUpgradeable { ); uint256 objectionKey = uint256( - keccak256(abi.encodePacked(reservationKey, msg.sender)) + keccak256(abi.encodePacked(vetoKey, msg.sender)) ); require(!objections[objectionKey], "Guardian already objected"); - Reservation.ReservationRequest memory reservation = IReservationBridge( + Reservation.ReservationAction memory action = IReservationBridge( address(bridge) - ).reservations(reservationKey); + ).reservationActions(reservationKey, requestNonce); require( - reservation.state == - Reservation.ReservationState.RedemptionRequested, + action.actionType == Reservation.ActionType.Redemption && + action.state == Reservation.ActionState.Pending, "Reserved redemption does not exist" ); - if (reservation.redemptionRequestedAt >= watchtowerEnabledAt) { + if (action.requestedAt >= watchtowerEnabledAt) { require( /* solhint-disable-next-line not-rely-on-time */ block.timestamp < - reservation.redemptionRequestedAt + - _redemptionDelay( - veto.objectionsCount, - reservation.mintedAmount - ), + action.requestedAt + + _redemptionDelay(veto.objectionsCount, action.amount), "Redemption veto delay period expired" ); } else { - emit VetoPeriodCheckOmitted(reservationKey); + emit VetoPeriodCheckOmitted(vetoKey); } objections[objectionKey] = true; - veto.redeemer = reservation.redeemer; + veto.redeemer = action.redeemer; veto.objectionsCount++; - emit ObjectionRaised(reservationKey, msg.sender); + emit ObjectionRaised(vetoKey, msg.sender); if (veto.objectionsCount == REQUIRED_OBJECTIONS_COUNT) { uint64 penaltyFee = vetoPenaltyFeeDivisor > 0 - ? reservation.mintedAmount / vetoPenaltyFeeDivisor + ? action.amount / vetoPenaltyFeeDivisor : 0; - veto.withdrawableAmount = reservation.mintedAmount - penaltyFee; + veto.withdrawableAmount = action.amount - penaltyFee; /* solhint-disable-next-line not-rely-on-time */ veto.finalizedAt = uint32(block.timestamp); - isBanned[reservation.redeemer] = true; + isBanned[action.redeemer] = true; - emit Banned(reservation.redeemer); + emit Banned(action.redeemer); - emit VetoFinalized(reservationKey); + emit VetoFinalized(vetoKey); // Notify the Bridge about the veto. As result of this call, - // this contract receives the surrendered gross amount + // this contract receives the escrowed gross amount // (as Bank's balance) from the Bridge. IReservationBridge(address(bridge)).notifyReservedRedemptionVeto( - reservationKey + reservationKey, + requestNonce ); // Burn the penalty fee but leave the claimable amount for the // redeemer to withdraw after the freeze period. @@ -498,33 +515,57 @@ contract RedemptionWatchtower is OwnableUpgradeable { } } - /// @notice Returns the applicable veto delay for a pending reserved - /// redemption identified by the given reservation key. + /// @notice Returns the applicable veto delay for the given pending + /// reserved redemption generation. This is the delay the + /// Bridge proof path enforces before a redemption generation + /// can settle: wallets must not sign before it elapses. /// @param reservationKey The key of the reservation. + /// @param requestNonce The redemption generation. /// @return Reserved redemption veto delay. - /// @dev If the watchtower has been disabled, the delay is always zero. - function getReservedRedemptionDelay(uint256 reservationKey) - external - view - returns (uint32) - { - Reservation.ReservationRequest memory reservation = IReservationBridge( + /// @dev The delay is zero when the watchtower has not been enabled + /// (no guardians exist, so a delay would protect nothing) or has + /// been permanently disabled. + function getReservedRedemptionDelay( + uint256 reservationKey, + uint64 requestNonce + ) external view returns (uint32) { + if (watchtowerEnabledAt == 0) { + return 0; + } + + Reservation.ReservationAction memory action = IReservationBridge( address(bridge) - ).reservations(reservationKey); + ).reservationActions(reservationKey, requestNonce); require( - reservation.state == - Reservation.ReservationState.RedemptionRequested, + action.actionType == Reservation.ActionType.Redemption, "Reserved redemption does not exist" ); return _redemptionDelay( - vetoProposals[reservationKey].objectionsCount, - reservation.mintedAmount + vetoProposals[reservedVetoKey(reservationKey, requestNonce)] + .objectionsCount, + action.amount ); } + /// @notice Determines whether a reserved redemption request is + /// considered safe: neither the reservation owner nor the + /// redeemer may be banned. Objection state is per generation, + /// so no historical objection count is consulted — a fresh + /// generation always starts with a clean count. + /// @param owner The reservation owner. + /// @param redeemer The address requesting the redemption. + /// @return True if the reserved redemption request is safe. + function isSafeReservedRedemption(address owner, address redeemer) + external + view + returns (bool) + { + return !isBanned[owner] && !isBanned[redeemer]; + } + /// @notice Returns the redemption delay for a given number of objections /// and requested amount. /// @param objectionsCount Number of objections. diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index 14d52142c..e6e556b7b 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -22,77 +22,115 @@ import "./BitcoinTx.sol"; import "./BridgeState.sol"; import "./Deposit.sol"; import "./Redemption.sol"; +import "./ReservationProofs.sol"; import "./Wallets.sol"; import "../bank/Bank.sol"; -/// @title Bridge UTXO reservations -/// @notice The library handles the logic for reserving deposited UTXOs so -/// that a depositor's coins are custodied without ever being -/// commingled with the pooled supply and are returned in-kind -- -/// with an unbroken 1-input-1-output lineage -- upon redemption. -/// @dev A reserved deposit is a regular revealed deposit routed to the -/// designated reservation vault (`reveal.vault == reservationVault`). -/// Instead of being swept into the wallet's main UTXO, a reserved -/// deposit is *anchored*: the wallet performs a 1-input-1-output spend -/// of the deposit into a fresh wallet-controlled P2(W)PKH output that -/// carries no refund path. The Bank balance is credited only when the -/// SPV proof of that anchor transaction is submitted. The anchor thus -/// mirrors the sweep's refund-disabling role while dropping its -/// consolidating role -- balances are never credited against an output -/// the depositor could still claw back, and the coins never merge with -/// the pooled supply. +/// @title Bridge UTXO reservations — control plane +/// @notice The library handles the request/authorization side of UTXO +/// reservations: deposits custodied without ever being commingled +/// with the pooled supply and returned in-kind — with an unbroken +/// 1-input-1-output lineage — upon redemption. The SPV settlement +/// side lives in the `ReservationProofs` companion library. +/// @dev Every Bitcoin action of a reservation's life — the acceptance +/// anchor, an in-kind redemption, a re-anchor to another wallet, and +/// the post-term dissolution — follows a *two-phase, +/// authorize-then-prove* model (see RFC 13): /// -/// The registry tracks the reservation's current anchor outpoint -/// through optional re-anchoring hops (wallet migration) until the -/// reservation is closed by an in-kind redemption, or -- once the -/// custody term and grace period elapse -- dissolved into the wallet's -/// main UTXO, at which point the owner's balance simply remains an -/// ordinary pooled claim. +/// 1. An explicit *request* increments the position's monotonic +/// request nonce, performs every capacity and lifecycle check and +/// *reserves* what it checks, and *snapshots* every proof- and +/// settlement-critical parameter into a per-generation +/// `ReservationAction` record keyed by `(reservationKey, nonce)`. +/// A wallet signs only requested (and, for redemptions, +/// watchtower-authorized) generations, and nothing that changes +/// after the request can make the signed transaction unprovable. /// -/// Claim accounting: the amount credited (`mintedAmount`) is the gross -/// anchor output value; the per-deposit treasury fee computed at reveal -/// time is deliberately not netted. All protocol fees are charged by -/// the reservation vault as explicit transfers. Bitcoin miner fees are -/// the only in-kind deductions: the anchor and any re-anchor fees -/// reduce the on-chain `anchorAmount`, while the reserved redemption -/// always burns the full `mintedAmount`, so supply and backing -/// reconcile exactly when the reservation closes via redemption. +/// 2. The SPV *proof* settles the generation against the record's +/// snapshots — never against live parameters. A generation that +/// times out leaves a terminal record that still accepts a late +/// proof (closing the anchor lineage without a second refund); a +/// generation vetoed by the redemption watchtower never accepts a +/// proof, leaving an early-signing wallet exposed to the fraud +/// machinery — the intended consequence of signing an unauthorized +/// action. /// -/// Fraud-defense integration: accepting a reservation marks the -/// underlying deposit as swept (making the deposit outpoint recognized -/// by `Fraud.defeatFraudChallenge`), and every proven consumption of an -/// anchor outpoint (redemption, re-anchor, dissolution) records it in -/// `spentMainUTXOs` -- the existing registry of honestly-spent, -/// wallet-controlled outpoints the fraud defeat path consults. +/// Claim accounting: the amount credited (`mintedAmount`) is the gross +/// anchor output value. All protocol fees are charged by the +/// reservation vault as explicit transfers; Bitcoin miner fees are the +/// only in-kind deductions. library Reservation { using BridgeState for BridgeState.Storage; using Wallets for BridgeState.Storage; - using BitcoinTx for BridgeState.Storage; using BTCUtils for bytes; using BytesLib for bytes; - /// @notice Represents the state of a reservation. + /// @notice Represents the state of a reservation position. enum ReservationState { - /// @dev The reservation is unknown to the Bridge. + /// @dev The reservation is unknown to the Bridge. Acceptance + /// requests are made against positions in this state. Unknown, /// @dev The reservation was accepted (anchor proven, balance - /// credited) and its anchor outpoint is under wallet custody. + /// credited) and its anchor outpoint is under wallet custody, + /// with no action in flight. Active, - /// @dev The owner requested an in-kind redemption of the reserved - /// outpoint and surrendered the gross balance. The wallet is - /// expected to spend the anchor to the redeemer script. - RedemptionRequested, - /// @dev The reservation was closed, either by a proven in-kind - /// redemption or by dissolution into the wallet's main UTXO. - Closed + /// @dev An action (redemption, re-anchor or dissolution) has been + /// requested for the position and is not yet settled. The + /// pending action record is + /// `reservationActions[actionKey(reservationKey, requestNonce)]`. + ActionPending, + /// @dev The reservation was closed: redeemed in-kind, dissolved + /// into the wallet's main UTXO, or settled late after a + /// timeout. + Closed, + /// @dev The custodying wallet was terminated while the anchor was + /// outstanding. The owner's minted balance remains an ordinary + /// pooled claim; the anchor is no longer tracked. + Stranded } - /// @notice Represents a UTXO reservation. + /// @notice Type of a reservation action generation. + enum ActionType { + None, + Acceptance, + Redemption, + Reanchor, + Dissolution + } + + /// @notice Settlement state of a reservation action generation. + enum ActionState { + /// @dev No action was ever requested under this key. + Unknown, + /// @dev The action is requested and awaiting its SPV proof. For + /// redemptions, the wallet may only sign once the watchtower + /// delay has elapsed without a veto; the proof path enforces + /// this. + Pending, + /// @dev The action was proven and settled. + Settled, + /// @dev The action timed out. Terminal for the generation, but a + /// late proof of the (already confirmed) Bitcoin transaction + /// is still accepted against this record: it closes the anchor + /// lineage and marks the consumed outpoints as honestly spent, + /// without repeating the refund performed at timeout. + TimedOut, + /// @dev The action was vetoed by the redemption watchtower. + /// Terminal; a proof against this generation is rejected + /// forever. + Vetoed, + /// @dev The action's anchor was consumed by a late settlement of an + /// older timed-out generation. Terminal; the escrowed claim + /// was refunded during the late settlement. + Superseded + } + + /// @notice Represents a UTXO reservation position. struct ReservationRequest { - // The reservation owner holding the in-kind redemption right. Set to - // the deposit's depositor at acceptance time. + // The reservation owner holding the in-kind redemption right. Set + // to the deposit's depositor at acceptance time. address owner; // Gross amount in satoshi credited to the owner via the reservation // vault at acceptance time. This is the amount that must be @@ -109,9 +147,7 @@ library Reservation { // re-anchor hop. uint64 anchorAmount; // UNIX timestamp the custody term expires at. Purely a contract - // layer fact -- the anchor output carries no timelock. After - // `expiresAt + reservationGracePeriod` the wallet may dissolve the - // reservation into its main UTXO. + // layer fact -- the anchor output carries no timelock. // XXX: Unsigned 32-bit int unix seconds, will break February 7th 2106. uint32 expiresAt; // Hash of the Bitcoin transaction holding the current anchor output. @@ -119,28 +155,84 @@ library Reservation { // Output index of the current anchor output. Always 0 given anchor // transactions have a single output; kept for auditability. uint32 anchorTxOutputIndex; - // Current state of the reservation. + // Current state of the reservation position. ReservationState state; - // UNIX timestamp the pending reserved redemption was requested at. - // Zero when no redemption is pending. - // XXX: Unsigned 32-bit int unix seconds, will break February 7th 2106. - uint32 redemptionRequestedAt; - // Transaction maximum BTC fee in satoshi snapshotted at redemption - // request time. - uint64 redemptionTxMaxFee; - // The address able to claim the surrendered balance back should the - // pending reserved redemption time out. - address redeemer; - // keccak256 hash of the length-prefixed redeemer output script the - // pending reserved redemption must pay to. - bytes32 redeemerOutputScriptHash; + // Monotonic generation counter. Incremented by every action + // request (including acceptance requests); all action state is + // keyed by `(reservationKey, requestNonce)` so a stale generation + // can never be confused with a newer one. + uint64 requestNonce; + // True when the owner holds a single-use, fee-free redemption + // retry entitlement, minted when a fee-paid redemption request + // times out through the wallet's fault. Consumed by the next + // retry request; voided by a dissolution request. + bool retryCredit; + // Custody term length in seconds, snapshotted at acceptance. + // Extensions extend by this value; later governance changes apply + // to new reservations only. + uint32 termSeconds; + // Grace period in seconds, snapshotted at acceptance. After + // `expiresAt + gracePeriod` the reservation becomes dissolvable + // and can no longer be redeemed in-kind. + uint32 gracePeriod; // This struct doesn't contain `__gap` property as the structure is // stored in a mapping, mappings store values in different slots and // they are not contiguous with other values. } + /// @notice Represents one requested generation of a reservation action. + /// All fields the proof and settlement paths consult are + /// snapshotted here at request time; live parameters are never + /// read at settlement. + struct ReservationAction { + // 20-byte public key hash of the wallet the action's single + // wallet-controlled output must pay to: the designated custodian + // for acceptances, the migration target for re-anchors, the + // custodying wallet itself for dissolutions. Zero for redemptions. + bytes20 targetWalletPubKeyHash; + // UNIX timestamp the action was requested at. + uint32 requestedAt; + // UNIX timestamp after which the action can be reported timed out. + uint32 timeoutAt; + // Snapshotted maximum Bitcoin miner fee for the action transaction. + uint64 txMaxFee; + // Action type of this generation. + ActionType actionType; + // Settlement state of this generation. + ActionState state; + // True when the generation was created through a fee-paying vault + // entry point; a fee-paid redemption generation that times out + // mints the retry entitlement. + bool feePaid; + // The address able to claim the escrowed balance back should a + // redemption generation time out. Zero for other action types. + address redeemer; + // Amount in satoshi associated with the generation: the escrowed + // gross claim for redemptions, the capacity-reserved deposit value + // for acceptances, the anchor value at request time otherwise. + uint64 amount; + // keccak256 hash of the length-prefixed redeemer output script a + // redemption generation must pay to. Zero for other action types. + bytes32 redeemerOutputScriptHash; + // Wallet main UTXO hash expected by a dissolution generation + // (`registeredWallets[wallet].mainUtxoHash` at request time). + // Zero for other action types, and for dissolutions of wallets + // with no main UTXO. + bytes32 expectedMainUtxoHash; + } + + event ReservationAcceptanceRequested( + uint256 indexed reservationKey, + uint64 requestNonce, + bytes20 indexed walletPubKeyHash, + uint64 depositAmount, + uint64 txMaxFee, + uint32 timeoutAt + ); + event ReservationAccepted( uint256 indexed reservationKey, + uint64 requestNonce, bytes20 indexed walletPubKeyHash, address indexed owner, bytes32 anchorTxHash, @@ -155,249 +247,222 @@ library Reservation { event ReservedRedemptionRequested( uint256 indexed reservationKey, + uint64 requestNonce, address indexed redeemer, bytes redeemerOutputScript, uint64 mintedAmount, - uint64 txMaxFee + uint64 txMaxFee, + bool feePaid ); event ReservedRedemptionCompleted( uint256 indexed reservationKey, + uint64 requestNonce, bytes32 redemptionTxHash ); - event ReservedRedemptionTimedOut( + event ReservationReanchorRequested( uint256 indexed reservationKey, - bytes20 indexed walletPubKeyHash + uint64 requestNonce, + bytes20 indexed sourceWalletPubKeyHash, + bytes20 indexed targetWalletPubKeyHash, + uint64 txMaxFee ); - event ReservedRedemptionVetoed(uint256 indexed reservationKey); - event ReservationReanchored( uint256 indexed reservationKey, + uint64 requestNonce, bytes20 indexed newWalletPubKeyHash, bytes32 newAnchorTxHash, uint64 newAnchorAmount ); + event ReservationDissolutionRequested( + uint256 indexed reservationKey, + uint64 requestNonce, + bytes20 indexed walletPubKeyHash, + uint64 txMaxFee, + bytes32 expectedMainUtxoHash + ); + event ReservationDissolved( uint256 indexed reservationKey, + uint64 requestNonce, bytes20 indexed walletPubKeyHash, bytes32 dissolutionTxHash ); + event ReservationActionTimedOut( + uint256 indexed reservationKey, + uint64 requestNonce, + ActionType actionType + ); + + event ReservedRedemptionVetoed( + uint256 indexed reservationKey, + uint64 requestNonce + ); + + event ReservationActionSuperseded( + uint256 indexed reservationKey, + uint64 requestNonce + ); + + event ReservationLateSettled( + uint256 indexed reservationKey, + uint64 requestNonce, + ActionType actionType + ); + + event ReservationRetryCreditMinted(uint256 indexed reservationKey); + event ReservationParametersUpdated( uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationGracePeriod, uint64 reservationMaxTotalAmount, - uint32 maxReservationsPerWallet + uint32 maxReservationsPerWallet, + uint32 reservationActionTimeout ); event ReservationVaultUpdated(address reservationVault); - /// @notice Represents the type of a reservation lifecycle SPV proof. - enum ProofType { - /// @dev Proof of the anchor transaction accepting a reserved - /// deposit. `mainUtxo` and `reservationKey` parameters are - /// ignored; the reservation key is derived from the spent - /// deposit outpoint. - Acceptance, - /// @dev Proof of an in-kind reserved redemption transaction. - /// `mainUtxo` parameter is ignored. - Redemption, - /// @dev Proof of a re-anchor transaction moving the anchor outpoint - /// to another wallet. `mainUtxo` parameter is ignored. - Reanchor, - /// @dev Proof of a dissolution transaction merging an expired - /// reservation's anchor into the wallet's main UTXO. - Dissolution + /// @notice Computes the storage key of the action record of the given + /// reservation generation. + function actionKey(uint256 reservationKey, uint64 requestNonce) + internal + pure + returns (uint256) + { + return + uint256(keccak256(abi.encodePacked(reservationKey, requestNonce))); } /// @notice Single entry point for all reservation lifecycle SPV proofs. - /// Dispatches to the appropriate handler based on `proofType`. - /// Consolidated into one external function to preserve the - /// Bridge contract's EIP-170 deployment size margin. - /// @param proofType The type of the submitted proof, see `ProofType`. - /// @param txInfo Bitcoin transaction data. - /// @param proof Bitcoin proof data. - /// @param mainUtxo Data of the wallet's main UTXO; only used for - /// `Dissolution` proofs and ignored otherwise. - /// @param reservationKey The key of the target reservation; ignored for - /// `Acceptance` proofs where the key is derived from the spent - /// deposit outpoint. + /// Forwards to the `ReservationProofs` settlement library. The + /// forwarding hop exists so the `ReservationRouter` links + /// exactly one external library; see the router for the + /// architecture. function submitReservationProof( BridgeState.Storage storage self, uint8 proofType, BitcoinTx.Info calldata txInfo, BitcoinTx.Proof calldata proof, BitcoinTx.UTXO calldata mainUtxo, - uint256 reservationKey + uint256 reservationKey, + uint64 requestNonce ) external { - ProofType parsedProofType = ProofType(proofType); - - if (parsedProofType == ProofType.Acceptance) { - submitReservationAcceptanceProof(self, txInfo, proof); - } else if (parsedProofType == ProofType.Redemption) { - submitReservedRedemptionProof(self, txInfo, proof, reservationKey); - } else if (parsedProofType == ProofType.Reanchor) { - submitReservationReanchorProof(self, txInfo, proof, reservationKey); - } else { - submitReservationDissolutionProof( - self, - txInfo, - proof, - mainUtxo, - reservationKey - ); - } + ReservationProofs.submitReservationProof( + self, + proofType, + txInfo, + proof, + mainUtxo, + reservationKey, + requestNonce + ); } - /// @notice Used by the wallet to prove the BTC anchor transaction of a - /// reserved deposit and to credit the owner's balance - /// accordingly. The anchor is only accepted if it satisfies SPV - /// proof. - /// - /// The anchor transaction must spend exactly the revealed - /// reserved deposit as its sole input and create exactly one - /// P2(W)PKH output controlled by a registered wallet. Proving it - /// marks the deposit as swept (blocking any regular sweep and - /// enabling fraud challenge defeats for the deposit outpoint), - /// registers the reservation and credits the gross anchor value - /// to the depositor through the reservation vault. - /// @param anchorTx Bitcoin anchor transaction data. - /// @param anchorProof Bitcoin anchor proof data. + /// @notice Returns the action record of the given reservation + /// generation. + function getAction( + BridgeState.Storage storage self, + uint256 reservationKey, + uint64 requestNonce + ) internal view returns (ReservationAction storage) { + return self.reservationActions[actionKey(reservationKey, requestNonce)]; + } + + /// @notice Requests the acceptance of a revealed reserved deposit: the + /// authorization for the designated wallet to perform the + /// 1-input-1-output anchor spend killing the deposit's refund + /// path. Checks and reserves capacity so that the anchor, once + /// signed, can always be proven. + /// @param reservationKey The deposit key of the revealed reserved + /// deposit (`keccak256(fundingTxHash | fundingOutputIndex)`), + /// which doubles as the reservation key. + /// @param walletPubKeyHash 20-byte public key hash of the wallet that + /// will anchor the deposit. Must be the wallet the deposit was + /// revealed for. /// @dev Requirements: /// - The reservation vault must be set, - /// - `anchorTx` must have exactly one input pointing to a revealed, - /// unswept deposit routed to the reservation vault, - /// - `anchorTx` must have exactly one P2(W)PKH output locking funds - /// on a 20-byte public key hash of a Live or MovingFunds wallet, - /// - The anchor output value must respect the reservation minimum - /// amount, the per-transaction max fee, and the reservation caps. - function submitReservationAcceptanceProof( + /// - The deposit must be revealed to the reservation vault and not + /// swept, + /// - No acceptance authorization for the deposit may be pending, + /// - The wallet must be Live, + /// - The deposit amount must satisfy the reservation minimum plus + /// the transaction fee allowance, so a compliant anchor always + /// satisfies the minimum after fees, + /// - The authorization window (now + action timeout) must end + /// before the deposit's guaranteed refund-locktime margin + /// (`revealedAt + depositRevealAheadPeriod`), so an authorized + /// anchor can never race the depositor's refund, + /// - Reservation capacity (total amount, per-wallet count) must + /// allow the deposit; both are reserved by this call and + /// released if the authorization times out. + function requestReservationAcceptance( BridgeState.Storage storage self, - BitcoinTx.Info calldata anchorTx, - BitcoinTx.Proof calldata anchorProof - ) internal { + uint256 reservationKey, + bytes20 walletPubKeyHash + ) external { require( self.reservationVault != address(0), "Reservations are disabled" ); - bytes32 anchorTxHash = self.validateProof(anchorTx, anchorProof); - - uint256 reservationKey = resolveAcceptedDeposit( - self, - anchorTx.inputVector - ); - - (bytes20 walletPubKeyHash, uint64 anchorAmount) = processAnchorOutput( - self, - anchorTx.outputVector - ); - Deposit.DepositRequest storage deposit = self.deposits[reservationKey]; - - require( - anchorAmount >= self.reservationMinAmount, - "Reservation amount too small" - ); - // The difference between the deposit value and the anchor output - // value is the Bitcoin miner fee of the anchor transaction -- the - // only in-kind deduction the reservation lifecycle allows. + require(deposit.revealedAt != 0, "Deposit not revealed"); + require(deposit.sweptAt == 0, "Deposit already swept"); require( - deposit.amount - anchorAmount <= self.reservationTxMaxFee, - "Transaction fee is too high" + deposit.vault == self.reservationVault, + "Deposit not routed to the reservation vault" ); - registerReservation( - self, - reservationKey, - deposit.depositor, - walletPubKeyHash, - anchorAmount, - anchorTxHash + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == ReservationState.Unknown, + "Reservation already exists" ); - - // Credit the gross anchored amount through the reservation vault. - // The per-deposit treasury fee computed at reveal time is - // deliberately ignored: reservation claims are minted gross and all - // protocol fees are charged as explicit transfers by the vault. - address[] memory depositors = new address[](1); - depositors[0] = deposit.depositor; - uint256[] memory amounts = new uint256[](1); - amounts[0] = anchorAmount; - self.bank.increaseBalanceAndCall( - self.reservationVault, - depositors, - amounts + require( + getAction(self, reservationKey, reservation.requestNonce).state != + ActionState.Pending, + "Acceptance already pending" ); - } - - /// @notice Resolves the reserved deposit spent by the anchor transaction - /// input vector, validates it and marks it as swept. Marking the - /// deposit as swept blocks any future regular sweep, prevents - /// double acceptance, and makes the deposit outpoint recognized - /// as correctly spent by the fraud challenge defeat path. - /// @return reservationKey The deposit key of the reserved deposit, used - /// as the reservation key. - function resolveAcceptedDeposit( - BridgeState.Storage storage self, - bytes memory inputVector - ) internal returns (uint256 reservationKey) { - (bytes32 outpointTxHash, uint32 outpointIndex) = OutboundTx - .parseWalletOutboundTxInput(inputVector); - reservationKey = uint256( - keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) + require( + self.registeredWallets[walletPubKeyHash].state == + Wallets.WalletState.Live, + "Wallet must be in Live state" ); - Deposit.DepositRequest storage deposit = self.deposits[reservationKey]; - require(deposit.revealedAt != 0, "Deposit not revealed"); - require(deposit.sweptAt == 0, "Deposit already swept"); require( - deposit.vault == self.reservationVault, - "Deposit not routed to the reservation vault" + deposit.amount >= + self.reservationMinAmount + self.reservationTxMaxFee, + "Deposit amount too small for a reservation" ); /* solhint-disable-next-line not-rely-on-time */ - deposit.sweptAt = uint32(block.timestamp); - } - - /// @notice Parses the anchor transaction single output and validates it - /// is controlled by a Live or MovingFunds wallet. - function processAnchorOutput( - BridgeState.Storage storage self, - bytes memory outputVector - ) internal view returns (bytes20 walletPubKeyHash, uint64 anchorAmount) { - bytes memory anchorOutput = parseSingleOutput(outputVector); - walletPubKeyHash = self.extractPubKeyHash(anchorOutput); - anchorAmount = anchorOutput.extractValue(); - - Wallets.WalletState walletState = self - .registeredWallets[walletPubKeyHash] - .state; - require( - walletState == Wallets.WalletState.Live || - walletState == Wallets.WalletState.MovingFunds, - "Anchor wallet must be in Live or MovingFunds state" - ); - } + uint32 timeoutAt = uint32(block.timestamp) + + self.reservationActionTimeout; + + // The reveal-ahead validation guarantees the deposit refund + // locktime is at least `depositRevealAheadPeriod` after the reveal. + // The authorization window must fit inside that guaranteed margin: + // the wallet must never hold an authorization that is still valid + // when the depositor's refund path may open. + if (self.depositRevealAheadPeriod > 0) { + require( + timeoutAt <= deposit.revealedAt + self.depositRevealAheadPeriod, + "Authorization window would overlap the deposit refund window" + ); + } - /// @notice Registers a new reservation: checks and adjusts the caps, - /// stores the reservation record and indexes the anchor - /// outpoint. - function registerReservation( - BridgeState.Storage storage self, - uint256 reservationKey, - address owner, - bytes20 walletPubKeyHash, - uint64 anchorAmount, - bytes32 anchorTxHash - ) internal { - uint64 newTotal = self.reservationTotalAmount + anchorAmount; + // Reserve capacity using the deposit value as the upper bound of + // the anchor value; the settlement releases the miner-fee delta. + uint64 newTotal = self.reservationTotalAmount + deposit.amount; require( newTotal <= self.reservationMaxTotalAmount, "Total reserved amount cap exceeded" @@ -411,109 +476,74 @@ library Reservation { ); self.walletReservationsCount[walletPubKeyHash] = walletCount; - /* solhint-disable-next-line not-rely-on-time */ - uint32 expiresAt = uint32(block.timestamp) + - self.reservationTermSeconds; + uint64 requestNonce = ++reservation.requestNonce; - ReservationRequest storage reservation = self.reservations[ - reservationKey - ]; - reservation.owner = owner; - reservation.mintedAmount = anchorAmount; + ReservationAction storage action = getAction( + self, + reservationKey, + requestNonce + ); + action.actionType = ActionType.Acceptance; + action.state = ActionState.Pending; /* solhint-disable-next-line not-rely-on-time */ - reservation.acceptedAt = uint32(block.timestamp); - reservation.walletPubKeyHash = walletPubKeyHash; - reservation.anchorAmount = anchorAmount; - reservation.expiresAt = expiresAt; - reservation.anchorTxHash = anchorTxHash; - reservation.anchorTxOutputIndex = 0; - reservation.state = ReservationState.Active; + action.requestedAt = uint32(block.timestamp); + action.timeoutAt = timeoutAt; + action.txMaxFee = self.reservationTxMaxFee; + action.targetWalletPubKeyHash = walletPubKeyHash; + action.amount = deposit.amount; - self.reservationsByAnchorUtxo[ - uint256(keccak256(abi.encodePacked(anchorTxHash, uint32(0)))) - ] = reservationKey; - - // slither-disable-next-line reentrancy-events - emit ReservationAccepted( + emit ReservationAcceptanceRequested( reservationKey, + requestNonce, walletPubKeyHash, - owner, - anchorTxHash, - anchorAmount, - expiresAt + deposit.amount, + action.txMaxFee, + timeoutAt ); } - /// @notice Extends the custody term of a reservation by the current - /// reservation term length. The custody fee for the extension is - /// collected by the reservation vault before this call. - /// @param reservationKey The key of the reservation to extend. - /// @dev Requirements: - /// - The caller must be the reservation vault, - /// - The reservation must be in the Active or RedemptionRequested - /// state, - /// - The reservation must not be past its grace period. - function extendReservation( - BridgeState.Storage storage self, - uint256 reservationKey - ) external { - require( - msg.sender == self.reservationVault, - "Caller is not the reservation vault" - ); - - ReservationRequest storage reservation = self.reservations[ - reservationKey - ]; - require( - reservation.state == ReservationState.Active || - reservation.state == ReservationState.RedemptionRequested, - "Reservation is not active" - ); - require( - /* solhint-disable-next-line not-rely-on-time */ - block.timestamp <= - uint256(reservation.expiresAt) + self.reservationGracePeriod, - "Reservation past grace period" - ); - - uint32 base = reservation.expiresAt; - /* solhint-disable-next-line not-rely-on-time */ - if (base < block.timestamp) { - /* solhint-disable-next-line not-rely-on-time */ - base = uint32(block.timestamp); - } - reservation.expiresAt = base + self.reservationTermSeconds; - - emit ReservationExtended(reservationKey, reservation.expiresAt); - } - /// @notice Requests an in-kind redemption of a reservation: the wallet /// is expected to spend exactly the reservation's anchor /// outpoint to the redeemer output script in a 1-input-1-output - /// transaction. The gross minted amount is taken from the - /// reservation vault's Bank balance and held by the Bridge until - /// the redemption is proven (burned) or times out (returned to - /// the redeemer). + /// transaction, once the watchtower delay elapses without a + /// veto. The gross minted amount is taken from the reservation + /// vault's Bank balance and held by the Bridge until the + /// redemption is proven (burned) or times out (returned to the + /// redeemer). /// @param reservationKey The key of the reservation to redeem. - /// @param redeemer The address able to claim the surrendered balance - /// back if the redemption times out. + /// @param redeemer The address able to claim the escrowed balance back + /// if the redemption times out. /// @param redeemerOutputScript The redeemer's length-prefixed output /// script (P2PKH, P2WPKH, P2SH or P2WSH) that will be used to /// lock the redeemed BTC. + /// @param feePaid True when the vault collected the redemption fee for + /// this request; a fee-paid generation that times out mints the + /// single-use retry entitlement. + /// @param useRetryCredit True when the request consumes the fee-free + /// retry entitlement instead of paying the fee. /// @dev Requirements: /// - The caller must be the reservation vault, which must have /// approved the Bridge in the Bank for the gross minted amount, - /// - The reservation must be in the Active state, - /// - If the redemption watchtower is set, the request must be - /// considered safe by the watchtower, + /// - The reservation must be Active and custodied by a Live or + /// MovingFunds wallet, + /// - The custody term plus (snapshotted) grace period must not + /// have elapsed — after grace only dissolution is possible, so + /// request/timeout cycling cannot defeat the stranding bound. + /// A retry-entitled request is exempt until a dissolution is + /// requested (which voids the entitlement), + /// - When `useRetryCredit` is set, the position must hold the + /// retry entitlement (consumed by this call), + /// - If the redemption watchtower is set, neither the owner nor + /// the redeemer may be banned, /// - `redeemerOutputScript` must be a standard type and must not /// pay to the custodying wallet's public key hash. function requestReservedRedemption( BridgeState.Storage storage self, uint256 reservationKey, address redeemer, - bytes calldata redeemerOutputScript + bytes calldata redeemerOutputScript, + bool feePaid, + bool useRetryCredit ) external { require( msg.sender == self.reservationVault, @@ -532,15 +562,33 @@ library Reservation { "Reservation is not active" ); + { + Wallets.WalletState walletState = self + .registeredWallets[reservation.walletPubKeyHash] + .state; + require( + walletState == Wallets.WalletState.Live || + walletState == Wallets.WalletState.MovingFunds, + "Wallet must be in Live or MovingFunds state" + ); + } + + if (useRetryCredit) { + require(reservation.retryCredit, "No retry entitlement"); + reservation.retryCredit = false; + } else { + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp <= + uint256(reservation.expiresAt) + reservation.gracePeriod, + "Reservation past grace period" + ); + } + if (self.redemptionWatchtower != address(0)) { require( IRedemptionWatchtower(self.redemptionWatchtower) - .isSafeRedemption( - reservation.walletPubKeyHash, - redeemerOutputScript, - self.reservationVault, - redeemer - ), + .isSafeReservedRedemption(reservation.owner, redeemer), "Redemption request rejected by the watchtower" ); } @@ -562,22 +610,35 @@ library Reservation { "Redeemer output script must not point to the wallet PKH" ); - reservation.state = ReservationState.RedemptionRequested; - reservation.redeemer = redeemer; - reservation.redeemerOutputScriptHash = keccak256( - redeemerOutputScriptMem + reservation.state = ReservationState.ActionPending; + uint64 requestNonce = ++reservation.requestNonce; + + ReservationAction storage action = getAction( + self, + reservationKey, + requestNonce ); + action.actionType = ActionType.Redemption; + action.state = ActionState.Pending; /* solhint-disable-next-line not-rely-on-time */ - reservation.redemptionRequestedAt = uint32(block.timestamp); - reservation.redemptionTxMaxFee = self.reservationTxMaxFee; + action.requestedAt = uint32(block.timestamp); + /* solhint-disable-next-line not-rely-on-time */ + action.timeoutAt = uint32(block.timestamp) + self.redemptionTimeout; + action.txMaxFee = self.reservationTxMaxFee; + action.feePaid = feePaid; + action.redeemer = redeemer; + action.amount = reservation.mintedAmount; + action.redeemerOutputScriptHash = keccak256(redeemerOutputScriptMem); // slither-disable-next-line reentrancy-events emit ReservedRedemptionRequested( reservationKey, + requestNonce, redeemer, redeemerOutputScript, reservation.mintedAmount, - reservation.redemptionTxMaxFee + action.txMaxFee, + feePaid ); self.bank.transferBalanceFrom( @@ -587,388 +648,383 @@ library Reservation { ); } - /// @notice Used by the wallet to prove the BTC reserved redemption - /// transaction and close the reservation. The transaction must - /// spend exactly the reservation's anchor outpoint as its sole - /// input and pay the requested redeemer output script as its - /// sole output. The full gross minted amount is burned: the - /// difference between the burned amount and the BTC paid out is - /// the Bitcoin miner fee (plus any re-anchor fees accrued - /// in-kind during custody), so supply and backing reconcile - /// exactly. - /// @param redemptionTx Bitcoin reserved redemption transaction data. - /// @param redemptionProof Bitcoin reserved redemption proof data. - /// @param reservationKey The key of the reservation being redeemed. + /// @notice Requests the re-anchoring of a reservation to another + /// wallet: the authorization for the source wallet to spend the + /// anchor in a 1-input-1-output transaction paying the target + /// wallet. Used during wallet migration so reservations never + /// pin retiring wallets, and for governance-approved rotations. + /// @param reservationKey The key of the reservation to re-anchor. + /// @param targetWalletPubKeyHash 20-byte public key hash of the target + /// wallet. + /// @param privileged True when the call is made by the governance + /// (checked by the calling contract), which may rotate anchors + /// away from Live wallets. /// @dev Requirements: - /// - The reservation must have a pending reserved redemption, - /// - `redemptionTx` must spend the reservation's current anchor - /// outpoint as its sole input, - /// - `redemptionTx` must have a single output paying the requested - /// redeemer output script with a value within the acceptable - /// range. - function submitReservedRedemptionProof( + /// - The reservation must be Active, + /// - The source wallet must be in the MovingFunds state (anyone + /// may then request — migration is the system's duty), or Live + /// with the governance as the caller (approved rotation), + /// - The target wallet must be Live and different from the source, + /// - The target wallet's reservation-count capacity must allow the + /// move; the capacity is reserved by this call and released if + /// the authorization times out. + function requestReservationReanchor( BridgeState.Storage storage self, - BitcoinTx.Info calldata redemptionTx, - BitcoinTx.Proof calldata redemptionProof, - uint256 reservationKey - ) internal { - bytes32 redemptionTxHash = self.validateProof( - redemptionTx, - redemptionProof - ); - + uint256 reservationKey, + bytes20 targetWalletPubKeyHash, + bool privileged + ) external { ReservationRequest storage reservation = self.reservations[ reservationKey ]; require( - reservation.state == ReservationState.RedemptionRequested, - "No pending reserved redemption" + reservation.state == ReservationState.Active, + "Reservation is not active" ); - consumeAnchor(self, reservation, redemptionTx.inputVector); - - bytes memory output = parseSingleOutput(redemptionTx.outputVector); - uint64 outputValue = output.extractValue(); - { - bytes memory outputScript = output.slice(8, output.length - 8); - require( - keccak256(outputScript) == reservation.redeemerOutputScriptHash, - "Output does not pay the requested redeemer script" - ); + Wallets.WalletState sourceState = self + .registeredWallets[reservation.walletPubKeyHash] + .state; + if (sourceState == Wallets.WalletState.Live) { + require( + privileged, + "Only governance can rotate a Live wallet's anchor" + ); + } else { + require( + sourceState == Wallets.WalletState.MovingFunds, + "Source wallet must be in Live or MovingFunds state" + ); + } } - // Underflow-safe range check. `redemptionTxMaxFee` is a governable - // parameter, not a value derived from the proven transaction, so it - // may exceed `anchorAmount` after re-anchor hops shrink the anchor - // or after a governance fee increase. The equivalent formulation - // below avoids the `anchorAmount - redemptionTxMaxFee` subtraction - // that would otherwise revert and permanently strand the - // reservation. `outputValue <= anchorAmount` is guaranteed by - // Bitcoin consensus (an output cannot exceed its input) but is - // asserted defensively. require( - outputValue <= reservation.anchorAmount && - reservation.anchorAmount - outputValue <= - reservation.redemptionTxMaxFee, - "Output value is not within the acceptable range" + targetWalletPubKeyHash != reservation.walletPubKeyHash, + "Target wallet must differ from the source wallet" + ); + require( + self.registeredWallets[targetWalletPubKeyHash].state == + Wallets.WalletState.Live, + "Target wallet must be in Live state" ); - closeReservation(self, reservation); + // Reserve the target wallet's count capacity; the source wallet's + // count is released at settlement (or kept on timeout). + uint32 targetCount = self.walletReservationsCount[ + targetWalletPubKeyHash + ] + 1; + require( + targetCount <= self.maxReservationsPerWallet, + "Wallet reservations cap exceeded" + ); + self.walletReservationsCount[targetWalletPubKeyHash] = targetCount; - // slither-disable-next-line reentrancy-events - emit ReservedRedemptionCompleted(reservationKey, redemptionTxHash); + reservation.state = ReservationState.ActionPending; + uint64 requestNonce = ++reservation.requestNonce; - // Burn the gross minted amount held by the Bridge since the - // redemption request. - self.bank.decreaseBalance(reservation.mintedAmount); + ReservationAction storage action = getAction( + self, + reservationKey, + requestNonce + ); + action.actionType = ActionType.Reanchor; + action.state = ActionState.Pending; + /* solhint-disable not-rely-on-time */ + action.requestedAt = uint32(block.timestamp); + action.timeoutAt = + uint32(block.timestamp) + + self.reservationActionTimeout; + /* solhint-enable not-rely-on-time */ + action.txMaxFee = self.reservationTxMaxFee; + action.targetWalletPubKeyHash = targetWalletPubKeyHash; + action.amount = reservation.anchorAmount; + + emit ReservationReanchorRequested( + reservationKey, + requestNonce, + reservation.walletPubKeyHash, + targetWalletPubKeyHash, + action.txMaxFee + ); } - /// @notice Notifies that a pending reserved redemption has timed out. - /// The surrendered balance is returned to the redeemer, the - /// wallet operators are slashed the same way as for a regular - /// redemption timeout, and the reservation returns to the - /// Active state -- the anchor outpoint was not spent, so the - /// in-kind claim survives and the redemption can be re-requested. - /// @param reservationKey The key of the reservation with the timed out - /// redemption. - /// @param walletMembersIDs Identifiers of the wallet signing group - /// members. + /// @notice Requests the dissolution of an expired reservation: the + /// authorization for the custodying wallet to merge the anchor + /// into its main UTXO. After dissolution the owner's minted + /// balance simply remains an ordinary pooled claim. + /// @param reservationKey The key of the reservation to dissolve. /// @dev Requirements: - /// - The reservation must have a pending reserved redemption, - /// - The amount of time defined by `redemptionTimeout` must have - /// passed since the reserved redemption was requested. - function notifyReservedRedemptionTimeout( + /// - The reservation must be Active, + /// - The custody term plus (snapshotted) grace period must have + /// elapsed, + /// - The custodying wallet must be in Live or MovingFunds state, + /// - No other dissolution may be in flight for the wallet (the + /// per-wallet main-UTXO action lock): concurrent dissolutions + /// of a no-main-UTXO wallet could otherwise all confirm on + /// Bitcoin with only the first being provable. + /// + /// Requesting a dissolution voids the position's redemption retry + /// entitlement: dissolution is the terminal cleanup and the + /// stranding bound takes precedence. + function requestReservationDissolution( BridgeState.Storage storage self, - uint256 reservationKey, - uint32[] calldata walletMembersIDs + uint256 reservationKey ) external { ReservationRequest storage reservation = self.reservations[ reservationKey ]; require( - reservation.state == ReservationState.RedemptionRequested, - "No pending reserved redemption" + reservation.state == ReservationState.Active, + "Reservation is not active" ); require( /* solhint-disable-next-line not-rely-on-time */ block.timestamp > - reservation.redemptionRequestedAt + self.redemptionTimeout, - "Redemption request has not timed out" + uint256(reservation.expiresAt) + reservation.gracePeriod, + "Reservation term or grace period not elapsed" ); - address redeemer = reservation.redeemer; - uint64 refundAmount = reservation.mintedAmount; bytes20 walletPubKeyHash = reservation.walletPubKeyHash; + Wallets.Wallet storage wallet = self.registeredWallets[ + walletPubKeyHash + ]; + { + Wallets.WalletState walletState = wallet.state; + require( + walletState == Wallets.WalletState.Live || + walletState == Wallets.WalletState.MovingFunds, + "Wallet must be in Live or MovingFunds state" + ); + } - reservation.state = ReservationState.Active; - reservation.redeemer = address(0); - reservation.redeemerOutputScriptHash = bytes32(0); - reservation.redemptionRequestedAt = 0; - reservation.redemptionTxMaxFee = 0; - - // Propagate timeout consequences to the wallet: slashing and state - // transition follow exactly the regular redemption timeout rules. - self.notifyWalletRedemptionTimeout(walletPubKeyHash, walletMembersIDs); + require( + self.walletPendingDissolution[walletPubKeyHash] == 0, + "Another dissolution is pending for the wallet" + ); + self.walletPendingDissolution[walletPubKeyHash] = reservationKey; - // slither-disable-next-line reentrancy-events - emit ReservedRedemptionTimedOut(reservationKey, walletPubKeyHash); + reservation.retryCredit = false; + reservation.state = ReservationState.ActionPending; + uint64 requestNonce = ++reservation.requestNonce; - // Return the surrendered balance to the redeemer as Bank balance. - self.bank.transferBalance(redeemer, refundAmount); + ReservationAction storage action = getAction( + self, + reservationKey, + requestNonce + ); + action.actionType = ActionType.Dissolution; + action.state = ActionState.Pending; + /* solhint-disable not-rely-on-time */ + action.requestedAt = uint32(block.timestamp); + action.timeoutAt = + uint32(block.timestamp) + + self.reservationActionTimeout; + /* solhint-enable not-rely-on-time */ + action.txMaxFee = self.reservationTxMaxFee; + action.targetWalletPubKeyHash = walletPubKeyHash; + action.amount = reservation.anchorAmount; + action.expectedMainUtxoHash = wallet.mainUtxoHash; + + emit ReservationDissolutionRequested( + reservationKey, + requestNonce, + walletPubKeyHash, + action.txMaxFee, + action.expectedMainUtxoHash + ); } - /// @notice Notifies that a pending reserved redemption was vetoed in the - /// redemption watchtower. Mirrors - /// `Redemption.notifyRedemptionVeto`: the surrendered balance is - /// detained and passed to the watchtower (as Bank balance) for - /// further processing, the pending request is cleared, and the - /// reservation returns to the Active state -- the anchor - /// outpoint was not spent, so the in-kind claim survives. - /// @param reservationKey The key of the reservation with the vetoed - /// redemption. + /// @notice Notifies that the pending action of the given reservation + /// has timed out. Writes the terminal `TimedOut` record — + /// which still accepts a late proof — releases the capacity + /// and locks reserved at request time, refunds the escrowed + /// claim for redemptions (minting the fee-free retry + /// entitlement when the generation had paid the fee), and + /// propagates wallet consequences: redemption and dissolution + /// timeouts slash the wallet operators exactly like a pooled + /// redemption timeout. + /// @param reservationKey The key of the reservation with the timed out + /// action. + /// @param walletMembersIDs Identifiers of the wallet signing group + /// members. Only consulted for redemption and dissolution + /// timeouts (the slashing path); pass an empty array otherwise. /// @dev Requirements: - /// - The caller must be the redemption watchtower, - /// - The reservation must have a pending reserved redemption. - function notifyReservedRedemptionVeto( + /// - The reservation must have a pending action (or a pending + /// acceptance authorization), + /// - The action's timeout must have elapsed. + function notifyReservationActionTimeout( BridgeState.Storage storage self, - uint256 reservationKey + uint256 reservationKey, + uint32[] calldata walletMembersIDs ) external { - require( - msg.sender == self.redemptionWatchtower, - "Caller is not the redemption watchtower" - ); - ReservationRequest storage reservation = self.reservations[ reservationKey ]; + uint64 requestNonce = reservation.requestNonce; + ReservationAction storage action = getAction( + self, + reservationKey, + requestNonce + ); + require(action.state == ActionState.Pending, "No pending action"); require( - reservation.state == ReservationState.RedemptionRequested, - "No pending reserved redemption" + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp > action.timeoutAt, + "Action has not timed out" ); - uint64 detainedAmount = reservation.mintedAmount; + action.state = ActionState.TimedOut; + + ActionType actionType = action.actionType; + + if (actionType == ActionType.Acceptance) { + // Release the capacity reserved at request time; the deposit + // remains revealed and a new acceptance can be requested while + // the refund-locktime margin allows. + self.reservationTotalAmount -= action.amount; + self.walletReservationsCount[action.targetWalletPubKeyHash] -= 1; + } else if (actionType == ActionType.Redemption) { + reservation.state = ReservationState.Active; + + if (action.feePaid) { + reservation.retryCredit = true; + emit ReservationRetryCreditMinted(reservationKey); + } + + // Propagate timeout consequences to the wallet: slashing and + // state transition follow the regular redemption timeout rules. + self.notifyWalletRedemptionTimeout( + reservation.walletPubKeyHash, + walletMembersIDs + ); - reservation.state = ReservationState.Active; - reservation.redeemer = address(0); - reservation.redeemerOutputScriptHash = bytes32(0); - reservation.redemptionRequestedAt = 0; - reservation.redemptionTxMaxFee = 0; + // Return the escrowed balance to the redeemer as Bank balance. + self.bank.transferBalance(action.redeemer, action.amount); + } else if (actionType == ActionType.Reanchor) { + reservation.state = ReservationState.Active; + // Release the target wallet's reserved count capacity. + self.walletReservationsCount[action.targetWalletPubKeyHash] -= 1; + } else { + // Dissolution. + reservation.state = ReservationState.Active; + delete self.walletPendingDissolution[reservation.walletPubKeyHash]; + + // A wallet failing its dissolution duty is slashed like a + // wallet failing a redemption: dissolution is the mechanism + // that makes term + grace a hard stranding bound. + self.notifyWalletRedemptionTimeout( + reservation.walletPubKeyHash, + walletMembersIDs + ); + } // slither-disable-next-line reentrancy-events - emit ReservedRedemptionVetoed(reservationKey); - - self.bank.transferBalance(self.redemptionWatchtower, detainedAmount); + emit ReservationActionTimedOut( + reservationKey, + requestNonce, + actionType + ); } - /// @notice Used by the wallet to prove a re-anchor transaction moving a - /// reservation's anchor outpoint to another (or a fresh output - /// of the same) wallet in a 1-input-1-output spend. Used during - /// wallet migration so reservations never pin retiring wallets. - /// @param reanchorTx Bitcoin re-anchor transaction data. - /// @param reanchorProof Bitcoin re-anchor proof data. - /// @param reservationKey The key of the reservation being re-anchored. + /// @notice Notifies that the pending reserved redemption of the given + /// reservation was vetoed in the redemption watchtower. The + /// escrowed balance is detained and passed to the watchtower + /// (as Bank balance) for penalty/freeze processing, the + /// generation becomes terminally `Vetoed` — a proof against it + /// is rejected forever — and the position returns to Active: + /// the anchor was not spent (an honest wallet does not sign + /// before authorization), so the in-kind claim survives. + /// @param reservationKey The key of the reservation with the vetoed + /// redemption. + /// @param requestNonce The generation being vetoed. /// @dev Requirements: - /// - The reservation must be in the Active state (a pending - /// reserved redemption blocks re-anchoring), - /// - `reanchorTx` must spend the current anchor outpoint as its - /// sole input and create a single P2(W)PKH output controlled by - /// a Live wallet, - /// - The value difference (Bitcoin miner fee) must not exceed the - /// reservation transaction max fee. - /// - /// The source wallet state is deliberately not restricted: the - /// ability to produce the transaction is enforced by Bitcoin (only - /// the custodying wallet can sign the anchor spend) and moving - /// reservations out must remain possible for wallets in any - /// lifecycle state. - function submitReservationReanchorProof( + /// - The caller must be the redemption watchtower, + /// - The generation must be the position's pending redemption. + function notifyReservedRedemptionVeto( BridgeState.Storage storage self, - BitcoinTx.Info calldata reanchorTx, - BitcoinTx.Proof calldata reanchorProof, - uint256 reservationKey - ) internal { - bytes32 reanchorTxHash = self.validateProof(reanchorTx, reanchorProof); + uint256 reservationKey, + uint64 requestNonce + ) external { + require( + msg.sender == self.redemptionWatchtower, + "Caller is not the redemption watchtower" + ); ReservationRequest storage reservation = self.reservations[ reservationKey ]; require( - reservation.state == ReservationState.Active, - "Reservation is not active" - ); - - consumeAnchor(self, reservation, reanchorTx.inputVector); - - bytes memory output = parseSingleOutput(reanchorTx.outputVector); - bytes20 newWalletPubKeyHash = self.extractPubKeyHash(output); - uint64 newAnchorAmount = output.extractValue(); - - require( - self.registeredWallets[newWalletPubKeyHash].state == - Wallets.WalletState.Live, - "Target wallet must be in Live state" + reservation.requestNonce == requestNonce, + "Not the current generation" ); - // `newAnchorAmount <= anchorAmount` is guaranteed by Bitcoin - // consensus (the re-anchor output cannot exceed its input). - require( - reservation.anchorAmount - newAnchorAmount <= - self.reservationTxMaxFee, - "Transaction fee is too high" + ReservationAction storage action = getAction( + self, + reservationKey, + requestNonce ); - - // Dust floor: the re-anchored amount must stay strictly above the - // per-transaction fee bound, keeping the anchor clear of dust and - // preserving positive redemption value. This is deliberately a dust - // floor rather than `reservationMinAmount`: a minimum-sized - // reservation must remain migratable (a `reservationMinAmount` floor - // combined with the proposal validator's positive-fee requirement - // would leave no compliant re-anchor for an exactly-minimum anchor, - // pinning a retiring wallet). Bounding cumulative Byzantine - // re-anchor grinding is deferred to the authorized-action model (an - // explicit migration request with nonce, owner/target authorization, - // and a cumulative fee budget), tracked as a follow-up. require( - newAnchorAmount > self.reservationTxMaxFee, - "Re-anchor amount below the dust floor" + action.actionType == ActionType.Redemption && + action.state == ActionState.Pending, + "No pending reserved redemption" ); - bytes20 oldWalletPubKeyHash = reservation.walletPubKeyHash; - if (oldWalletPubKeyHash != newWalletPubKeyHash) { - self.walletReservationsCount[oldWalletPubKeyHash] -= 1; - uint32 newCount = self.walletReservationsCount[ - newWalletPubKeyHash - ] + 1; - require( - newCount <= self.maxReservationsPerWallet, - "Wallet reservations cap exceeded" - ); - self.walletReservationsCount[newWalletPubKeyHash] = newCount; - } - - // The miner fee reduces the on-chain earmarked amount. The gross - // claim (`mintedAmount`) is unchanged; the accumulated in-kind fees - // are settled when the reservation is redeemed (full gross burn). - self.reservationTotalAmount -= (reservation.anchorAmount - - newAnchorAmount); - - reservation.walletPubKeyHash = newWalletPubKeyHash; - reservation.anchorAmount = newAnchorAmount; - reservation.anchorTxHash = reanchorTxHash; - reservation.anchorTxOutputIndex = 0; + action.state = ActionState.Vetoed; + reservation.state = ReservationState.Active; - self.reservationsByAnchorUtxo[ - uint256(keccak256(abi.encodePacked(reanchorTxHash, uint32(0)))) - ] = reservationKey; + // slither-disable-next-line reentrancy-events + emit ReservedRedemptionVetoed(reservationKey, requestNonce); - emit ReservationReanchored( - reservationKey, - newWalletPubKeyHash, - reanchorTxHash, - newAnchorAmount - ); + self.bank.transferBalance(self.redemptionWatchtower, action.amount); } - /// @notice Used by the wallet to prove a dissolution transaction merging - /// an expired reservation's anchor outpoint into the wallet's - /// main UTXO. After dissolution the owner's minted balance - /// simply remains an ordinary pooled claim; no balances are - /// moved or burned. - /// @param dissolutionTx Bitcoin dissolution transaction data. - /// @param dissolutionProof Bitcoin dissolution proof data. - /// @param mainUtxo Data of the wallet's main UTXO, as currently known on - /// the Ethereum chain. Ignored if the wallet has no main UTXO. - /// @param reservationKey The key of the reservation being dissolved. + /// @notice Extends the custody term of a reservation by its snapshotted + /// term length. The custody fee for the extension is collected + /// by the reservation vault before this call. + /// @param reservationKey The key of the reservation to extend. /// @dev Requirements: - /// - The reservation must be in the Active state (a pending - /// reserved redemption always wins over dissolution), - /// - The custody term plus grace period must have elapsed, - /// - The custodying wallet must be in Live or MovingFunds state, - /// - If the wallet has a main UTXO, `dissolutionTx` must spend - /// exactly the anchor outpoint (first input) and that main UTXO - /// (second input); otherwise it must spend exactly the anchor - /// outpoint, - /// - `dissolutionTx` must have a single P2(W)PKH output locking - /// funds back on the custodying wallet's public key hash; that - /// output becomes the wallet's new main UTXO. - /// - /// Note the Bridge cannot verify *when* the dissolution transaction - /// was signed. A wallet signing it before the grace period elapses - /// cannot prove it here (this function reverts), so such a spend is - /// an undefeatable fraud challenge target until the grace period - /// passes -- premature dissolution is deterred economically by the - /// fraud slashing machinery. - function submitReservationDissolutionProof( + /// - The caller must be the reservation vault, + /// - The reservation must be Active, or awaiting the settlement + /// of a pending redemption, + /// - The reservation must not be past its (snapshotted) grace + /// period. + function extendReservation( BridgeState.Storage storage self, - BitcoinTx.Info calldata dissolutionTx, - BitcoinTx.Proof calldata dissolutionProof, - BitcoinTx.UTXO calldata mainUtxo, uint256 reservationKey - ) internal { - bytes32 dissolutionTxHash = self.validateProof( - dissolutionTx, - dissolutionProof + ) external { + require( + msg.sender == self.reservationVault, + "Caller is not the reservation vault" ); ReservationRequest storage reservation = self.reservations[ reservationKey ]; require( - reservation.state == ReservationState.Active, + reservation.state == ReservationState.Active || + (reservation.state == ReservationState.ActionPending && + getAction(self, reservationKey, reservation.requestNonce) + .actionType == + ActionType.Redemption), "Reservation is not active" ); require( /* solhint-disable-next-line not-rely-on-time */ - block.timestamp > - uint256(reservation.expiresAt) + self.reservationGracePeriod, - "Reservation term or grace period not elapsed" + block.timestamp <= + uint256(reservation.expiresAt) + reservation.gracePeriod, + "Reservation past grace period" ); - Wallets.Wallet storage wallet = self.registeredWallets[ - reservation.walletPubKeyHash - ]; - { - Wallets.WalletState walletState = wallet.state; - require( - walletState == Wallets.WalletState.Live || - walletState == Wallets.WalletState.MovingFunds, - "Wallet must be in Live or MovingFunds state" - ); + uint32 base = reservation.expiresAt; + /* solhint-disable-next-line not-rely-on-time */ + if (base < block.timestamp) { + /* solhint-disable-next-line not-rely-on-time */ + base = uint32(block.timestamp); } + reservation.expiresAt = base + reservation.termSeconds; - uint64 inputsTotalValue = processDissolutionInputs( - self, - reservation, - dissolutionTx.inputVector, - wallet.mainUtxoHash, - mainUtxo - ); - - bytes memory output = parseSingleOutput(dissolutionTx.outputVector); - uint64 outputValue = output.extractValue(); - require( - self.extractPubKeyHash(output) == reservation.walletPubKeyHash, - "Dissolution output must pay to the custodying wallet" - ); - require( - inputsTotalValue - outputValue <= self.reservationTxMaxFee, - "Transaction fee is too high" - ); - - // The dissolution output becomes the wallet's new main UTXO: the - // reserved backing rejoins the pooled supply. - wallet.mainUtxoHash = keccak256( - abi.encodePacked(dissolutionTxHash, uint32(0), outputValue) - ); - - closeReservation(self, reservation); - - emit ReservationDissolved( - reservationKey, - reservation.walletPubKeyHash, - dissolutionTxHash - ); + emit ReservationExtended(reservationKey, reservation.expiresAt); } /// @notice Updates the reservation parameters, including the @@ -979,8 +1035,13 @@ library Reservation { /// - Reservation minimum amount must be greater than the /// reservation transaction max fee, /// - Reservation term must be greater than zero, + /// - Reservation action timeout must be greater than zero, /// - The reservation vault can only be changed while there are no /// active reservations (total reserved amount is zero). + /// + /// Term, grace period and fee bounds are snapshotted into + /// positions and action records when they are created; updates + /// apply prospectively only. function updateReservationParameters( BridgeState.Storage storage self, address reservationVault, @@ -989,7 +1050,8 @@ library Reservation { uint32 reservationTermSeconds, uint32 reservationGracePeriod, uint64 reservationMaxTotalAmount, - uint32 maxReservationsPerWallet + uint32 maxReservationsPerWallet, + uint32 reservationActionTimeout ) external { require( reservationTxMaxFee > 0, @@ -1003,6 +1065,10 @@ library Reservation { reservationTermSeconds > 0, "Reservation term must be greater than zero" ); + require( + reservationActionTimeout > 0, + "Reservation action timeout must be greater than zero" + ); if (reservationVault != self.reservationVault) { require( @@ -1019,6 +1085,7 @@ library Reservation { self.reservationGracePeriod = reservationGracePeriod; self.reservationMaxTotalAmount = reservationMaxTotalAmount; self.maxReservationsPerWallet = maxReservationsPerWallet; + self.reservationActionTimeout = reservationActionTimeout; emit ReservationParametersUpdated( reservationMinAmount, @@ -1026,167 +1093,11 @@ library Reservation { reservationTermSeconds, reservationGracePeriod, reservationMaxTotalAmount, - maxReservationsPerWallet + maxReservationsPerWallet, + reservationActionTimeout ); } - /// @notice Parses the given output vector and returns its single output. - /// Reverts if the vector does not contain exactly one output. - function parseSingleOutput(bytes memory outputVector) - internal - pure - returns (bytes memory output) - { - (, uint256 outputsCount) = outputVector.parseVarInt(); - require( - outputsCount == 1, - "Reservation transaction must have a single output" - ); - - output = outputVector.extractOutputAtIndex(0); - } - - /// @notice Asserts the given input vector contains exactly one input - /// pointing to the reservation's current anchor outpoint, marks - /// that outpoint as correctly spent (making it recognized by the - /// fraud challenge defeat path) and clears its anchor index - /// entry. - function consumeAnchor( - BridgeState.Storage storage self, - ReservationRequest storage reservation, - bytes memory inputVector - ) internal { - (bytes32 outpointTxHash, uint32 outpointIndex) = OutboundTx - .parseWalletOutboundTxInput(inputVector); - - require( - reservation.anchorTxHash == outpointTxHash && - reservation.anchorTxOutputIndex == outpointIndex, - "Transaction input must point to the reservation anchor" - ); - - uint256 anchorUtxoKey = uint256( - keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) - ); - - // Anchor outpoints are wallet-controlled UTXOs. Marking a consumed - // anchor in `spentMainUTXOs` -- the existing registry of honestly - // spent wallet UTXOs -- makes the spend recognized by - // `Fraud.defeatFraudChallenge` without modifying the fraud library. - self.spentMainUTXOs[anchorUtxoKey] = true; - delete self.reservationsByAnchorUtxo[anchorUtxoKey]; - } - - /// @notice Processes the dissolution transaction inputs: the first - /// input must spend the reservation's anchor outpoint and, if - /// the wallet has a main UTXO, the second input must spend - /// exactly that main UTXO. Marks both consumed outpoints as - /// correctly spent. - /// @return inputsTotalValue Sum of all inputs values. - function processDissolutionInputs( - BridgeState.Storage storage self, - ReservationRequest storage reservation, - bytes memory inputVector, - bytes32 mainUtxoHash, - BitcoinTx.UTXO calldata mainUtxo - ) internal returns (uint64 inputsTotalValue) { - bool mainUtxoExpected = mainUtxoHash != bytes32(0); - - (uint256 varIntLength, uint256 inputsCount) = inputVector.parseVarInt(); - require( - inputsCount == (mainUtxoExpected ? 2 : 1), - "Wrong number of dissolution transaction inputs" - ); - - // The first input must spend the reservation's anchor outpoint. - uint256 nextInputIndex = consumeAnchorInputAt( - self, - reservation, - inputVector, - 1 + varIntLength - ); - inputsTotalValue = reservation.anchorAmount; - - if (mainUtxoExpected) { - require( - keccak256( - abi.encodePacked( - mainUtxo.txHash, - mainUtxo.txOutputIndex, - mainUtxo.txOutputValue - ) - ) == mainUtxoHash, - "Invalid main UTXO data" - ); - - consumeMainUtxoInputAt(self, inputVector, nextInputIndex, mainUtxo); - inputsTotalValue += mainUtxo.txOutputValue; - } - - return inputsTotalValue; - } - - /// @notice Asserts the input at the given starting index spends the - /// reservation's current anchor outpoint, marks that outpoint as - /// correctly spent and clears its anchor index entry. - /// @return nextInputIndex Starting index of the next input. - function consumeAnchorInputAt( - BridgeState.Storage storage self, - ReservationRequest storage reservation, - bytes memory inputVector, - uint256 inputStartingIndex - ) internal returns (uint256 nextInputIndex) { - bytes32 outpointTxHash = inputVector.extractInputTxIdLeAt( - inputStartingIndex - ); - uint32 outpointIndex = BTCUtils.reverseUint32( - uint32(inputVector.extractTxIndexLeAt(inputStartingIndex)) - ); - - require( - reservation.anchorTxHash == outpointTxHash && - reservation.anchorTxOutputIndex == outpointIndex, - "Transaction input must point to the reservation anchor" - ); - - uint256 anchorUtxoKey = uint256( - keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) - ); - // See `consumeAnchor` for the `spentMainUTXOs` rationale. - self.spentMainUTXOs[anchorUtxoKey] = true; - delete self.reservationsByAnchorUtxo[anchorUtxoKey]; - - nextInputIndex = - inputStartingIndex + - inputVector.determineInputLengthAt(inputStartingIndex); - } - - /// @notice Asserts the input at the given starting index spends the - /// wallet's main UTXO and marks it as correctly spent. - function consumeMainUtxoInputAt( - BridgeState.Storage storage self, - bytes memory inputVector, - uint256 inputStartingIndex, - BitcoinTx.UTXO calldata mainUtxo - ) internal { - bytes32 outpointTxHash = inputVector.extractInputTxIdLeAt( - inputStartingIndex - ); - uint32 outpointIndex = BTCUtils.reverseUint32( - uint32(inputVector.extractTxIndexLeAt(inputStartingIndex)) - ); - - require( - mainUtxo.txHash == outpointTxHash && - mainUtxo.txOutputIndex == outpointIndex, - "Transaction input must point to the wallet's main UTXO" - ); - - self.spentMainUTXOs[ - uint256(keccak256(abi.encodePacked(outpointTxHash, outpointIndex))) - ] = true; - } - /// @notice Closes a reservation: adjusts the wallet reservation count /// and the total reserved amount, and marks the reservation as /// Closed. diff --git a/solidity/contracts/bridge/ReservationProofs.sol b/solidity/contracts/bridge/ReservationProofs.sol new file mode 100644 index 000000000..98dc4a263 --- /dev/null +++ b/solidity/contracts/bridge/ReservationProofs.sol @@ -0,0 +1,1066 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ + +pragma solidity 0.8.17; + +import {BTCUtils} from "@keep-network/bitcoin-spv-sol/contracts/BTCUtils.sol"; +import {BytesLib} from "@keep-network/bitcoin-spv-sol/contracts/BytesLib.sol"; + +import "./BitcoinTx.sol"; +import "./BridgeState.sol"; +import "./Deposit.sol"; +import "./MovingFunds.sol"; +import "./Redemption.sol"; +import "./Reservation.sol"; +import "./Wallets.sol"; + +import "../bank/Bank.sol"; + +/// @title Bridge UTXO reservations — settlement +/// @notice SPV settlement side of the two-phase reservation model (see +/// `Reservation` for the request/authorization side and RFC 13 for +/// the architecture). Every proof settles one requested +/// *generation*, named explicitly by `(reservationKey, +/// requestNonce)`, and is validated exclusively against that +/// generation's snapshotted action record — never against live +/// parameters. +/// @dev Settlement rules shared by all action types: +/// +/// - A `Pending` generation settles normally. For redemptions the +/// proof additionally requires the watchtower delay of the +/// generation to have elapsed — a Byzantine wallet broadcasting +/// before authorization cannot finalize early, and if the +/// generation is vetoed its transaction is unprovable forever +/// (the fraud machinery handles the unauthorized signature). +/// +/// - A `TimedOut` generation still settles ("late settlement"): the +/// Bitcoin transaction confirmed and the anchor is irrevocably +/// spent, so the registry records reality — consumed outpoints are +/// marked honestly spent (defeating fraud challenges against the +/// honest-but-late signature), the anchor lineage closes, and any +/// newer pending generation whose anchor no longer exists is +/// unwound (its escrow refunded). What late settlement never does +/// is repeat a Bank movement: the timeout already refunded the +/// escrow, so nothing is burned or paid again. If the position's +/// *current pending* redemption generation matches the transaction +/// as well, the proof must settle against it instead — the +/// deterministic, economically correct target. +/// +/// - A `Vetoed` generation never settles. +library ReservationProofs { + using BridgeState for BridgeState.Storage; + using BitcoinTx for BridgeState.Storage; + using Reservation for BridgeState.Storage; + + using BTCUtils for bytes; + using BytesLib for bytes; + + /// @notice Represents the type of a reservation lifecycle SPV proof. + /// Numbering matches `Reservation.ActionType` minus `None`. + enum ProofType { + Acceptance, + Redemption, + Reanchor, + Dissolution + } + + /// @notice Single entry point for all reservation lifecycle SPV proofs. + /// Dispatches to the appropriate handler based on `proofType`. + /// @param proofType The type of the submitted proof, see `ProofType`. + /// @param txInfo Bitcoin transaction data. + /// @param proof Bitcoin proof data. + /// @param mainUtxo Data of the main UTXO expected by a `Dissolution` + /// generation; ignored otherwise. + /// @param reservationKey The key of the target reservation. + /// @param requestNonce The generation being settled. Late settlements + /// name an older, timed-out generation. + function submitReservationProof( + BridgeState.Storage storage self, + uint8 proofType, + BitcoinTx.Info calldata txInfo, + BitcoinTx.Proof calldata proof, + BitcoinTx.UTXO calldata mainUtxo, + uint256 reservationKey, + uint64 requestNonce + ) external { + ProofType parsedProofType = ProofType(proofType); + + if (parsedProofType == ProofType.Acceptance) { + submitReservationAcceptanceProof( + self, + txInfo, + proof, + reservationKey, + requestNonce + ); + } else if (parsedProofType == ProofType.Redemption) { + submitReservedRedemptionProof( + self, + txInfo, + proof, + reservationKey, + requestNonce + ); + } else if (parsedProofType == ProofType.Reanchor) { + submitReservationReanchorProof( + self, + txInfo, + proof, + reservationKey, + requestNonce + ); + } else { + submitReservationDissolutionProof( + self, + txInfo, + proof, + mainUtxo, + reservationKey, + requestNonce + ); + } + } + + /// @notice Loads the action record of the generation being settled and + /// validates it is settleable (`Pending` or `TimedOut`) and of + /// the expected type. + /// @return action The action record. + /// @return late True when settling a timed-out generation. + function loadSettleableAction( + BridgeState.Storage storage self, + uint256 reservationKey, + uint64 requestNonce, + Reservation.ActionType expectedType + ) + internal + view + returns (Reservation.ReservationAction storage action, bool late) + { + action = self.reservationActions[ + Reservation.actionKey(reservationKey, requestNonce) + ]; + require(action.actionType == expectedType, "Action type mismatch"); + require( + action.state == Reservation.ActionState.Pending || + action.state == Reservation.ActionState.TimedOut, + "Action is not settleable" + ); + late = action.state == Reservation.ActionState.TimedOut; + } + + /// @notice Used by the wallet to prove the BTC anchor transaction of an + /// authorized reserved deposit acceptance and to credit the + /// owner's balance accordingly. + /// + /// The anchor transaction must spend exactly the revealed + /// reserved deposit as its sole input and create exactly one + /// P2(W)PKH output controlled by the wallet the acceptance + /// request authorized. Proving it marks the deposit as swept + /// (blocking any regular sweep and enabling fraud challenge + /// defeats for the deposit outpoint), registers the reservation + /// and credits the gross anchor value to the depositor through + /// the reservation vault. + /// @dev Requirements: + /// - The named generation must be a settleable acceptance + /// authorization for the deposit, + /// - `anchorTx` must spend the revealed reserved deposit as its + /// sole input, + /// - `anchorTx` must have exactly one P2(W)PKH output locking + /// funds on the authorized wallet's public key hash, + /// - The Bitcoin miner fee must not exceed the authorization's + /// snapshotted fee bound. Together with the request-time check + /// `depositAmount >= minAmount + txMaxFee` this guarantees the + /// anchor value satisfies the reservation minimum without any + /// proof-time dependency on live parameters. + function submitReservationAcceptanceProof( + BridgeState.Storage storage self, + BitcoinTx.Info calldata anchorTx, + BitcoinTx.Proof calldata anchorProof, + uint256 reservationKey, + uint64 requestNonce + ) internal { + ( + Reservation.ReservationAction storage action, + bool late + ) = loadSettleableAction( + self, + reservationKey, + requestNonce, + Reservation.ActionType.Acceptance + ); + + require( + self.reservations[reservationKey].state == + Reservation.ReservationState.Unknown, + "Reservation already exists" + ); + + bytes32 anchorTxHash = self.validateProof(anchorTx, anchorProof); + + consumeAcceptedDeposit(self, anchorTx.inputVector, reservationKey); + + uint64 anchorAmount = validateAnchorOutput( + self, + anchorTx.outputVector, + action + ); + + settleAcceptance( + self, + reservationKey, + requestNonce, + action, + late, + anchorTxHash, + anchorAmount + ); + } + + /// @notice Asserts the anchor transaction's sole input spends the + /// reserved deposit the generation was authorized for and marks + /// the deposit as swept: any regular sweep is blocked and the + /// deposit outpoint becomes recognized by the fraud challenge + /// defeat path. + /// @dev The deposit was validated against the reservation vault at + /// request time; the routing cannot change while the total + /// reserved amount (which includes this authorization's reserved + /// capacity) is non-zero. + function consumeAcceptedDeposit( + BridgeState.Storage storage self, + bytes memory inputVector, + uint256 reservationKey + ) internal { + (bytes32 outpointTxHash, uint32 outpointIndex) = OutboundTx + .parseWalletOutboundTxInput(inputVector); + require( + uint256( + keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) + ) == reservationKey, + "Transaction input must spend the reserved deposit" + ); + + Deposit.DepositRequest storage deposit = self.deposits[reservationKey]; + require(deposit.sweptAt == 0, "Deposit already swept"); + + /* solhint-disable-next-line not-rely-on-time */ + deposit.sweptAt = uint32(block.timestamp); + } + + /// @notice Parses the anchor transaction's single output, validates it + /// pays the authorized wallet within the snapshotted fee bound + /// and returns its value. + function validateAnchorOutput( + BridgeState.Storage storage self, + bytes memory outputVector, + Reservation.ReservationAction storage action + ) internal view returns (uint64 anchorAmount) { + bytes memory output = parseSingleOutput(outputVector); + anchorAmount = output.extractValue(); + require( + self.extractPubKeyHash(output) == action.targetWalletPubKeyHash, + "Anchor output must pay the authorized wallet" + ); + require( + action.amount - anchorAmount <= action.txMaxFee, + "Transaction fee is too high" + ); + } + + /// @notice Finalizes an acceptance settlement: adjusts the reserved + /// capacity, creates the reservation position, indexes the + /// anchor outpoint and credits the gross anchor value through + /// the reservation vault. + function settleAcceptance( + BridgeState.Storage storage self, + uint256 reservationKey, + uint64 requestNonce, + Reservation.ReservationAction storage action, + bool late, + bytes32 anchorTxHash, + uint64 anchorAmount + ) internal { + action.state = Reservation.ActionState.Settled; + + if (late) { + // The timeout released the capacity reserved at request time; + // re-take it for the actual anchor value. Deliberately no cap + // check: caps are request-time throttles and the anchor is + // already confirmed on Bitcoin. + self.reservationTotalAmount += anchorAmount; + self.walletReservationsCount[action.targetWalletPubKeyHash] += 1; + emit Reservation.ReservationLateSettled( + reservationKey, + requestNonce, + Reservation.ActionType.Acceptance + ); + } else { + // Release the difference between the reserved upper bound + // (deposit value) and the actual anchor value (miner fee). + self.reservationTotalAmount -= (action.amount - anchorAmount); + } + + address depositor = self.deposits[reservationKey].depositor; + + Reservation.ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + + /* solhint-disable-next-line not-rely-on-time */ + uint32 expiresAt = uint32(block.timestamp) + + self.reservationTermSeconds; + + reservation.owner = depositor; + reservation.mintedAmount = anchorAmount; + /* solhint-disable-next-line not-rely-on-time */ + reservation.acceptedAt = uint32(block.timestamp); + reservation.walletPubKeyHash = action.targetWalletPubKeyHash; + reservation.anchorAmount = anchorAmount; + reservation.expiresAt = expiresAt; + reservation.anchorTxHash = anchorTxHash; + reservation.anchorTxOutputIndex = 0; + reservation.state = Reservation.ReservationState.Active; + reservation.termSeconds = self.reservationTermSeconds; + reservation.gracePeriod = self.reservationGracePeriod; + + self.reservationsByAnchorUtxo[ + uint256(keccak256(abi.encodePacked(anchorTxHash, uint32(0)))) + ] = reservationKey; + + // slither-disable-next-line reentrancy-events + emit Reservation.ReservationAccepted( + reservationKey, + requestNonce, + action.targetWalletPubKeyHash, + depositor, + anchorTxHash, + anchorAmount, + expiresAt + ); + + // Credit the gross anchored amount through the reservation vault. + // The per-deposit treasury fee computed at reveal time is + // deliberately ignored: reservation claims are minted gross and all + // protocol fees are charged as explicit transfers by the vault. + address[] memory depositors = new address[](1); + depositors[0] = depositor; + uint256[] memory amounts = new uint256[](1); + amounts[0] = anchorAmount; + self.bank.increaseBalanceAndCall( + self.reservationVault, + depositors, + amounts + ); + } + + /// @notice Used by the wallet to prove the BTC reserved redemption + /// transaction of a generation and settle it. The transaction + /// must spend exactly the reservation's anchor outpoint as its + /// sole input and pay the generation's redeemer output script + /// as its sole output. + /// @dev Requirements: + /// - The named generation must be a settleable redemption, + /// - For a pending generation, the watchtower delay applicable to + /// the generation must have elapsed (the on-chain enforcement + /// of the authorize-before-signing rule), + /// - `redemptionTx` must spend the reservation's current anchor + /// outpoint as its sole input, + /// - `redemptionTx` must have a single output paying the + /// generation's redeemer script with a value within the + /// snapshotted range, + /// - A late settlement is rejected when the position's current + /// pending redemption generation matches the transaction as + /// well; the proof must then settle the pending generation. + function submitReservedRedemptionProof( + BridgeState.Storage storage self, + BitcoinTx.Info calldata redemptionTx, + BitcoinTx.Proof calldata redemptionProof, + uint256 reservationKey, + uint64 requestNonce + ) internal { + ( + Reservation.ReservationAction storage action, + bool late + ) = loadSettleableAction( + self, + reservationKey, + requestNonce, + Reservation.ActionType.Redemption + ); + + Reservation.ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == Reservation.ReservationState.Active || + reservation.state == Reservation.ReservationState.ActionPending, + "Reservation is not settleable" + ); + + if (!late) { + require( + reservation.requestNonce == requestNonce, + "Not the current generation" + ); + + // On-chain authorization enforcement: the wallet may only sign + // once the watchtower delay of this generation has elapsed + // without a veto, and the proof path verifies it — an early + // broadcast cannot finalize before the guardians' window + // closes. + if (self.redemptionWatchtower != address(0)) { + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp >= + uint256(action.requestedAt) + + IRedemptionWatchtower(self.redemptionWatchtower) + .getReservedRedemptionDelay( + reservationKey, + requestNonce + ), + "Watchtower delay has not elapsed" + ); + } + } + + bytes32 redemptionTxHash = self.validateProof( + redemptionTx, + redemptionProof + ); + + consumeAnchor(self, reservation, redemptionTx.inputVector); + + bytes memory output = parseSingleOutput(redemptionTx.outputVector); + uint64 outputValue = output.extractValue(); + + { + bytes memory outputScript = output.slice(8, output.length - 8); + require( + keccak256(outputScript) == action.redeemerOutputScriptHash, + "Output does not pay the requested redeemer script" + ); + } + + // Underflow-safe range check against the position's anchor value + // (unchanged since the generation was requested: the anchor can + // only be consumed by proving a transaction that spends it) and + // the generation's snapshotted fee bound. + require( + outputValue <= reservation.anchorAmount && + reservation.anchorAmount - outputValue <= action.txMaxFee, + "Output value is not within the acceptable range" + ); + + if (late) { + resolveLateRedemptionAgainstPending( + self, + reservation, + reservationKey, + action, + outputValue + ); + + emit Reservation.ReservationLateSettled( + reservationKey, + requestNonce, + Reservation.ActionType.Redemption + ); + // No Bank movement: the timeout already refunded the escrowed + // claim and slashed the wallet. The registry records the + // confirmed spend and closes the lineage. + } + + action.state = Reservation.ActionState.Settled; + self.closeReservation(reservation); + + // slither-disable-next-line reentrancy-events + emit Reservation.ReservedRedemptionCompleted( + reservationKey, + requestNonce, + redemptionTxHash + ); + + if (!late) { + // Burn the gross minted amount held by the Bridge since the + // redemption request. + self.bank.decreaseBalance(action.amount); + } + } + + /// @notice Used by the wallet to prove a re-anchor transaction moving a + /// reservation's anchor outpoint to the authorized target + /// wallet in a 1-input-1-output spend. + /// @dev Requirements: + /// - The named generation must be a settleable re-anchor, + /// - `reanchorTx` must spend the current anchor outpoint as its + /// sole input and create a single P2(W)PKH output paying the + /// generation's authorized target wallet, + /// - The miner fee must respect the snapshotted bound and the + /// re-anchored value must stay above the dust floor (the + /// snapshotted fee bound), preserving positive redemption + /// value. + function submitReservationReanchorProof( + BridgeState.Storage storage self, + BitcoinTx.Info calldata reanchorTx, + BitcoinTx.Proof calldata reanchorProof, + uint256 reservationKey, + uint64 requestNonce + ) internal { + ( + Reservation.ReservationAction storage action, + bool late + ) = loadSettleableAction( + self, + reservationKey, + requestNonce, + Reservation.ActionType.Reanchor + ); + + Reservation.ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == Reservation.ReservationState.Active || + reservation.state == Reservation.ReservationState.ActionPending, + "Reservation is not settleable" + ); + if (!late) { + require( + reservation.requestNonce == requestNonce, + "Not the current generation" + ); + } + + bytes32 reanchorTxHash = self.validateProof(reanchorTx, reanchorProof); + + consumeAnchor(self, reservation, reanchorTx.inputVector); + + bytes memory output = parseSingleOutput(reanchorTx.outputVector); + bytes20 newWalletPubKeyHash = self.extractPubKeyHash(output); + uint64 newAnchorAmount = output.extractValue(); + + require( + newWalletPubKeyHash == action.targetWalletPubKeyHash, + "Output must pay the authorized target wallet" + ); + + // `newAnchorAmount <= anchorAmount` is guaranteed by Bitcoin + // consensus (the re-anchor output cannot exceed its input). + require( + reservation.anchorAmount - newAnchorAmount <= action.txMaxFee, + "Transaction fee is too high" + ); + + // Dust floor: the re-anchored amount must stay strictly above the + // snapshotted per-transaction fee bound, keeping the anchor clear + // of dust and preserving positive redemption value. + require( + newAnchorAmount > action.txMaxFee, + "Re-anchor amount below the dust floor" + ); + + if (late) { + // The timeout released the target wallet's reserved count; + // re-take it. Deliberately no cap check (see acceptance). + self.walletReservationsCount[newWalletPubKeyHash] += 1; + + // A newer pending generation references an anchor this + // transaction just consumed; unwind it. + if ( + reservation.state == Reservation.ReservationState.ActionPending + ) { + unwindPendingAction(self, reservation, reservationKey); + } + + emit Reservation.ReservationLateSettled( + reservationKey, + requestNonce, + Reservation.ActionType.Reanchor + ); + } + + // The source wallet's count is released now; the target wallet's + // count was reserved at request time (or re-taken above). + self.walletReservationsCount[reservation.walletPubKeyHash] -= 1; + + // The miner fee reduces the on-chain earmarked amount. + self.reservationTotalAmount -= (reservation.anchorAmount - + newAnchorAmount); + + reservation.walletPubKeyHash = newWalletPubKeyHash; + reservation.anchorAmount = newAnchorAmount; + reservation.anchorTxHash = reanchorTxHash; + reservation.anchorTxOutputIndex = 0; + reservation.state = Reservation.ReservationState.Active; + + action.state = Reservation.ActionState.Settled; + + self.reservationsByAnchorUtxo[ + uint256(keccak256(abi.encodePacked(reanchorTxHash, uint32(0)))) + ] = reservationKey; + + emit Reservation.ReservationReanchored( + reservationKey, + requestNonce, + newWalletPubKeyHash, + reanchorTxHash, + newAnchorAmount + ); + } + + /// @notice Used by the wallet to prove a dissolution transaction + /// merging an expired reservation's anchor outpoint into the + /// wallet's main UTXO, per the generation's authorization. + /// @dev Requirements: + /// - The named generation must be a settleable dissolution, + /// - If the generation's snapshot recorded a main UTXO, + /// `dissolutionTx` must spend exactly the anchor outpoint + /// (first input) and that main UTXO (second input, matching the + /// `mainUtxo` parameter); otherwise it must spend exactly the + /// anchor outpoint, + /// - `dissolutionTx` must have a single P2(W)PKH output locking + /// funds back on the custodying wallet's public key hash. + /// + /// If the wallet's registry main UTXO still equals the snapshot, + /// the output becomes the wallet's new main UTXO. If it drifted — + /// possible only for no-main-UTXO snapshots, when e.g. a deposit + /// sweep landed first — the output is registered as a moved-funds + /// sweep request so the wallet later consolidates it, keeping the + /// backing tracked either way. + function submitReservationDissolutionProof( + BridgeState.Storage storage self, + BitcoinTx.Info calldata dissolutionTx, + BitcoinTx.Proof calldata dissolutionProof, + BitcoinTx.UTXO calldata mainUtxo, + uint256 reservationKey, + uint64 requestNonce + ) internal { + ( + Reservation.ReservationAction storage action, + bool late + ) = loadSettleableAction( + self, + reservationKey, + requestNonce, + Reservation.ActionType.Dissolution + ); + + Reservation.ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == Reservation.ReservationState.Active || + reservation.state == Reservation.ReservationState.ActionPending, + "Reservation is not settleable" + ); + if (!late) { + require( + reservation.requestNonce == requestNonce, + "Not the current generation" + ); + } + + bytes32 dissolutionTxHash = self.validateProof( + dissolutionTx, + dissolutionProof + ); + + uint64 inputsTotalValue = processDissolutionInputs( + self, + reservation, + dissolutionTx.inputVector, + action.expectedMainUtxoHash, + mainUtxo + ); + + uint64 outputValue = validateDissolutionOutput( + self, + dissolutionTx.outputVector, + reservation.walletPubKeyHash, + inputsTotalValue, + action.txMaxFee + ); + + settleDissolution( + self, + reservationKey, + requestNonce, + action, + late, + dissolutionTxHash, + outputValue + ); + } + + /// @notice Parses the dissolution transaction's single output, validates + /// it pays the custodying wallet within the snapshotted fee + /// bound and returns its value. + function validateDissolutionOutput( + BridgeState.Storage storage self, + bytes memory outputVector, + bytes20 walletPubKeyHash, + uint64 inputsTotalValue, + uint64 txMaxFee + ) internal view returns (uint64 outputValue) { + bytes memory output = parseSingleOutput(outputVector); + outputValue = output.extractValue(); + require( + self.extractPubKeyHash(output) == walletPubKeyHash, + "Dissolution output must pay to the custodying wallet" + ); + require( + inputsTotalValue - outputValue <= txMaxFee, + "Transaction fee is too high" + ); + } + + /// @notice Finalizes a dissolution settlement: registers the output as + /// the wallet's new main UTXO (or as a moved-funds sweep + /// request when the registry drifted), releases the per-wallet + /// main-UTXO action lock, unwinds a superseded pending + /// generation on late settlements and closes the position. + function settleDissolution( + BridgeState.Storage storage self, + uint256 reservationKey, + uint64 requestNonce, + Reservation.ReservationAction storage action, + bool late, + bytes32 dissolutionTxHash, + uint64 outputValue + ) internal { + Reservation.ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + bytes20 walletPubKeyHash = reservation.walletPubKeyHash; + + Wallets.Wallet storage wallet = self.registeredWallets[ + walletPubKeyHash + ]; + + if (wallet.mainUtxoHash == action.expectedMainUtxoHash) { + // The dissolution output becomes the wallet's new main UTXO: + // the reserved backing rejoins the pooled supply. + wallet.mainUtxoHash = keccak256( + abi.encodePacked(dissolutionTxHash, uint32(0), outputValue) + ); + } else { + // Registry drift: another transaction (e.g. a deposit sweep) + // registered a main UTXO after this no-main-UTXO dissolution + // was authorized. The confirmed dissolution output is a + // wallet-held UTXO the registry must keep tracking; register + // it for the moved-funds sweep machinery to consolidate. + require( + action.expectedMainUtxoHash == bytes32(0), + "Recorded main UTXO can no longer be spent" + ); + MovingFunds.MovedFundsSweepRequest storage sweepRequest = self + .movedFundsSweepRequests[ + uint256( + keccak256( + abi.encodePacked(dissolutionTxHash, uint32(0)) + ) + ) + ]; + sweepRequest.walletPubKeyHash = walletPubKeyHash; + sweepRequest.value = outputValue; + /* solhint-disable-next-line not-rely-on-time */ + sweepRequest.createdAt = uint32(block.timestamp); + sweepRequest.state = MovingFunds + .MovedFundsSweepRequestState + .Pending; + } + + if (late) { + if ( + reservation.state == Reservation.ReservationState.ActionPending + ) { + unwindPendingAction(self, reservation, reservationKey); + } + emit Reservation.ReservationLateSettled( + reservationKey, + requestNonce, + Reservation.ActionType.Dissolution + ); + } else { + // Release the per-wallet main-UTXO action lock taken at + // request time (a timed-out generation released it already, + // and it may have been re-taken by another reservation since). + if ( + self.walletPendingDissolution[walletPubKeyHash] == + reservationKey + ) { + delete self.walletPendingDissolution[walletPubKeyHash]; + } + } + + action.state = Reservation.ActionState.Settled; + self.closeReservation(reservation); + + emit Reservation.ReservationDissolved( + reservationKey, + requestNonce, + walletPubKeyHash, + dissolutionTxHash + ); + } + + /// @notice Resolves a late redemption settlement against the position's + /// current pending generation. If the pending generation is a + /// redemption that matches the proven transaction as well, the + /// proof must settle against it instead (the pending settlement + /// burns the escrow — the correct accounting whenever both + /// generations can claim the transaction); otherwise the + /// pending generation's anchor is provably gone and it is + /// unwound. + function resolveLateRedemptionAgainstPending( + BridgeState.Storage storage self, + Reservation.ReservationRequest storage reservation, + uint256 reservationKey, + Reservation.ReservationAction storage action, + uint64 outputValue + ) internal { + if (reservation.state != Reservation.ReservationState.ActionPending) { + return; + } + + Reservation.ReservationAction storage pendingAction = self + .reservationActions[ + Reservation.actionKey(reservationKey, reservation.requestNonce) + ]; + if ( + pendingAction.actionType == Reservation.ActionType.Redemption && + pendingAction.redeemerOutputScriptHash == + action.redeemerOutputScriptHash && + reservation.anchorAmount - outputValue <= pendingAction.txMaxFee + ) { + revert("Must settle the pending generation"); + } + + // The pending generation cannot claim the transaction; its anchor + // is gone, so unwind it. + unwindPendingAction(self, reservation, reservationKey); + } + + /// @notice Unwinds the position's current pending generation during a + /// late settlement of an older generation: the anchor the + /// pending generation was authorized to spend has provably been + /// consumed, so the generation can never settle. Its escrow is + /// refunded (redemptions), its reserved capacity and locks are + /// released, and it is terminally marked `Superseded`. + function unwindPendingAction( + BridgeState.Storage storage self, + Reservation.ReservationRequest storage reservation, + uint256 reservationKey + ) internal { + uint64 pendingNonce = reservation.requestNonce; + Reservation.ReservationAction storage pendingAction = self + .reservationActions[ + Reservation.actionKey(reservationKey, pendingNonce) + ]; + require( + pendingAction.state == Reservation.ActionState.Pending, + "No pending action to unwind" + ); + + pendingAction.state = Reservation.ActionState.Superseded; + + if (pendingAction.actionType == Reservation.ActionType.Redemption) { + // Return the escrowed balance: the redeemer surrendered it for + // an anchor that no longer exists. + self.bank.transferBalance( + pendingAction.redeemer, + pendingAction.amount + ); + } else if ( + pendingAction.actionType == Reservation.ActionType.Reanchor + ) { + self.walletReservationsCount[ + pendingAction.targetWalletPubKeyHash + ] -= 1; + } else if ( + pendingAction.actionType == Reservation.ActionType.Dissolution + ) { + if ( + self.walletPendingDissolution[reservation.walletPubKeyHash] == + reservationKey + ) { + delete self.walletPendingDissolution[ + reservation.walletPubKeyHash + ]; + } + } + + emit Reservation.ReservationActionSuperseded( + reservationKey, + pendingNonce + ); + } + + /// @notice Parses the given output vector and returns its single output. + /// Reverts if the vector does not contain exactly one output. + function parseSingleOutput(bytes memory outputVector) + internal + pure + returns (bytes memory output) + { + (, uint256 outputsCount) = outputVector.parseVarInt(); + require( + outputsCount == 1, + "Reservation transaction must have a single output" + ); + + output = outputVector.extractOutputAtIndex(0); + } + + /// @notice Asserts the given input vector contains exactly one input + /// pointing to the reservation's current anchor outpoint, marks + /// that outpoint as correctly spent (making it recognized by the + /// fraud challenge defeat path) and clears its anchor index + /// entry. + function consumeAnchor( + BridgeState.Storage storage self, + Reservation.ReservationRequest storage reservation, + bytes memory inputVector + ) internal { + (bytes32 outpointTxHash, uint32 outpointIndex) = OutboundTx + .parseWalletOutboundTxInput(inputVector); + + require( + reservation.anchorTxHash == outpointTxHash && + reservation.anchorTxOutputIndex == outpointIndex, + "Transaction input must point to the reservation anchor" + ); + + uint256 anchorUtxoKey = uint256( + keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) + ); + + // Anchor outpoints are wallet-controlled UTXOs. Marking a consumed + // anchor in `spentMainUTXOs` -- the existing registry of honestly + // spent wallet UTXOs -- makes the spend recognized by + // `Fraud.defeatFraudChallenge` without modifying the fraud library. + self.spentMainUTXOs[anchorUtxoKey] = true; + delete self.reservationsByAnchorUtxo[anchorUtxoKey]; + } + + /// @notice Processes the dissolution transaction inputs: the first + /// input must spend the reservation's anchor outpoint and, if + /// the generation's snapshot recorded a main UTXO, the second + /// input must spend exactly that main UTXO. Marks both consumed + /// outpoints as correctly spent. + /// @return inputsTotalValue Sum of all inputs values. + function processDissolutionInputs( + BridgeState.Storage storage self, + Reservation.ReservationRequest storage reservation, + bytes memory inputVector, + bytes32 expectedMainUtxoHash, + BitcoinTx.UTXO calldata mainUtxo + ) internal returns (uint64 inputsTotalValue) { + bool mainUtxoExpected = expectedMainUtxoHash != bytes32(0); + + (uint256 varIntLength, uint256 inputsCount) = inputVector.parseVarInt(); + require( + inputsCount == (mainUtxoExpected ? 2 : 1), + "Wrong number of dissolution transaction inputs" + ); + + // The first input must spend the reservation's anchor outpoint. + uint256 nextInputIndex = consumeAnchorInputAt( + self, + reservation, + inputVector, + 1 + varIntLength + ); + inputsTotalValue = reservation.anchorAmount; + + if (mainUtxoExpected) { + require( + keccak256( + abi.encodePacked( + mainUtxo.txHash, + mainUtxo.txOutputIndex, + mainUtxo.txOutputValue + ) + ) == expectedMainUtxoHash, + "Invalid main UTXO data" + ); + + consumeMainUtxoInputAt(self, inputVector, nextInputIndex, mainUtxo); + inputsTotalValue += mainUtxo.txOutputValue; + } + + return inputsTotalValue; + } + + /// @notice Asserts the input at the given starting index spends the + /// reservation's current anchor outpoint, marks that outpoint as + /// correctly spent and clears its anchor index entry. + /// @return nextInputIndex Starting index of the next input. + function consumeAnchorInputAt( + BridgeState.Storage storage self, + Reservation.ReservationRequest storage reservation, + bytes memory inputVector, + uint256 inputStartingIndex + ) internal returns (uint256 nextInputIndex) { + bytes32 outpointTxHash = inputVector.extractInputTxIdLeAt( + inputStartingIndex + ); + uint32 outpointIndex = BTCUtils.reverseUint32( + uint32(inputVector.extractTxIndexLeAt(inputStartingIndex)) + ); + + require( + reservation.anchorTxHash == outpointTxHash && + reservation.anchorTxOutputIndex == outpointIndex, + "Transaction input must point to the reservation anchor" + ); + + uint256 anchorUtxoKey = uint256( + keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) + ); + // See `consumeAnchor` for the `spentMainUTXOs` rationale. + self.spentMainUTXOs[anchorUtxoKey] = true; + delete self.reservationsByAnchorUtxo[anchorUtxoKey]; + + nextInputIndex = + inputStartingIndex + + inputVector.determineInputLengthAt(inputStartingIndex); + } + + /// @notice Asserts the input at the given starting index spends the + /// given main UTXO and marks it as correctly spent. + function consumeMainUtxoInputAt( + BridgeState.Storage storage self, + bytes memory inputVector, + uint256 inputStartingIndex, + BitcoinTx.UTXO calldata mainUtxo + ) internal { + bytes32 outpointTxHash = inputVector.extractInputTxIdLeAt( + inputStartingIndex + ); + uint32 outpointIndex = BTCUtils.reverseUint32( + uint32(inputVector.extractTxIndexLeAt(inputStartingIndex)) + ); + + require( + mainUtxo.txHash == outpointTxHash && + mainUtxo.txOutputIndex == outpointIndex, + "Transaction input must point to the wallet's main UTXO" + ); + + self.spentMainUTXOs[ + uint256(keccak256(abi.encodePacked(outpointTxHash, outpointIndex))) + ] = true; + } +} diff --git a/solidity/contracts/bridge/ReservationRouter.sol b/solidity/contracts/bridge/ReservationRouter.sol index 82a001c61..a26235a1e 100644 --- a/solidity/contracts/bridge/ReservationRouter.sol +++ b/solidity/contracts/bridge/ReservationRouter.sol @@ -33,12 +33,12 @@ import "./Reservation.sol"; /// part of the Bridge ABI surface observable at the Bridge address. /// /// The router exists to preserve the Bridge's EIP-170 deployment -/// size margin: the reservation feature (and its planned two-phase -/// settlement state machine) does not fit in the ~400 bytes the -/// monolithic Bridge implementation has left. Moving the surface to -/// a delegatecall extension gives reservations their own 24 kB -/// budget while changing nothing about the storage/address/authority -/// model. +/// size margin: the reservation feature and its two-phase +/// settlement state machine do not fit in the bytes the monolithic +/// Bridge implementation has left. Moving the surface to a +/// delegatecall extension gives reservations their own 24 kB +/// budget while changing nothing about the storage/address/ +/// authority model. /// /// Architecture notes (why delegatecall, not an external router): /// the P2TR activation track routes *fraud signature checks* through @@ -91,8 +91,18 @@ contract ReservationRouter is Governable, Initializable { // `self` starts at the same slot as in the Bridge. See invariant 1. BridgeState.Storage internal self; + event ReservationAcceptanceRequested( + uint256 indexed reservationKey, + uint64 requestNonce, + bytes20 indexed walletPubKeyHash, + uint64 depositAmount, + uint64 txMaxFee, + uint32 timeoutAt + ); + event ReservationAccepted( uint256 indexed reservationKey, + uint64 requestNonce, bytes20 indexed walletPubKeyHash, address indexed owner, bytes32 anchorTxHash, @@ -107,44 +117,83 @@ contract ReservationRouter is Governable, Initializable { event ReservedRedemptionRequested( uint256 indexed reservationKey, + uint64 requestNonce, address indexed redeemer, bytes redeemerOutputScript, uint64 mintedAmount, - uint64 txMaxFee + uint64 txMaxFee, + bool feePaid ); event ReservedRedemptionCompleted( uint256 indexed reservationKey, + uint64 requestNonce, bytes32 redemptionTxHash ); - event ReservedRedemptionTimedOut( + event ReservationReanchorRequested( uint256 indexed reservationKey, - bytes20 indexed walletPubKeyHash + uint64 requestNonce, + bytes20 indexed sourceWalletPubKeyHash, + bytes20 indexed targetWalletPubKeyHash, + uint64 txMaxFee ); - event ReservedRedemptionVetoed(uint256 indexed reservationKey); - event ReservationReanchored( uint256 indexed reservationKey, + uint64 requestNonce, bytes20 indexed newWalletPubKeyHash, bytes32 newAnchorTxHash, uint64 newAnchorAmount ); + event ReservationDissolutionRequested( + uint256 indexed reservationKey, + uint64 requestNonce, + bytes20 indexed walletPubKeyHash, + uint64 txMaxFee, + bytes32 expectedMainUtxoHash + ); + event ReservationDissolved( uint256 indexed reservationKey, + uint64 requestNonce, bytes20 indexed walletPubKeyHash, bytes32 dissolutionTxHash ); + event ReservationActionTimedOut( + uint256 indexed reservationKey, + uint64 requestNonce, + Reservation.ActionType actionType + ); + + event ReservedRedemptionVetoed( + uint256 indexed reservationKey, + uint64 requestNonce + ); + + event ReservationActionSuperseded( + uint256 indexed reservationKey, + uint64 requestNonce + ); + + event ReservationLateSettled( + uint256 indexed reservationKey, + uint64 requestNonce, + Reservation.ActionType actionType + ); + + event ReservationRetryCreditMinted(uint256 indexed reservationKey); + event ReservationParametersUpdated( uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationGracePeriod, uint64 reservationMaxTotalAmount, - uint32 maxReservationsPerWallet + uint32 maxReservationsPerWallet, + uint32 reservationActionTimeout ); event ReservationVaultUpdated(address reservationVault); @@ -164,95 +213,155 @@ contract ReservationRouter is Governable, Initializable { _; } + /// @notice Requests the acceptance of a revealed reserved deposit: the + /// authorization for the designated wallet to perform the + /// anchor spend. Checks and reserves reservation capacity so + /// the anchor, once signed, can always be proven. See + /// `Reservation.requestReservationAcceptance`. + /// @param reservationKey The deposit key of the revealed reserved + /// deposit (doubles as the reservation key). + /// @param walletPubKeyHash 20-byte public key hash of the wallet that + /// will anchor the deposit. + function requestReservationAcceptance( + uint256 reservationKey, + bytes20 walletPubKeyHash + ) external { + self.requestReservationAcceptance(reservationKey, walletPubKeyHash); + } + + /// @notice Requests an in-kind redemption of a reservation. Can only be + /// called by the reservation vault, which must have approved + /// the Bridge in the Bank for the gross minted amount. See + /// `Reservation.requestReservedRedemption`. + /// @param reservationKey The key of the reservation to redeem. + /// @param redeemer The address able to claim the escrowed balance back + /// if the redemption times out. + /// @param redeemerOutputScript The redeemer's length-prefixed output + /// script (P2PKH, P2WPKH, P2SH or P2WSH). + /// @param feePaid True when the vault collected the redemption fee for + /// this request. + /// @param useRetryCredit True when the request consumes the fee-free + /// retry entitlement minted by a fee-paid timeout. + function requestReservedRedemption( + uint256 reservationKey, + address redeemer, + bytes calldata redeemerOutputScript, + bool feePaid, + bool useRetryCredit + ) external { + // The caller is checked in the library function. + self.requestReservedRedemption( + reservationKey, + redeemer, + redeemerOutputScript, + feePaid, + useRetryCredit + ); + } + + /// @notice Requests the re-anchoring of a reservation to another + /// wallet: the authorization for the source wallet to move the + /// anchor during migration or a governance-approved rotation. + /// See `Reservation.requestReservationReanchor`. + /// @param reservationKey The key of the reservation to re-anchor. + /// @param targetWalletPubKeyHash 20-byte public key hash of the target + /// wallet. + function requestReservationReanchor( + uint256 reservationKey, + bytes20 targetWalletPubKeyHash + ) external { + self.requestReservationReanchor( + reservationKey, + targetWalletPubKeyHash, + msg.sender == governance + ); + } + + /// @notice Requests the dissolution of an expired reservation: the + /// authorization for the custodying wallet to merge the anchor + /// into its main UTXO once the custody term and grace period + /// elapsed. See `Reservation.requestReservationDissolution`. + /// @param reservationKey The key of the reservation to dissolve. + function requestReservationDissolution(uint256 reservationKey) external { + self.requestReservationDissolution(reservationKey); + } + /// @notice Single entry point for all reservation lifecycle SPV proofs: /// anchor acceptance, in-kind reserved redemption, re-anchoring - /// and dissolution. See `Reservation.submitReservationProof` and - /// the individual handlers in the `Reservation` library for - /// detailed requirements. + /// and dissolution. Settles the named action generation. See + /// `ReservationProofs.submitReservationProof` (reached through + /// the `Reservation` library so this contract links exactly one + /// external library) and the individual handlers for detailed + /// requirements. /// @param proofType The type of the submitted proof, see - /// `Reservation.ProofType`. + /// `ReservationProofs.ProofType`. /// @param txInfo Bitcoin transaction data. /// @param proof Bitcoin proof data. - /// @param mainUtxo Data of the wallet's main UTXO; only used for - /// `Dissolution` proofs and ignored otherwise. - /// @param reservationKey The key of the target reservation; ignored for - /// `Acceptance` proofs where the key is derived from the spent - /// deposit outpoint. + /// @param mainUtxo Data of the main UTXO expected by a `Dissolution` + /// generation; ignored otherwise. + /// @param reservationKey The key of the target reservation. + /// @param requestNonce The action generation being settled. Late + /// settlements name an older, timed-out generation. function submitReservationProof( uint8 proofType, BitcoinTx.Info calldata txInfo, BitcoinTx.Proof calldata proof, BitcoinTx.UTXO calldata mainUtxo, - uint256 reservationKey + uint256 reservationKey, + uint64 requestNonce ) external onlySpvMaintainer { self.submitReservationProof( proofType, txInfo, proof, mainUtxo, - reservationKey - ); - } - - /// @notice Extends the custody term of a reservation by the current - /// reservation term length. Can only be called by the - /// reservation vault, which collects the custody fee for the - /// extension. See `Reservation.extendReservation`. - /// @param reservationKey The key of the reservation to extend. - function extendReservation(uint256 reservationKey) external { - // The caller is checked in the library function. - self.extendReservation(reservationKey); - } - - /// @notice Requests an in-kind redemption of a reservation: the wallet - /// is expected to spend exactly the reservation's current anchor - /// outpoint to the redeemer output script in a 1-input-1-output - /// transaction. Can only be called by the reservation vault, - /// which must have approved the Bridge in the Bank for the gross - /// minted amount. See `Reservation.requestReservedRedemption`. - /// @param reservationKey The key of the reservation to redeem. - /// @param redeemer The address able to claim the surrendered balance - /// back if the redemption times out. - /// @param redeemerOutputScript The redeemer's length-prefixed output - /// script (P2PKH, P2WPKH, P2SH or P2WSH). - function requestReservedRedemption( - uint256 reservationKey, - address redeemer, - bytes calldata redeemerOutputScript - ) external { - // The caller is checked in the library function. - self.requestReservedRedemption( reservationKey, - redeemer, - redeemerOutputScript + requestNonce ); } - /// @notice Notifies that a pending reserved redemption has timed out. - /// Returns the surrendered balance to the redeemer, slashes the - /// wallet operators like a regular redemption timeout, and - /// returns the reservation to the Active state. See - /// `Reservation.notifyReservedRedemptionTimeout`. + /// @notice Notifies that the pending action of the given reservation + /// has timed out. Writes the terminal, late-proof-accepting + /// record, releases reserved capacity and locks, refunds the + /// escrowed claim for redemptions, and slashes the wallet for + /// redemption and dissolution timeouts. See + /// `Reservation.notifyReservationActionTimeout`. /// @param reservationKey The key of the reservation with the timed out - /// redemption. + /// action. /// @param walletMembersIDs Identifiers of the wallet signing group - /// members. - function notifyReservedRedemptionTimeout( + /// members; only consulted on the slashing paths (redemption + /// and dissolution timeouts). + function notifyReservationActionTimeout( uint256 reservationKey, uint32[] calldata walletMembersIDs ) external { - self.notifyReservedRedemptionTimeout(reservationKey, walletMembersIDs); + self.notifyReservationActionTimeout(reservationKey, walletMembersIDs); } - /// @notice Notifies that a pending reserved redemption was vetoed in the - /// redemption watchtower. Detains the surrendered balance to the - /// watchtower and returns the reservation to the Active state. - /// See `Reservation.notifyReservedRedemptionVeto`. + /// @notice Notifies that a pending reserved redemption generation was + /// vetoed in the redemption watchtower. Detains the escrowed + /// balance to the watchtower, terminally voids the generation + /// and returns the position to Active. See + /// `Reservation.notifyReservedRedemptionVeto`. /// @param reservationKey The key of the reservation with the vetoed /// redemption. - function notifyReservedRedemptionVeto(uint256 reservationKey) external { + /// @param requestNonce The generation being vetoed. + function notifyReservedRedemptionVeto( + uint256 reservationKey, + uint64 requestNonce + ) external { // The caller is checked in the library function. - self.notifyReservedRedemptionVeto(reservationKey); + self.notifyReservedRedemptionVeto(reservationKey, requestNonce); + } + + /// @notice Extends the custody term of a reservation by its snapshotted + /// term length. Can only be called by the reservation vault, + /// which collects the custody fee for the extension. See + /// `Reservation.extendReservation`. + /// @param reservationKey The key of the reservation to extend. + function extendReservation(uint256 reservationKey) external { + // The caller is checked in the library function. + self.extendReservation(reservationKey); } /// @notice Updates parameters of reservations, including the @@ -261,20 +370,19 @@ contract ReservationRouter is Governable, Initializable { /// @param reservationVault Address of the reservation vault. Can only be /// changed while there are no active reservations. /// @param reservationMinAmount New value of the reservation minimum - /// amount in satoshis. It is the minimal anchor output amount - /// accepted for a reservation. + /// amount in satoshis. /// @param reservationTxMaxFee New value of the reservation transaction - /// max fee in satoshis. It is the maximum amount of BTC - /// transaction fee that can be incurred by a single reservation - /// lifecycle transaction. + /// max fee in satoshis. /// @param reservationTermSeconds New value of the reservation custody - /// term length in seconds. + /// term length in seconds. Snapshotted into new positions. /// @param reservationGracePeriod New value of the reservation grace - /// period in seconds. + /// period in seconds. Snapshotted into new positions. /// @param reservationMaxTotalAmount New cap on the total amount in /// satoshi locked under active reservations. /// @param maxReservationsPerWallet New cap on the number of active /// reservations a single wallet can custody. + /// @param reservationActionTimeout New value of the reservation action + /// timeout in seconds. /// @dev Requirements: /// - The caller must be the governance, /// - See `Reservation.updateReservationParameters` for parameter @@ -286,7 +394,8 @@ contract ReservationRouter is Governable, Initializable { uint32 reservationTermSeconds, uint32 reservationGracePeriod, uint64 reservationMaxTotalAmount, - uint32 maxReservationsPerWallet + uint32 maxReservationsPerWallet, + uint32 reservationActionTimeout ) external onlyGovernance { self.updateReservationParameters( reservationVault, @@ -295,12 +404,13 @@ contract ReservationRouter is Governable, Initializable { reservationTermSeconds, reservationGracePeriod, reservationMaxTotalAmount, - maxReservationsPerWallet + maxReservationsPerWallet, + reservationActionTimeout ); } - /// @notice Collection of all reservations indexed by the deposit key of - /// the underlying reserved deposit, i.e. + /// @notice Collection of all reservation positions indexed by the + /// deposit key of the underlying reserved deposit, i.e. /// `keccak256(fundingTxHash | fundingOutputIndex)`. /// @param reservationKey The key of the reservation. function reservations(uint256 reservationKey) @@ -311,6 +421,33 @@ contract ReservationRouter is Governable, Initializable { return self.reservations[reservationKey]; } + /// @notice Returns the action record of the given reservation + /// generation. + /// @param reservationKey The key of the reservation. + /// @param requestNonce The action generation. + function reservationActions(uint256 reservationKey, uint64 requestNonce) + external + view + returns (Reservation.ReservationAction memory) + { + return + self.reservationActions[ + Reservation.actionKey(reservationKey, requestNonce) + ]; + } + + /// @notice Returns the reservation key of the wallet's in-flight + /// dissolution, or zero when none is pending (the per-wallet + /// main-UTXO action lock). + /// @param walletPubKeyHash 20-byte public key hash of the wallet. + function walletPendingDissolution(bytes20 walletPubKeyHash) + external + view + returns (uint256) + { + return self.walletPendingDissolution[walletPubKeyHash]; + } + /// @notice Returns the current values of Bridge reservation parameters. function reservationParameters() external @@ -323,7 +460,8 @@ contract ReservationRouter is Governable, Initializable { uint32 reservationGracePeriod, uint64 reservationMaxTotalAmount, uint64 reservationTotalAmount, - uint32 maxReservationsPerWallet + uint32 maxReservationsPerWallet, + uint32 reservationActionTimeout ) { reservationVault = self.reservationVault; @@ -334,6 +472,7 @@ contract ReservationRouter is Governable, Initializable { reservationMaxTotalAmount = self.reservationMaxTotalAmount; reservationTotalAmount = self.reservationTotalAmount; maxReservationsPerWallet = self.maxReservationsPerWallet; + reservationActionTimeout = self.reservationActionTimeout; } /// @notice Returns the address of the reservation router the Bridge diff --git a/solidity/contracts/bridge/WalletProposalValidator.sol b/solidity/contracts/bridge/WalletProposalValidator.sol index 8366e906c..47e40ab76 100644 --- a/solidity/contracts/bridge/WalletProposalValidator.sol +++ b/solidity/contracts/bridge/WalletProposalValidator.sol @@ -270,7 +270,7 @@ contract WalletProposalValidator { address proposalVault = address(0); - (address reservationVault, , , , , , , ) = IReservationBridge( + (address reservationVault, , , , , , , , ) = IReservationBridge( address(bridge) ).reservationParameters(); @@ -918,6 +918,8 @@ contract WalletProposalValidator { bytes20 walletPubKeyHash; // Key of the reserved deposit to anchor. DepositKey depositKey; + // Generation of the acceptance authorization being executed. + uint64 requestNonce; // Proposed BTC fee for the anchor transaction. uint256 anchorTxFee; } @@ -928,6 +930,8 @@ contract WalletProposalValidator { bytes20 walletPubKeyHash; // Key of the reservation with the pending reserved redemption. uint256 reservationKey; + // Generation of the redemption request being executed. + uint64 requestNonce; // Proposed BTC fee for the reserved redemption transaction. uint256 redemptionTxFee; } @@ -939,6 +943,8 @@ contract WalletProposalValidator { bytes20 sourceWalletPubKeyHash; // Key of the reservation to re-anchor. uint256 reservationKey; + // Generation of the re-anchor authorization being executed. + uint64 requestNonce; // 20-byte public key hash of the wallet receiving the anchor. bytes20 targetWalletPubKeyHash; // Proposed BTC fee for the re-anchor transaction. @@ -952,10 +958,34 @@ contract WalletProposalValidator { bytes20 walletPubKeyHash; // Key of the reservation to dissolve. uint256 reservationKey; + // Generation of the dissolution authorization being executed. + uint64 requestNonce; // Proposed BTC fee for the dissolution transaction. uint256 dissolutionTxFee; } + /// @notice Fetches the given reservation action generation and reverts + /// unless it is pending and of the expected type. Wallets must + /// only sign explicitly requested, still-pending generations — + /// the Bridge proof path settles nothing else (except late + /// proofs of timed-out generations, which only exist for + /// transactions signed while the generation was pending). + function requirePendingAction( + uint256 reservationKey, + uint64 requestNonce, + Reservation.ActionType expectedType + ) internal view returns (Reservation.ReservationAction memory action) { + action = IReservationBridge(address(bridge)).reservationActions( + reservationKey, + requestNonce + ); + require( + action.actionType == expectedType && + action.state == Reservation.ActionState.Pending, + "No pending action of the expected type" + ); + } + /// @notice View function encapsulating the main rules of a valid /// reservation anchor proposal. /// @param proposal The anchor proposal to validate. @@ -963,13 +993,15 @@ contract WalletProposalValidator { /// validation. /// @return True if the proposal is valid. Reverts otherwise. /// @dev Requirements: - /// - Reservations must be enabled (reservation vault set), + /// - The named acceptance authorization generation must be + /// pending, authorize the proposal wallet, and not be within + /// the timeout safety margin, /// - The wallet must be in the Live or MovingFunds state, - /// - The deposit must be revealed, old enough, not swept, and - /// routed to the reservation vault, - /// - The proposed fee must be positive, within the reservation - /// transaction max fee, and must leave an anchor amount above - /// the reservation minimum, + /// - The deposit must be revealed, old enough and not swept, + /// - The proposed fee must be positive and within the + /// authorization's snapshotted fee bound (which, with the + /// request-time amount check, keeps the anchor above the + /// reservation minimum), /// - The deposit extra info must be valid and preserve the refund /// safety margin, /// - The deposit must be controlled by the proposal wallet. @@ -977,19 +1009,6 @@ contract WalletProposalValidator { ReservationAnchorProposal calldata proposal, DepositExtraInfo calldata depositExtraInfo ) external view returns (bool) { - ( - address reservationVault, - uint64 reservationMinAmount, - uint64 reservationTxMaxFee, - , - , - , - , - - ) = IReservationBridge(address(bridge)).reservationParameters(); - - require(reservationVault != address(0), "Reservations are disabled"); - requireWalletLiveOrMovingFunds(proposal.walletPubKeyHash); uint256 depositKeyUint = uint256( @@ -1001,6 +1020,24 @@ contract WalletProposalValidator { ) ); + Reservation.ReservationAction memory action = requirePendingAction( + depositKeyUint, + proposal.requestNonce, + Reservation.ActionType.Acceptance + ); + + require( + action.targetWalletPubKeyHash == proposal.walletPubKeyHash, + "Acceptance authorized for different wallet" + ); + + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp < + action.timeoutAt - REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN, + "Authorization timeout safety margin is not preserved" + ); + Deposit.DepositRequest memory depositRequest = bridge.deposits( depositKeyUint ); @@ -1015,24 +1052,14 @@ contract WalletProposalValidator { require(depositRequest.sweptAt == 0, "Deposit already swept"); - require( - depositRequest.vault == reservationVault, - "Deposit not routed to the reservation vault" - ); - require( proposal.anchorTxFee > 0, "Proposed transaction fee cannot be zero" ); require( - proposal.anchorTxFee <= reservationTxMaxFee, + proposal.anchorTxFee <= action.txMaxFee, "Proposed transaction fee is too high" ); - require( - depositRequest.amount - proposal.anchorTxFee >= - reservationMinAmount, - "Anchor amount below the reservation minimum" - ); validateDepositExtraInfo( proposal.depositKey, @@ -1082,21 +1109,30 @@ contract WalletProposalValidator { address(bridge) ).reservations(proposal.reservationKey); - require( - reservation.state == - Reservation.ReservationState.RedemptionRequested, - "No pending reserved redemption" - ); require( reservation.walletPubKeyHash == proposal.walletPubKeyHash, "Reservation custodied by different wallet" ); + Reservation.ReservationAction memory action = requirePendingAction( + proposal.reservationKey, + proposal.requestNonce, + Reservation.ActionType.Redemption + ); + + // The authorization gate: the watchtower delay applicable to this + // generation must have elapsed before the wallet signs. The Bridge + // proof path enforces the same rule, so signing earlier would + // produce a transaction that cannot settle before the guardians' + // window closes — and never settles if the generation gets vetoed. uint32 requestMinAge = REDEMPTION_REQUEST_MIN_AGE; address watchtower = bridge.getRedemptionWatchtower(); if (watchtower != address(0)) { uint32 delay = IRedemptionWatchtower(watchtower) - .getReservedRedemptionDelay(proposal.reservationKey); + .getReservedRedemptionDelay( + proposal.reservationKey, + proposal.requestNonce + ); if (delay > requestMinAge) { requestMinAge = delay; } @@ -1104,17 +1140,14 @@ contract WalletProposalValidator { require( /* solhint-disable-next-line not-rely-on-time */ - block.timestamp > reservation.redemptionRequestedAt + requestMinAge, + block.timestamp > action.requestedAt + requestMinAge, "Reserved redemption min age not achieved yet" ); - (, , , , uint32 redemptionTimeout, , ) = bridge.redemptionParameters(); require( /* solhint-disable-next-line not-rely-on-time */ block.timestamp < - reservation.redemptionRequestedAt + - redemptionTimeout - - REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN, + action.timeoutAt - REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN, "Redemption request timeout safety margin is not preserved" ); @@ -1123,7 +1156,7 @@ contract WalletProposalValidator { "Proposed transaction fee cannot be zero" ); require( - proposal.redemptionTxFee <= reservation.redemptionTxMaxFee, + proposal.redemptionTxFee <= action.txMaxFee, "Proposed transaction fee is too high" ); @@ -1137,13 +1170,12 @@ contract WalletProposalValidator { /// @dev Requirements: /// - The reservation must be Active and custodied by the source /// wallet, - /// - The target wallet must be in the Live state, - /// - The proposed fee must be positive and within the reservation - /// transaction max fee. - /// - /// The source wallet state is deliberately not restricted, mirroring - /// `Reservation.submitReservationReanchorProof`: moving reservations - /// out must remain possible for wallets in any lifecycle state. + /// - The named re-anchor authorization generation must be pending, + /// target the proposal's target wallet, and not be within the + /// timeout safety margin, + /// - The proposed fee must be positive, within the authorization's + /// snapshotted fee bound, and leave the re-anchored amount above + /// the dust floor. function validateReservationReanchorProposal( ReservationReanchorProposal calldata proposal ) external view returns (bool) { @@ -1151,32 +1183,41 @@ contract WalletProposalValidator { address(bridge) ).reservations(proposal.reservationKey); - require( - reservation.state == Reservation.ReservationState.Active, - "Reservation is not active" - ); require( reservation.walletPubKeyHash == proposal.sourceWalletPubKeyHash, "Reservation custodied by different wallet" ); + Reservation.ReservationAction memory action = requirePendingAction( + proposal.reservationKey, + proposal.requestNonce, + Reservation.ActionType.Reanchor + ); + require( - bridge.wallets(proposal.targetWalletPubKeyHash).state == - Wallets.WalletState.Live, - "Target wallet must be in Live state" + action.targetWalletPubKeyHash == proposal.targetWalletPubKeyHash, + "Re-anchor authorized for different target wallet" + ); + + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp < + action.timeoutAt - REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN, + "Authorization timeout safety margin is not preserved" ); - (, , uint64 reservationTxMaxFee, , , , , ) = IReservationBridge( - address(bridge) - ).reservationParameters(); require( proposal.reanchorTxFee > 0, "Proposed transaction fee cannot be zero" ); require( - proposal.reanchorTxFee <= reservationTxMaxFee, + proposal.reanchorTxFee <= action.txMaxFee, "Proposed transaction fee is too high" ); + require( + reservation.anchorAmount - proposal.reanchorTxFee > action.txMaxFee, + "Re-anchor amount below the dust floor" + ); return true; } @@ -1187,10 +1228,12 @@ contract WalletProposalValidator { /// @return True if the proposal is valid. Reverts otherwise. /// @dev Requirements: /// - The wallet must be in the Live or MovingFunds state, - /// - The reservation must be Active, custodied by the proposal - /// wallet, and past its custody term plus grace period, - /// - The proposed fee must be positive and within the reservation - /// transaction max fee. + /// - The reservation must be custodied by the proposal wallet, + /// - The named dissolution authorization generation must be + /// pending (its request already validated the term and grace + /// expiry) and not be within the timeout safety margin, + /// - The proposed fee must be positive and within the + /// authorization's snapshotted fee bound. function validateReservationDissolutionProposal( ReservationDissolutionProposal calldata proposal ) external view returns (bool) { @@ -1200,31 +1243,22 @@ contract WalletProposalValidator { address(bridge) ).reservations(proposal.reservationKey); - require( - reservation.state == Reservation.ReservationState.Active, - "Reservation is not active" - ); require( reservation.walletPubKeyHash == proposal.walletPubKeyHash, "Reservation custodied by different wallet" ); - ( - , - , - uint64 reservationTxMaxFee, - , - uint32 reservationGracePeriod, - , - , - - ) = IReservationBridge(address(bridge)).reservationParameters(); + Reservation.ReservationAction memory action = requirePendingAction( + proposal.reservationKey, + proposal.requestNonce, + Reservation.ActionType.Dissolution + ); require( /* solhint-disable-next-line not-rely-on-time */ - block.timestamp > - uint256(reservation.expiresAt) + reservationGracePeriod, - "Reservation term or grace period not elapsed" + block.timestamp < + action.timeoutAt - REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN, + "Authorization timeout safety margin is not preserved" ); require( @@ -1232,7 +1266,7 @@ contract WalletProposalValidator { "Proposed transaction fee cannot be zero" ); require( - proposal.dissolutionTxFee <= reservationTxMaxFee, + proposal.dissolutionTxFee <= action.txMaxFee, "Proposed transaction fee is too high" ); diff --git a/solidity/contracts/bridge/Wallets.sol b/solidity/contracts/bridge/Wallets.sol index bd054550b..a4a65a15e 100644 --- a/solidity/contracts/bridge/Wallets.sol +++ b/solidity/contracts/bridge/Wallets.sol @@ -593,11 +593,20 @@ library Wallets { ) internal { Wallet storage wallet = self.registeredWallets[walletPubKeyHash]; - if (wallet.mainUtxoHash == bytes32(0)) { - // If the wallet has no main UTXO, that means its BTC balance - // is zero and the wallet closing should begin immediately. + if ( + wallet.mainUtxoHash == bytes32(0) && + self.walletReservationsCount[walletPubKeyHash] == 0 + ) { + // If the wallet has no main UTXO and no reservation anchors, + // its BTC balance is zero and the wallet closing should begin + // immediately. beginWalletClosing(self, walletPubKeyHash); } else { + // The wallet holds funds: a main UTXO, reservation anchors, or + // both. A wallet with anchors but no main UTXO still enters + // the moving funds process — its reservations must be + // re-anchored to other wallets (or redeemed/dissolved) before + // it can begin closing via the below-dust notification. // Otherwise, initialize the moving funds process. wallet.state = WalletState.MovingFunds; /* solhint-disable-next-line not-rely-on-time */ @@ -626,6 +635,17 @@ library Wallets { BridgeState.Storage storage self, bytes20 walletPubKeyHash ) internal { + // A wallet that still custodies reservation anchors (or reserved + // capacity of pending reservation actions) has not finished moving + // its funds: reservations must first be redeemed, re-anchored to + // other wallets or dissolved into the main UTXO. Entering the + // Closing state would strand them — reservation actions require a + // Live or MovingFunds wallet. + require( + self.walletReservationsCount[walletPubKeyHash] == 0, + "Wallet still custodies reservations" + ); + Wallet storage wallet = self.registeredWallets[walletPubKeyHash]; // Initialize the closing period. wallet.state = WalletState.Closing; diff --git a/solidity/contracts/test/BridgeStub.sol b/solidity/contracts/test/BridgeStub.sol index eac26f40e..60ba14e87 100644 --- a/solidity/contracts/test/BridgeStub.sol +++ b/solidity/contracts/test/BridgeStub.sol @@ -70,6 +70,10 @@ contract BridgeStub is Bridge { self.activeWalletPubKeyHash = activeWalletPubKeyHash; } + function setLiveWalletsCount(uint32 liveWalletsCount) external { + self.liveWalletsCount = liveWalletsCount; + } + function setWalletMainUtxo( bytes20 walletPubKeyHash, BitcoinTx.UTXO calldata utxo diff --git a/solidity/contracts/vault/ReservationVault.sol b/solidity/contracts/vault/ReservationVault.sol index 91f159e7f..bd8c8bd03 100644 --- a/solidity/contracts/vault/ReservationVault.sol +++ b/solidity/contracts/vault/ReservationVault.sol @@ -256,7 +256,9 @@ contract ReservationVault is IVault, Ownable { bridge.requestReservedRedemption( reservationKey, msg.sender, - redeemerOutputScript + redeemerOutputScript, + true, // The redemption fee was collected above. + false ); } @@ -304,7 +306,11 @@ contract ReservationVault is IVault, Ownable { /// @dev Requirements: /// - The caller must be the reservation owner, /// - The caller must have approved this vault in the Bank for the - /// gross minted amount (`Bank.approveBalance`). + /// gross minted amount (`Bank.approveBalance`), + /// - The reservation must hold the single-use retry entitlement + /// the Bridge mints when a fee-paid redemption request times + /// out through the wallet's fault (enforced by the Bridge and + /// consumed by this call). /// /// The redemption fee is not re-charged: it was collected by the /// original `redeemReservation` call and the retry only exists @@ -348,7 +354,9 @@ contract ReservationVault is IVault, Ownable { bridge.requestReservedRedemption( reservationKey, msg.sender, - redeemerOutputScript + redeemerOutputScript, + false, + true // Consume the retry entitlement instead of paying the fee. ); } diff --git a/solidity/deploy/06_deploy_bridge.ts b/solidity/deploy/06_deploy_bridge.ts index 7cc5b1aa1..7397a7f44 100644 --- a/solidity/deploy/06_deploy_bridge.ts +++ b/solidity/deploy/06_deploy_bridge.ts @@ -39,12 +39,20 @@ const func: DeployFunction = async function deployBridge( }) const Fraud = await deploy("Fraud", deployOptions) const MovingFunds = await deploy("MovingFunds", deployOptions) - const Reservation = await deploy("Reservation", deployOptions) + const ReservationProofs = await deploy("ReservationProofs", deployOptions) + const Reservation = await deploy("Reservation", { + ...deployOptions, + libraries: { + ReservationProofs: ReservationProofs.address, + }, + }) // The reservation router holds the Bridge's UTXO-reservation external // surface and is reached through the Bridge's fallback via delegatecall. // It is stateless code (all storage lives in the Bridge), so a single - // instance can serve any number of Bridge deployments. + // instance can serve any number of Bridge deployments. The router links + // exactly one library (Reservation), which in turn links the settlement + // library (ReservationProofs). const ReservationRouter = await deploy("ReservationRouter", { ...deployOptions, libraries: { @@ -103,6 +111,7 @@ const func: DeployFunction = async function deployBridge( await helpers.etherscan.verify(Fraud) await helpers.etherscan.verify(MovingFunds) await helpers.etherscan.verify(Reservation) + await helpers.etherscan.verify(ReservationProofs) await helpers.etherscan.verify(ReservationRouter) // We use `verify` instead of `verify:verify` as the `verify` task is defined diff --git a/solidity/deploy/40_deploy_redemption_watchtower.ts b/solidity/deploy/40_deploy_redemption_watchtower.ts index 60495d07e..db0d23c7c 100644 --- a/solidity/deploy/40_deploy_redemption_watchtower.ts +++ b/solidity/deploy/40_deploy_redemption_watchtower.ts @@ -16,6 +16,14 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { }, proxyOpts: { kind: "transparent", + // The watchtower imports the `Reservation` library for its shared + // types; that library forwards reservation proofs to the external + // `ReservationProofs` library, which trips the upgrades plugin's + // source-closure check. The watchtower's own bytecode links no + // external library (types and internal helpers are inlined), so + // the linking is upgrade safe here — same rationale as the Bridge + // deployment. + unsafeAllow: ["external-library-linking"], }, }) From 69de27a69719c18c3093b992c0f44cc63c16cb4e Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 8 Aug 2026 10:15:24 -0400 Subject: [PATCH 02/14] fix(build): tolerate foreign link-reference splices in upgrades-core validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @openzeppelin/upgrades-core@1.20.0's getUnlinkedBytecode probes every linkable contract by splicing its library-link placeholders into the bytecode being proxy-deployed. When a foreign contract's link position lands inside that bytecode's metadata-trim boundary, the truncated placeholder makes getVersion throw 'Bytecode is not a valid hex string' and the whole proxy deployment fails — observed for unrelated proxy deployments once the ReservationRouter's link references entered the compilation set. Newer upgrades-core versions wrap each probe in try/catch; this config-time shim applies the same per-candidate tolerance without a dependency bump and can be dropped when the plugin is upgraded. --- solidity/hardhat.config.ts | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/solidity/hardhat.config.ts b/solidity/hardhat.config.ts index 5e45d3a45..cb724f0f5 100644 --- a/solidity/hardhat.config.ts +++ b/solidity/hardhat.config.ts @@ -19,6 +19,69 @@ import "solidity-docgen" // Load .env from tbtc-v2/ (parent of solidity/) so CHAIN_API_URL etc. are available loadEnv({ path: path.join(__dirname, "..", ".env") }) +// Backport of the upstream fix for a @openzeppelin/upgrades-core@1.20.0 bug: +// `getUnlinkedBytecode` probes every linkable contract by splicing its +// library-link placeholders into the bytecode being deployed. When a foreign +// contract's link position lands inside the metadata-trim boundary of that +// bytecode, the truncated placeholder makes `getVersion` throw "Bytecode is +// not a valid hex string" and the whole proxy deployment fails — observed +// once the ReservationRouter (a delegatecall extension with library link +// references) entered the compilation set. Newer upgrades-core versions +// wrap each probe in try/catch; this shim applies the same per-candidate +// tolerance without a dependency bump. +/* eslint-disable @typescript-eslint/no-var-requires, global-require */ +{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const patch = (queryModule: any) => { + const { + normalizeValidationData, + // eslint-disable-next-line @typescript-eslint/no-var-requires + } = require("@openzeppelin/upgrades-core/dist/validate/data") + const { + unlinkBytecode, + // eslint-disable-next-line @typescript-eslint/no-var-requires + } = require("@openzeppelin/upgrades-core/dist/link-refs") + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getVersion } = require("@openzeppelin/upgrades-core/dist/version") + + // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any + queryModule.getUnlinkedBytecode = (data: any, bytecode: string) => { + const dataV3 = normalizeValidationData(data) + for (const validation of dataV3.log) { + const linkableContracts = Object.keys(validation).filter( + (name) => validation[name].linkReferences.length > 0 + ) + for (const name of linkableContracts) { + try { + const unlinkedBytecode = unlinkBytecode( + bytecode, + validation[name].linkReferences + ) + const version = getVersion(unlinkedBytecode) + if ( + validation[name].version?.withMetadata === version.withMetadata + ) { + return unlinkedBytecode + } + } catch { + // A foreign contract's link references mangled this bytecode; + // it cannot be the contract being deployed — skip the candidate. + } + } + } + return bytecode + } + } + + patch(require("@openzeppelin/upgrades-core/dist/validate/query")) + const upgradesCore = require("@openzeppelin/upgrades-core") + const queryModule = require("@openzeppelin/upgrades-core/dist/validate/query") + if (upgradesCore.getUnlinkedBytecode !== queryModule.getUnlinkedBytecode) { + patch(upgradesCore) + } +} +/* eslint-enable @typescript-eslint/no-var-requires, global-require */ + const ecdsaSolidityCompilerConfig = { version: "0.8.17", settings: { From b913b7876f3b0acf40933d3cd9b702bf2f760dea Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 8 Aug 2026 10:15:24 -0400 Subject: [PATCH 03/14] test: two-phase reservation settlement coverage Adapt the reservation suite to the authorize-then-prove flows (explicit acceptance/re-anchor/dissolution requests, generation-named proofs, unified action timeout, per-generation veto keying, retry entitlement) and add an adversarial settlement suite covering: the claim double-spend regression (timeout racing a confirmed redemption settles late with no second refund and fraud-defeat registration), the late-proof matrix against newer pending generations (matching generation must settle, non-matching is unwound with its escrow refunded), on-chain watchtower delay enforcement and the never-settling vetoed generation, the per-wallet dissolution lock with serialized no-main-UTXO dissolutions and the registry-drift fallback, capacity-reserved-before-signing (fill-then-prove), acceptance authorization timeout with late anchor settlement, the post-grace stranding bound, and wallet lifecycle integration (reservation-holding wallets retire through MovingFunds). The pooled WalletProposalValidator suite now fakes the Bridge from the merged Bridge+ReservationRouter ABI (router-resident views must return zeroed defaults rather than empty revert data), and the TIP-109 deployment tests expect the 6-library Bridge link map (Reservation moved behind the router). --- .../test/bridge/Bridge.Reservation.test.ts | 873 +++++++++--- .../Bridge.ReservationSettlement.test.ts | 1198 +++++++++++++++++ .../test/bridge/ReservationRouter.test.ts | 22 +- .../bridge/WalletProposalValidator.test.ts | 24 +- ...5_deploy_tip109_governance_upgrade.test.ts | 11 +- 5 files changed, 1920 insertions(+), 208 deletions(-) create mode 100644 solidity/test/bridge/Bridge.ReservationSettlement.test.ts diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index b68fef53c..de947ef20 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -12,6 +12,7 @@ import type { BridgeGovernance, BridgeStub, IRelay, + ReservationRouter, ReservationVault, TBTCVault, TBTC, @@ -35,9 +36,38 @@ const RESERVATION_MIN_AMOUNT = 10000 const RESERVATION_TX_MAX_FEE = 2000 const RESERVATION_MAX_TOTAL = BigNumber.from("2100000000000000") const MAX_RESERVATIONS_PER_WALLET = 10 +const RESERVATION_ACTION_TIMEOUT = 172800 // 48 hours const SATOSHI_MULTIPLIER = BigNumber.from(10).pow(10) +// Reservation.ReservationState +const ReservationState = { + Unknown: 0, + Active: 1, + ActionPending: 2, + Closed: 3, + Stranded: 4, +} + +// Reservation.ActionType +const ActionType = { + None: 0, + Acceptance: 1, + Redemption: 2, + Reanchor: 3, + Dissolution: 4, +} + +// Reservation.ActionState +const ActionState = { + Unknown: 0, + Pending: 1, + Settled: 2, + TimedOut: 3, + Vetoed: 4, + Superseded: 5, +} + describe("Bridge - Reservation", () => { let governance: SignerWithAddress let spvMaintainer: SignerWithAddress @@ -47,7 +77,7 @@ describe("Bridge - Reservation", () => { let bank: Bank & BankStub let relay: FakeContract - let bridge: Bridge & BridgeStub + let bridge: Bridge & BridgeStub & ReservationRouter let tbtc: TBTC & Contract let tbtcVault: TBTCVault & Contract let bridgeGovernance: BridgeGovernance @@ -109,12 +139,13 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) } - // Builds an active reservation record owned by `owner`, custodied by the - // wallet identified by `walletPubKeyHash`. + // Builds an active reservation position owned by `owner`, custodied by + // the wallet identified by `walletPubKeyHash`. async function activeReservation( owner: string, walletPubKeyHash: string, @@ -129,14 +160,40 @@ describe("Bridge - Reservation", () => { expiresAt: (await lastBlockTime()) + RESERVATION_TERM, anchorTxHash: ethers.utils.randomBytes(32), anchorTxOutputIndex: 0, - state: 1, // Active - redemptionRequestedAt: 0, - redemptionTxMaxFee: 0, - redeemer: ZERO_ADDRESS, - redeemerOutputScriptHash: ZERO_BYTES32, + state: ReservationState.Active, + requestNonce: 0, + retryCredit: false, + termSeconds: RESERVATION_TERM, + gracePeriod: RESERVATION_GRACE, } } + // Requests a reserved redemption through the impersonated vault, funding + // and approving the gross Bank balance surrender. + async function requestRedemptionViaVault( + reservationKey: BigNumber | number, + amountSat: BigNumber, + redeemer: string, + redeemerOutputScript: string, + options: { feePaid?: boolean; useRetryCredit?: boolean } = {} + ) { + const bridgeSigner = await impersonateContract(bridge.address) + await bank + .connect(bridgeSigner) + .increaseBalance(reservationVault.address, amountSat) + const vaultSigner = await impersonateContract(reservationVault.address) + await bank.connect(vaultSigner).approveBalance(bridge.address, amountSat) + return bridge + .connect(vaultSigner) + .requestReservedRedemption( + reservationKey, + redeemer, + redeemerOutputScript, + options.feePaid ?? true, + options.useRetryCredit ?? false + ) + } + // ---- Bitcoin fixture crafting (regtest-style difficulty) ---- // SPV proof validation checks transaction structure, merkle inclusion, // and header work against the relay difficulty -- it does not execute @@ -275,7 +332,8 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) ).to.be.revertedWith("Caller is not the governance") }) @@ -292,7 +350,8 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) await expect(tx) @@ -306,7 +365,8 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) }) @@ -321,13 +381,33 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) ).to.be.revertedWith( "Reservation transaction max fee must be greater than zero" ) }) + it("should revert for a zero action timeout", async () => { + await expect( + bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET, + 0 + ) + ).to.be.revertedWith( + "Reservation action timeout must be greater than zero" + ) + }) + it("should revert when changing the vault with active reservations", async () => { const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" await bridge.setReservation( @@ -349,7 +429,8 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) ).to.be.revertedWith("Active reservations exist") }) @@ -443,9 +524,24 @@ describe("Bridge - Reservation", () => { }) context("when called by the reservation vault", () => { - it("should extend the reservation term", async () => { + it("should extend the reservation term by the snapshotted length", async () => { const before_ = await bridge.reservations(reservationKey) + // Change the live term parameter: the extension must use the + // position's snapshot, not the live value. + await bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM * 2, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT + ) + const vaultSigner = await impersonateContract(reservationVault.address) const tx = await bridge .connect(vaultSigner) @@ -472,8 +568,6 @@ describe("Bridge - Reservation", () => { await createSnapshot() await wireReservations() - // A Terminated wallet lets the timeout path skip slashing and state - // transitions, isolating the reservation bookkeeping under test. await bridge.setWallet(walletPubKeyHash, { ecdsaWalletID: ethers.utils.randomBytes(32), mainUtxoHash: ZERO_BYTES32, @@ -482,21 +576,17 @@ describe("Bridge - Reservation", () => { movingFundsRequestedAt: 0, closingStartedAt: 0, pendingMovedFundsSweepRequestsCount: 0, - state: walletState.Terminated, + state: walletState.Live, movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, }) + // The redemption timeout path slashes the wallet and moves it toward + // retirement, decrementing the live wallets counter. + await bridge.setLiveWalletsCount(1) await bridge.setReservation( reservationKey, await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) ) - - // Give the reservation vault the gross Bank balance it must - // surrender with the request. - const bridgeSigner = await impersonateContract(bridge.address) - await bank - .connect(bridgeSigner) - .increaseBalance(reservationVault.address, amountSat) }) after(async () => { @@ -510,63 +600,91 @@ describe("Bridge - Reservation", () => { .requestReservedRedemption( reservationKey, thirdParty.address, - redeemerOutputScript + redeemerOutputScript, + true, + false ) ).to.be.revertedWith("Caller is not the reservation vault") }) - it("should register the redemption, take the balance, and refund on timeout", async () => { - const vaultSigner = await impersonateContract(reservationVault.address) - - await bank.connect(vaultSigner).approveBalance(bridge.address, amountSat) - - const tx = await bridge - .connect(vaultSigner) - .requestReservedRedemption( - reservationKey, - thirdParty.address, - redeemerOutputScript - ) + it("should register the generation, take the balance, and refund on timeout", async () => { + const tx = await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) await expect(tx) .to.emit(bridge, "ReservedRedemptionRequested") .withArgs( reservationKey, + 1, thirdParty.address, redeemerOutputScript, amountSat, - RESERVATION_TX_MAX_FEE + RESERVATION_TX_MAX_FEE, + true ) - expect((await bridge.reservations(reservationKey)).state).to.equal(2) // RedemptionRequested + const reservation = await bridge.reservations(reservationKey) + expect(reservation.state).to.equal(ReservationState.ActionPending) + expect(reservation.requestNonce).to.equal(1) expect(await bank.balanceOf(bridge.address)).to.equal(amountSat) + const action = await bridge.reservationActions(reservationKey, 1) + expect(action.actionType).to.equal(ActionType.Redemption) + expect(action.state).to.equal(ActionState.Pending) + expect(action.redeemer).to.equal(thirdParty.address) + expect(action.amount).to.equal(amountSat) + expect(action.feePaid).to.be.true + // Re-requesting while one is pending must fail. + const vaultSigner = await impersonateContract(reservationVault.address) await expect( bridge .connect(vaultSigner) .requestReservedRedemption( reservationKey, thirdParty.address, - redeemerOutputScript + redeemerOutputScript, + true, + false ) ).to.be.revertedWith("Reservation is not active") - // Timeout: the redeemer gets the balance back and the reservation - // survives as Active. + // Timeout: the redeemer gets the balance back, the terminal record + // persists, the fee-paid generation mints the retry entitlement and + // the reservation survives as Active. const { redemptionTimeout } = await bridge.redemptionParameters() await increaseTime(redemptionTimeout + 1) const timeoutTx = await bridge .connect(thirdParty) - .notifyReservedRedemptionTimeout(reservationKey, []) + .notifyReservationActionTimeout(reservationKey, []) await expect(timeoutTx) - .to.emit(bridge, "ReservedRedemptionTimedOut") - .withArgs(reservationKey, walletPubKeyHash) + .to.emit(bridge, "ReservationActionTimedOut") + .withArgs(reservationKey, 1, ActionType.Redemption) + await expect(timeoutTx) + .to.emit(bridge, "ReservationRetryCreditMinted") + .withArgs(reservationKey) expect(await bank.balanceOf(thirdParty.address)).to.equal(amountSat) - expect((await bridge.reservations(reservationKey)).state).to.equal(1) // Active + + const reservationAfter = await bridge.reservations(reservationKey) + expect(reservationAfter.state).to.equal(ReservationState.Active) + expect(reservationAfter.retryCredit).to.be.true + + expect( + (await bridge.reservationActions(reservationKey, 1)).state + ).to.equal(ActionState.TimedOut) + + // The wallet was pushed toward retirement (it holds a reservation + // and no main UTXO, so it enters MovingFunds rather than Closing). + expect((await bridge.wallets(walletPubKeyHash)).state).to.equal( + walletState.MovingFunds + ) }) }) @@ -625,6 +743,17 @@ describe("Bridge - Reservation", () => { "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" before(async () => { + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) await bridge.setReservation( reservationKey, await activeReservation( @@ -674,7 +803,11 @@ describe("Bridge - Reservation", () => { expect(await tbtc.balanceOf(treasury.address)).to.equal( treasuryBalanceBefore.add(fee) ) - expect((await bridge.reservations(reservationKey)).state).to.equal(2) + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.ActionPending + ) + expect((await bridge.reservationActions(reservationKey, 1)).feePaid).to + .be.true expect(await bank.balanceOf(bridge.address)).to.equal(amountSat) }) }) @@ -699,7 +832,7 @@ describe("Bridge - Reservation", () => { movingFundsRequestedAt: 0, closingStartedAt: 0, pendingMovedFundsSweepRequestsCount: 0, - state: walletState.Terminated, + state: walletState.Live, movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, }) await bridge.setReservation( @@ -707,22 +840,15 @@ describe("Bridge - Reservation", () => { await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) ) - const bridgeSigner = await impersonateContract(bridge.address) - await bank - .connect(bridgeSigner) - .increaseBalance(reservationVault.address, amountSat) - const vaultSigner = await impersonateContract(reservationVault.address) - await bank.connect(vaultSigner).approveBalance(bridge.address, amountSat) - await bridge - .connect(vaultSigner) - .requestReservedRedemption( - reservationKey, - thirdParty.address, - redeemerOutputScript - ) + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) - // Wire the watchtower only after the request so the - // isSafeRedemption gate does not call an EOA. + // Wire the watchtower only after the request so the safety gate + // does not call an EOA. await bridge .connect(bridgeGovernanceSigner) .setRedemptionWatchtower(deployer.address) @@ -734,21 +860,37 @@ describe("Bridge - Reservation", () => { it("should revert when called by a third party", async () => { await expect( - bridge.connect(thirdParty).notifyReservedRedemptionVeto(reservationKey) + bridge + .connect(thirdParty) + .notifyReservedRedemptionVeto(reservationKey, 1) ).to.be.revertedWith("Caller is not the redemption watchtower") }) - it("should detain the balance and reactivate the reservation", async () => { + it("should revert for a non-current generation", async () => { + await expect( + bridge.connect(deployer).notifyReservedRedemptionVeto(reservationKey, 2) + ).to.be.revertedWith("Not the current generation") + }) + + it("should detain the balance, void the generation, and reactivate the reservation", async () => { const tx = await bridge .connect(deployer) - .notifyReservedRedemptionVeto(reservationKey) + .notifyReservedRedemptionVeto(reservationKey, 1) await expect(tx) .to.emit(bridge, "ReservedRedemptionVetoed") - .withArgs(reservationKey) + .withArgs(reservationKey, 1) expect(await bank.balanceOf(deployer.address)).to.equal(amountSat) - expect((await bridge.reservations(reservationKey)).state).to.equal(1) // Active + + const reservation = await bridge.reservations(reservationKey) + expect(reservation.state).to.equal(ReservationState.Active) + // A veto is owner-fault: no retry entitlement is minted. + expect(reservation.retryCredit).to.be.false + + expect( + (await bridge.reservationActions(reservationKey, 1)).state + ).to.equal(ActionState.Vetoed) }) }) @@ -775,28 +917,29 @@ describe("Bridge - Reservation", () => { movingFundsRequestedAt: 0, closingStartedAt: 0, pendingMovedFundsSweepRequestsCount: 0, - state: walletState.Terminated, + state: walletState.Live, movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, }) + await bridge.setLiveWalletsCount(1) await bridge.setReservation( reservationKey, await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) ) - // Simulate the state a redemption timeout leaves the owner in: the - // gross amount refunded as Bank balance. The fee is paid in TBTC, - // funded here through an ordinary reservation credit. - const bridgeSigner = await impersonateContract(bridge.address) - await bank - .connect(bridgeSigner) - .increaseBalance(thirdParty.address, amountSat) - await bank - .connect(bridgeSigner) - .increaseBalanceAndCall( - reservationVault.address, - [thirdParty.address], - [amountSat] - ) + // A fee-paid request that times out mints the retry entitlement and + // refunds the gross amount to the owner as Bank balance -- exactly + // the state the retry path is designed for. + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + const { redemptionTimeout } = await bridge.redemptionParameters() + await increaseTime(redemptionTimeout + 1) + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) }) after(async () => { @@ -813,13 +956,47 @@ describe("Bridge - Reservation", () => { .connect(thirdParty) .retryRedeemReservation(reservationKey, redeemerOutputScript) - await expect(tx).to.emit(bridge, "ReservedRedemptionRequested") - expect((await bridge.reservations(reservationKey)).state).to.equal(2) // RedemptionRequested + await expect(tx) + .to.emit(bridge, "ReservedRedemptionRequested") + .withArgs( + reservationKey, + 2, + thirdParty.address, + redeemerOutputScript, + amountSat, + RESERVATION_TX_MAX_FEE, + false + ) + expect(await bank.balanceOf(bridge.address)).to.equal(amountSat) // The redemption fee is not re-charged on retry. expect(await tbtc.balanceOf(treasury.address)).to.equal( treasuryBalanceBefore ) + // The entitlement is consumed. + expect((await bridge.reservations(reservationKey)).retryCredit).to.be + .false + }) + + it("rejects a second retry without a fresh entitlement", async () => { + const { redemptionTimeout } = await bridge.redemptionParameters() + await increaseTime(redemptionTimeout + 1) + // The retry generation was not fee-paid, so its timeout mints no + // new entitlement. + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + expect((await bridge.reservations(reservationKey)).retryCredit).to.be + .false + + await bank + .connect(thirdParty) + .approveBalance(reservationVault.address, amountSat) + await expect( + reservationVault + .connect(thirdParty) + .retryRedeemReservation(reservationKey, redeemerOutputScript) + ).to.be.revertedWith("No retry entitlement") }) }) @@ -842,7 +1019,8 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) await expect( @@ -870,7 +1048,8 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) }) }) @@ -884,6 +1063,9 @@ describe("Bridge - Reservation", () => { let redemptionWatchtower: Contract let guardianSigners: SignerWithAddress[] + const vetoKeyOf = (key: BigNumber | number, nonce: number): string => + ethers.utils.solidityKeccak256(["uint256", "uint64"], [key, nonce]) + before(async () => { await createSnapshot() await wireReservations() @@ -932,7 +1114,7 @@ describe("Bridge - Reservation", () => { movingFundsRequestedAt: 0, closingStartedAt: 0, pendingMovedFundsSweepRequestsCount: 0, - state: walletState.Terminated, + state: walletState.Live, movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, }) await bridge.setReservation( @@ -940,57 +1122,57 @@ describe("Bridge - Reservation", () => { await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) ) - const bridgeSigner = await impersonateContract(bridge.address) - await bank - .connect(bridgeSigner) - .increaseBalance(reservationVault.address, amountSat) - const vaultSigner = await impersonateContract(reservationVault.address) - await bank.connect(vaultSigner).approveBalance(bridge.address, amountSat) - await bridge - .connect(vaultSigner) - .requestReservedRedemption( - reservationKey, - thirdParty.address, - redeemerOutputScript - ) + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) }) after(async () => { await restoreSnapshot() }) - it("vetoes a reserved redemption after three guardian objections", async () => { + it("vetoes a reserved redemption generation after three guardian objections", async () => { await redemptionWatchtower .connect(guardianSigners[0]) - .raiseReservedObjection(reservationKey) + .raiseReservedObjection(reservationKey, 1) await redemptionWatchtower .connect(guardianSigners[1]) - .raiseReservedObjection(reservationKey) + .raiseReservedObjection(reservationKey, 1) const tx = await redemptionWatchtower .connect(guardianSigners[2]) - .raiseReservedObjection(reservationKey) + .raiseReservedObjection(reservationKey, 1) + const vetoKey = vetoKeyOf(reservationKey, 1) await expect(tx) .to.emit(redemptionWatchtower, "VetoFinalized") - .withArgs(reservationKey) + .withArgs(vetoKey) await expect(tx) .to.emit(bridge, "ReservedRedemptionVetoed") - .withArgs(reservationKey) + .withArgs(reservationKey, 1) await expect(tx) .to.emit(redemptionWatchtower, "Banned") .withArgs(thirdParty.address) - // The reservation survives the veto as Active. - expect((await bridge.reservations(reservationKey)).state).to.equal(1) + // The reservation survives the veto as Active; the generation is + // terminally vetoed. + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.Active + ) + expect( + (await bridge.reservationActions(reservationKey, 1)).state + ).to.equal(ActionState.Vetoed) // Default penalty is 100%: the whole detained amount is burned. - const veto = await redemptionWatchtower.vetoProposals(reservationKey) + const veto = await redemptionWatchtower.vetoProposals(vetoKey) expect(veto.withdrawableAmount).to.equal(0) expect(await bank.balanceOf(redemptionWatchtower.address)).to.equal(0) // The banned owner cannot re-request through the vault: the - // isSafeRedemption gate now rejects them. + // reservation-scoped safety gate now rejects them. const vaultSigner = await impersonateContract(reservationVault.address) await expect( bridge @@ -998,10 +1180,42 @@ describe("Bridge - Reservation", () => { .requestReservedRedemption( reservationKey, thirdParty.address, - redeemerOutputScript + redeemerOutputScript, + true, + false ) ).to.be.revertedWith("Redemption request rejected by the watchtower") }) + + it("starts every new generation with a clean objection count once unbanned", async () => { + // Unban the owner: the position must become processable again -- + // objection state never accumulates across generations. + await redemptionWatchtower.connect(governance).unban(thirdParty.address) + + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + + const reservation = await bridge.reservations(reservationKey) + expect(reservation.state).to.equal(ReservationState.ActionPending) + expect(reservation.requestNonce).to.equal(2) + + // A single objection against the fresh generation works and does + // not immediately veto (count starts at zero). + await redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey, 2) + const veto = await redemptionWatchtower.vetoProposals( + vetoKeyOf(reservationKey, 2) + ) + expect(veto.objectionsCount).to.equal(1) + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.ActionPending + ) + }) }) describe("WalletProposalValidator", () => { @@ -1015,6 +1229,7 @@ describe("Bridge - Reservation", () => { let futureRefundLocktime: string let fundingTx: { info: any; txHash: string } let depositKey: { fundingTxHash: string; fundingOutputIndex: number } + let depositKeyUint: BigNumber let depositExtraInfo: any before(async () => { @@ -1041,6 +1256,17 @@ describe("Bridge - Reservation", () => { state: walletState.Live, movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, }) + await bridge.setWallet(secondWalletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) // A refund locktime comfortably in the future (400 days), as 4-byte LE. futureRefundLocktime = `0x${toLE( @@ -1080,6 +1306,12 @@ describe("Bridge - Reservation", () => { }) depositKey = { fundingTxHash: fundingTx.txHash, fundingOutputIndex: 0 } + depositKeyUint = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) depositExtraInfo = { fundingTx: fundingTx.info, blindingFactor, @@ -1110,87 +1342,128 @@ describe("Bridge - Reservation", () => { ).to.be.revertedWith("Reserved deposits must not be swept") }) - it("validates a reservation anchor proposal", async () => { + it("validates a reservation anchor proposal against the authorization", async () => { + // No authorization yet: the proposal is invalid. + await expect( + validator.validateReservationAnchorProposal( + { walletPubKeyHash, depositKey, requestNonce: 1, anchorTxFee: 1500 }, + depositExtraInfo + ) + ).to.be.revertedWith("No pending action of the expected type") + + await bridge + .connect(thirdParty) + .requestReservationAcceptance(depositKeyUint, walletPubKeyHash) + expect( await validator.validateReservationAnchorProposal( - { walletPubKeyHash, depositKey, anchorTxFee: 1500 }, + { walletPubKeyHash, depositKey, requestNonce: 1, anchorTxFee: 1500 }, depositExtraInfo ) ).to.be.true await expect( validator.validateReservationAnchorProposal( - { walletPubKeyHash, depositKey, anchorTxFee: 0 }, + { walletPubKeyHash, depositKey, requestNonce: 1, anchorTxFee: 0 }, depositExtraInfo ) ).to.be.revertedWith("Proposed transaction fee cannot be zero") await expect( validator.validateReservationAnchorProposal( - { walletPubKeyHash, depositKey, anchorTxFee: 2001 }, + { walletPubKeyHash, depositKey, requestNonce: 1, anchorTxFee: 2001 }, depositExtraInfo ) ).to.be.revertedWith("Proposed transaction fee is too high") + + await expect( + validator.validateReservationAnchorProposal( + { + walletPubKeyHash: secondWalletPubKeyHash, + depositKey, + requestNonce: 1, + anchorTxFee: 1500, + }, + depositExtraInfo + ) + ).to.be.revertedWith("Acceptance authorized for different wallet") }) - it("validates reserved redemption, re-anchor and dissolution proposals", async () => { + it("validates reserved redemption and re-anchor proposals against their generations", async () => { const reservationKey = 12321 await bridge.setReservation( reservationKey, await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) ) - // Re-anchor: valid toward a Live target wallet. - await bridge.setWallet(secondWalletPubKeyHash, { - ecdsaWalletID: ethers.utils.randomBytes(32), - mainUtxoHash: ZERO_BYTES32, - pendingRedemptionsValue: 0, - createdAt: await lastBlockTime(), - movingFundsRequestedAt: 0, - closingStartedAt: 0, - pendingMovedFundsSweepRequestsCount: 0, - state: walletState.Live, - movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, - }) + // Re-anchor: requires an authorization. The source wallet is Live, + // so only the governance can authorize a rotation. + await expect( + validator.validateReservationReanchorProposal({ + sourceWalletPubKeyHash: walletPubKeyHash, + reservationKey, + requestNonce: 1, + targetWalletPubKeyHash: secondWalletPubKeyHash, + reanchorTxFee: 1500, + }) + ).to.be.revertedWith("No pending action of the expected type") + + await bridge + .connect(bridgeGovernanceSigner) + .requestReservationReanchor(reservationKey, secondWalletPubKeyHash) + expect( await validator.validateReservationReanchorProposal({ sourceWalletPubKeyHash: walletPubKeyHash, reservationKey, + requestNonce: 1, targetWalletPubKeyHash: secondWalletPubKeyHash, reanchorTxFee: 1500, }) ).to.be.true - // Dissolution: rejected before term + grace elapse. + await expect( + validator.validateReservationReanchorProposal({ + sourceWalletPubKeyHash: walletPubKeyHash, + reservationKey, + requestNonce: 1, + targetWalletPubKeyHash: walletPubKeyHash, + reanchorTxFee: 1500, + }) + ).to.be.revertedWith("Re-anchor authorized for different target wallet") + + // Dissolution: no pending authorization (term has not elapsed, so + // none can be requested). await expect( validator.validateReservationDissolutionProposal({ walletPubKeyHash, reservationKey, + requestNonce: 1, dissolutionTxFee: 1500, }) - ).to.be.revertedWith("Reservation term or grace period not elapsed") + ).to.be.revertedWith("No pending action of the expected type") - // Reserved redemption: request through the vault, wait past min age. - const bridgeSigner = await impersonateContract(bridge.address) - await bank - .connect(bridgeSigner) - .increaseBalance(reservationVault.address, amountSat) - const vaultSigner = await impersonateContract(reservationVault.address) - await bank.connect(vaultSigner).approveBalance(bridge.address, amountSat) - await bridge - .connect(vaultSigner) - .requestReservedRedemption( - reservationKey, - thirdParty.address, - "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" - ) + // Reserved redemption on a second reservation: request through the + // vault, wait past min age. + const redemptionReservationKey = 12322 + await bridge.setReservation( + redemptionReservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + await requestRedemptionViaVault( + redemptionReservationKey, + amountSat, + thirdParty.address, + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + ) await increaseTime(601) expect( await validator.validateReservedRedemptionProposal({ walletPubKeyHash, - reservationKey, + reservationKey: redemptionReservationKey, + requestNonce: 1, redemptionTxFee: 1500, }) ).to.be.true @@ -1198,7 +1471,8 @@ describe("Bridge - Reservation", () => { await expect( validator.validateReservedRedemptionProposal({ walletPubKeyHash: secondWalletPubKeyHash, - reservationKey, + reservationKey: redemptionReservationKey, + requestNonce: 1, redemptionTxFee: 1500, }) ).to.be.revertedWith("Reservation custodied by different wallet") @@ -1245,7 +1519,8 @@ describe("Bridge - Reservation", () => { }) } - // Reveals a fresh reserved deposit and proves its anchor transaction. + // Reveals a fresh reserved deposit, requests its acceptance and proves + // its anchor transaction (settling acceptance generation 1). async function makeAcceptedReservation(anchorValue?: BigNumber) { const fundingTx = buildTx( [ @@ -1279,6 +1554,17 @@ describe("Bridge - Reservation", () => { vault: reservationVault.address, }) + const reservationKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) + + await bridge + .connect(thirdParty) + .requestReservationAcceptance(reservationKey, walletPubKeyHash) + const anchorTx = buildTx( [{ txHash: fundingTx.txHash, index: 0 }], [ @@ -1296,15 +1582,9 @@ describe("Bridge - Reservation", () => { anchorTx.info, proofFor(anchorTx.txHash), NO_MAIN_UTXO_PARAM, - 0 - ) - - const reservationKey = BigNumber.from( - ethers.utils.solidityKeccak256( - ["bytes32", "uint32"], - [fundingTx.txHash, 0] + reservationKey, + 1 ) - ) return { fundingTx, anchorTx, acceptTx, reservationKey } } @@ -1337,6 +1617,62 @@ describe("Bridge - Reservation", () => { await restoreSnapshot() }) + it("requires an acceptance authorization before the anchor proof", async () => { + const fundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: depositAmount, + script: p2wshScript( + buildDepositScript( + thirdParty.address, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime + ) + ), + }, + ] + ) + await bridge.connect(thirdParty).revealDeposit(fundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime, + vault: reservationVault.address, + }) + const reservationKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) + const anchorTx = buildTx( + [{ txHash: fundingTx.txHash, index: 0 }], + [{ valueSat: anchorAmount, script: p2wpkhScript(walletPubKeyHash) }] + ) + + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Acceptance, + anchorTx.info, + proofFor(anchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 1 + ) + ).to.be.revertedWith("Action type mismatch") + }) + it("accepts a proven anchor and credits the gross amount", async () => { const { anchorTx, acceptTx, reservationKey } = await makeAcceptedReservation() @@ -1345,6 +1681,7 @@ describe("Bridge - Reservation", () => { .to.emit(bridge, "ReservationAccepted") .withArgs( reservationKey, + 1, walletPubKeyHash, thirdParty.address, anchorTx.txHash, @@ -1359,7 +1696,14 @@ describe("Bridge - Reservation", () => { expect(reservation.mintedAmount).to.equal(anchorAmount) expect(reservation.anchorAmount).to.equal(anchorAmount) expect(reservation.anchorTxHash).to.equal(anchorTx.txHash) - expect(reservation.state).to.equal(1) // Active + expect(reservation.state).to.equal(ReservationState.Active) + expect(reservation.termSeconds).to.equal(RESERVATION_TERM) + expect(reservation.gracePeriod).to.equal(RESERVATION_GRACE) + + // The capacity reserved at authorization released the miner-fee + // delta at settlement: the total tracks the anchor value. + const params = await bridge.reservationParameters() + expect(params.reservationTotalAmount).to.equal(anchorAmount) // Gross mint minus the 40 bps initiation fee. const fee = grossTbtc.mul(40).div(10000) @@ -1376,9 +1720,10 @@ describe("Bridge - Reservation", () => { anchorTx.info, proofFor(anchorTx.txHash), NO_MAIN_UTXO_PARAM, - 0 + reservationKey, + 1 ) - ).to.be.revertedWith("Deposit already swept") + ).to.be.revertedWith("Action is not settleable") }) it("rejects an anchor paying an excessive miner fee", async () => { @@ -1387,6 +1732,72 @@ describe("Bridge - Reservation", () => { ).to.be.revertedWith("Transaction fee is too high") }) + it("rejects an anchor paying a wallet other than the authorized one", async () => { + const fundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: depositAmount, + script: p2wshScript( + buildDepositScript( + thirdParty.address, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime + ) + ), + }, + ] + ) + await bridge.connect(thirdParty).revealDeposit(fundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime, + vault: reservationVault.address, + }) + const reservationKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) + await bridge + .connect(thirdParty) + .requestReservationAcceptance(reservationKey, walletPubKeyHash) + + await liveWallet(secondWalletPubKeyHash) + const anchorTx = buildTx( + [{ txHash: fundingTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount, + script: p2wpkhScript(secondWalletPubKeyHash), + }, + ] + ) + + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Acceptance, + anchorTx.info, + proofFor(anchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 1 + ) + ).to.be.revertedWith("Anchor output must pay the authorized wallet") + }) + it("completes an in-kind reserved redemption", async () => { // Two acceptances fund the owner with enough TBTC for the gross // surrender plus the redemption fee on the first reservation. @@ -1409,6 +1820,7 @@ describe("Bridge - Reservation", () => { await reservationVault .connect(thirdParty) .redeemReservation(reservationKey, redeemerScript) + const redemptionTx = buildTx( [{ txHash: anchorTx.txHash, index: 0 }], [ @@ -1426,30 +1838,35 @@ describe("Bridge - Reservation", () => { redemptionTx.info, proofFor(redemptionTx.txHash), NO_MAIN_UTXO_PARAM, - reservationKey + reservationKey, + 2 ) await expect(tx) .to.emit(bridge, "ReservedRedemptionCompleted") - .withArgs(reservationKey, redemptionTx.txHash) + .withArgs(reservationKey, 2, redemptionTx.txHash) - expect((await bridge.reservations(reservationKey)).state).to.equal(3) // Closed + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.Closed + ) + expect( + (await bridge.reservationActions(reservationKey, 2)).state + ).to.equal(ActionState.Settled) // The gross surrendered balance was burned. expect(await bank.balanceOf(bridge.address)).to.equal(0) expect(await tbtc.totalSupply()).to.equal(supplyBefore.sub(grossTbtc)) }) it("keeps redemption provable when txMaxFee exceeds the anchor (underflow guard)", async () => { - // Regression: redemptionTxMaxFee is a governable parameter, not a - // tx-derived value, so a governance increase can push it above an - // existing anchorAmount. The redemption range check must not underflow - // in that case. Fund the owner with two acceptances, then raise the - // fee/min parameters above the anchor and prove an in-kind redemption. + // Regression: the redemption fee bound is snapshotted at request + // time, but a snapshot can still exceed the anchor value. The + // redemption range check must not underflow in that case. Raise the + // fee/min parameters above the anchor BEFORE requesting so the + // snapshot captures them, then prove an in-kind redemption paying + // the full anchor value. const { anchorTx, reservationKey } = await makeAcceptedReservation() await makeAcceptedReservation() // funds the owner with extra TBTC - // Raise the reservation tx max fee above the anchor amount (and the - // minimum above the fee, preserving the invariant). await bridge .connect(bridgeGovernanceSigner) .updateReservationParameters( @@ -1459,7 +1876,8 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) const redeemerScript = `0x16${p2wpkhScript( @@ -1486,20 +1904,21 @@ describe("Bridge - Reservation", () => { redemptionTx.info, proofFor(redemptionTx.txHash), NO_MAIN_UTXO_PARAM, - reservationKey + reservationKey, + 2 ) await expect(tx) .to.emit(bridge, "ReservedRedemptionCompleted") - .withArgs(reservationKey, redemptionTx.txHash) + .withArgs(reservationKey, 2, redemptionTx.txHash) }) - it("allows a minimum-sized reservation to migrate (H-08 regression)", async () => { + it("re-anchors via a governance-authorized rotation (H-08 regression)", async () => { // A reservation whose anchor sits at the reservation minimum must // still be migratable with a positive fee. The earlier // `>= reservationMinAmount` re-anchor floor, combined with the // proposal validator's positive-fee requirement, left no compliant // re-anchor for an exactly-minimum anchor and would have pinned a - // retiring wallet. The dust floor (`> reservationTxMaxFee`) fixes it. + // retiring wallet. The dust floor (`> txMaxFee` snapshot) fixes it. const minAmount = BigNumber.from(RESERVATION_MIN_AMOUNT) const depositAmt = minAmount.add(RESERVATION_TX_MAX_FEE) const fundingTx = buildTx( @@ -1532,6 +1951,15 @@ describe("Bridge - Reservation", () => { refundLocktime, vault: reservationVault.address, }) + const reservationKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) + await bridge + .connect(thirdParty) + .requestReservationAcceptance(reservationKey, walletPubKeyHash) const anchorTx = buildTx( [{ txHash: fundingTx.txHash, index: 0 }], [{ valueSat: minAmount, script: p2wpkhScript(walletPubKeyHash) }] @@ -1543,17 +1971,23 @@ describe("Bridge - Reservation", () => { anchorTx.info, proofFor(anchorTx.txHash), NO_MAIN_UTXO_PARAM, - 0 - ) - const reservationKey = BigNumber.from( - ethers.utils.solidityKeccak256( - ["bytes32", "uint32"], - [fundingTx.txHash, 0] + reservationKey, + 1 ) - ) await liveWallet(secondWalletPubKeyHash) + // A third party cannot authorize a rotation away from a Live wallet. + await expect( + bridge + .connect(thirdParty) + .requestReservationReanchor(reservationKey, secondWalletPubKeyHash) + ).to.be.revertedWith("Only governance can rotate a Live wallet's anchor") + + await bridge + .connect(bridgeGovernanceSigner) + .requestReservationReanchor(reservationKey, secondWalletPubKeyHash) + // Migrate with a 1-sat fee: the anchor stays above the dust floor. const migrated = minAmount.sub(1) const reanchorTx = buildTx( @@ -1567,47 +2001,85 @@ describe("Bridge - Reservation", () => { reanchorTx.info, proofFor(reanchorTx.txHash), NO_MAIN_UTXO_PARAM, - reservationKey + reservationKey, + 2 ) await expect(tx) .to.emit(bridge, "ReservationReanchored") .withArgs( reservationKey, + 2, secondWalletPubKeyHash, reanchorTx.txHash, migrated ) + + const reservation = await bridge.reservations(reservationKey) + expect(reservation.walletPubKeyHash).to.equal(secondWalletPubKeyHash) + expect(reservation.state).to.equal(ReservationState.Active) }) - it("dissolves an expired reservation into the wallet main UTXO", async () => { + it("rejects a re-anchor paying a wallet other than the authorized target", async () => { const { anchorTx, reservationKey } = await makeAcceptedReservation() - const dissolutionFee = 500 - const dissolutionTx = buildTx( + await liveWallet(secondWalletPubKeyHash) + await bridge + .connect(bridgeGovernanceSigner) + .requestReservationReanchor(reservationKey, secondWalletPubKeyHash) + + // The transaction pays the source wallet instead of the target. + const reanchorTx = buildTx( [{ txHash: anchorTx.txHash, index: 0 }], [ { - valueSat: anchorAmount.sub(dissolutionFee), + valueSat: anchorAmount.sub(1000), script: p2wpkhScript(walletPubKeyHash), }, ] ) - - // Not dissolvable before term + grace. await expect( bridge .connect(spvMaintainer) .submitReservationProof( - ProofType.Dissolution, - dissolutionTx.info, - proofFor(dissolutionTx.txHash), + ProofType.Reanchor, + reanchorTx.info, + proofFor(reanchorTx.txHash), NO_MAIN_UTXO_PARAM, - reservationKey + reservationKey, + 2 ) + ).to.be.revertedWith("Output must pay the authorized target wallet") + }) + + it("dissolves an expired reservation into the wallet main UTXO", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + + // Not requestable before term + grace. + await expect( + bridge.connect(thirdParty).requestReservationDissolution(reservationKey) ).to.be.revertedWith("Reservation term or grace period not elapsed") await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + await bridge + .connect(thirdParty) + .requestReservationDissolution(reservationKey) + + expect(await bridge.walletPendingDissolution(walletPubKeyHash)).to.equal( + reservationKey + ) + + const dissolutionFee = 500 + const dissolutionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(dissolutionFee), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + const tx = await bridge .connect(spvMaintainer) .submitReservationProof( @@ -1615,12 +2087,21 @@ describe("Bridge - Reservation", () => { dissolutionTx.info, proofFor(dissolutionTx.txHash), NO_MAIN_UTXO_PARAM, - reservationKey + reservationKey, + 2 ) - await expect(tx).to.emit(bridge, "ReservationDissolved") + await expect(tx) + .to.emit(bridge, "ReservationDissolved") + .withArgs(reservationKey, 2, walletPubKeyHash, dissolutionTx.txHash) - expect((await bridge.reservations(reservationKey)).state).to.equal(3) // Closed + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.Closed + ) + // The lock is released. + expect(await bridge.walletPendingDissolution(walletPubKeyHash)).to.equal( + 0 + ) // The dissolution output became the wallet's new main UTXO. const wallet = await bridge.wallets(walletPubKeyHash) diff --git a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts new file mode 100644 index 000000000..8cc6a68b7 --- /dev/null +++ b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts @@ -0,0 +1,1198 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +// Adversarial settlement tests for the two-phase reservation state machine: +// the claim double-spend regression (timeout racing a confirmed redemption), +// the late-proof settlement matrix, the on-chain watchtower-delay +// enforcement, the per-wallet dissolution lock, and the +// capacity-reserved-before-signing guarantee. + +import { ethers, helpers, waffle } from "hardhat" +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" +import { BigNumber, Contract } from "ethers" +import chai, { expect } from "chai" +import { FakeContract, smock } from "@defi-wonderland/smock" +import type { + Bank, + BankStub, + Bridge, + BridgeStub, + IRelay, + ReservationRouter, + ReservationVault, + TBTCVault, + TBTC, +} from "../../typechain" +import bridgeFixture from "../fixtures/bridge" +import { walletState } from "../fixtures" + +chai.use(smock.matchers) + +const { createSnapshot, restoreSnapshot } = helpers.snapshot +const { lastBlockTime, increaseTime } = helpers.time + +const ZERO_BYTES32 = ethers.constants.HashZero + +const RESERVATION_TERM = 31536000 // 365 days +const RESERVATION_GRACE = 2592000 // 30 days +const RESERVATION_MIN_AMOUNT = 10000 +const RESERVATION_TX_MAX_FEE = 2000 +const RESERVATION_MAX_TOTAL = BigNumber.from("2100000000000000") +const MAX_RESERVATIONS_PER_WALLET = 10 +const RESERVATION_ACTION_TIMEOUT = 172800 // 48 hours + +const SATOSHI_MULTIPLIER = BigNumber.from(10).pow(10) + +const ReservationState = { + Unknown: 0, + Active: 1, + ActionPending: 2, + Closed: 3, + Stranded: 4, +} + +const ActionType = { + None: 0, + Acceptance: 1, + Redemption: 2, + Reanchor: 3, + Dissolution: 4, +} + +const ActionState = { + Unknown: 0, + Pending: 1, + Settled: 2, + TimedOut: 3, + Vetoed: 4, + Superseded: 5, +} + +const ProofType = { + Acceptance: 0, + Redemption: 1, + Reanchor: 2, + Dissolution: 3, +} + +describe("Bridge - Reservation settlement", () => { + let governance: SignerWithAddress + let spvMaintainer: SignerWithAddress + let thirdParty: SignerWithAddress + let deployer: SignerWithAddress + + let bank: Bank & BankStub + let relay: FakeContract + let bridge: Bridge & BridgeStub & ReservationRouter + let tbtc: TBTC & Contract + let tbtcVault: TBTCVault & Contract + let reservationVault: ReservationVault + let bridgeGovernanceSigner: SignerWithAddress + + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const secondWalletPubKeyHash = "0xafcdf88d15a0e0c2134dbbc9f6da24d0e26c8f21" + const blindingFactor = "0xf9f0c90d00039523" + const refundPubKeyHash = "0x28e081f285138ccbe389c1eb8985716230129f89" + const refundLocktime = "0x60bcea61" + const NO_MAIN_UTXO_PARAM = { + txHash: ZERO_BYTES32, + txOutputIndex: 0, + txOutputValue: 0, + } + + const depositAmount = BigNumber.from(3000000) + const anchorFee = 1500 + const anchorAmount = depositAmount.sub(anchorFee) + const grossTbtc = anchorAmount.mul(SATOSHI_MULTIPLIER) + + before(async () => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ + deployer, + governance, + spvMaintainer, + thirdParty, + bank, + relay, + bridge, + tbtc, + tbtcVault, + } = await waffle.loadFixture(bridgeFixture)) + + reservationVault = await helpers.contracts.getContract("ReservationVault") + bridgeGovernanceSigner = await impersonateContract( + await bridge.governance() + ) + + await bridge + .connect(bridgeGovernanceSigner) + .setVaultStatus(reservationVault.address, true) + await bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT + ) + + relay.getCurrentEpochDifficulty.returns(0) + relay.getPrevEpochDifficulty.returns(0) + + await bridge.setDepositDustThreshold(10000) + await bridge.setDepositTxMaxFee(2000) + await bridge.setDepositRevealAheadPeriod(0) + await liveWallet(walletPubKeyHash) + await bridge.setLiveWalletsCount(10) + + const tbtcOwner = await impersonateContract(await tbtc.owner()) + await tbtc.connect(tbtcOwner).transferOwnership(tbtcVault.address) + }) + + async function impersonateContract( + address: string + ): Promise { + await ethers.provider.send("hardhat_impersonateAccount", [address]) + await ethers.provider.send("hardhat_setBalance", [ + address, + "0x8AC7230489E80000", + ]) + return ethers.getSigner(address) + } + + async function liveWallet(pkh: string) { + await bridge.setWallet(pkh, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + } + + // ---- Bitcoin fixture crafting (regtest-style difficulty) ---- + + const REGTEST_BITS_LE = "ffff7f20" + const REGTEST_TARGET = BigNumber.from("0x7fffff").mul( + BigNumber.from(2).pow(8 * (0x20 - 3)) + ) + + const reverseHex = (hex: string): string => + hex.replace(/^0x/, "").match(/../g)!.reverse().join("") + + const hash256 = (hexData: string): string => + ethers.utils.sha256(ethers.utils.sha256(hexData)) + + const toLE = (value: number | BigNumber, byteLength: number): string => + reverseHex( + BigNumber.from(value) + .toHexString() + .slice(2) + .padStart(byteLength * 2, "0") + ) + + const compactSize = (n: number): string => { + if (n >= 0xfd) { + throw new Error("compactSize > 252 not supported in fixtures") + } + return n.toString(16).padStart(2, "0") + } + + function buildTx( + inputs: { txHash: string; index: number }[], + outputs: { valueSat: BigNumber | number; script: string }[] + ) { + const inputVector = `0x${compactSize(inputs.length)}${inputs + .map((i) => `${i.txHash.slice(2)}${toLE(i.index, 4)}00ffffffff`) + .join("")}` + const outputVector = `0x${compactSize(outputs.length)}${outputs + .map( + (o) => + `${toLE(BigNumber.from(o.valueSat), 8)}${compactSize( + o.script.length / 2 + )}${o.script}` + ) + .join("")}` + const info = { + version: "0x01000000", + inputVector, + outputVector, + locktime: "0x00000000", + } + const txHash = hash256( + `0x01000000${inputVector.slice(2)}${outputVector.slice(2)}00000000` + ) + return { info, txHash } + } + + function mineHeader(merkleRoot: string): string { + const prevBlock = ethers.utils + .hexlify(ethers.utils.randomBytes(32)) + .slice(2) + const base = `20000000${prevBlock}${merkleRoot.slice( + 2 + )}662a2c68${REGTEST_BITS_LE}` + for (let nonce = 0; ; nonce++) { + const header = `0x${base}${toLE(nonce, 4)}` + if ( + BigNumber.from(`0x${reverseHex(hash256(header))}`).lte(REGTEST_TARGET) + ) { + return header + } + } + } + + function proofFor(txHash: string) { + const coinbasePreimage = ethers.utils.sha256(ethers.utils.randomBytes(32)) + const coinbaseTxId = ethers.utils.sha256(coinbasePreimage) + const merkleRoot = hash256(`0x${coinbaseTxId.slice(2)}${txHash.slice(2)}`) + return { + merkleProof: coinbaseTxId, + txIndexInBlock: 1, + bitcoinHeaders: mineHeader(merkleRoot), + coinbasePreimage, + coinbaseProof: txHash, + } + } + + const buildDepositScript = ( + depositor: string, + blinding: string, + walletPkh: string, + refundPkh: string, + locktime: string + ): string => + `14${depositor.slice(2)}7508${blinding.slice(2)}7576a914${walletPkh + .slice(2) + .toLowerCase()}8763ac6776a914${refundPkh.slice(2)}8804${locktime.slice( + 2 + )}b175ac68` + + const p2wshScript = (script: string): string => + `0020${ethers.utils.sha256(`0x${script}`).slice(2)}` + + const p2wpkhScript = (pkh: string): string => `0014${pkh.slice(2)}` + + const randomRedeemerScript = (): string => + `0x16${p2wpkhScript(ethers.utils.hexlify(ethers.utils.randomBytes(20)))}` + + // Reveals a fresh reserved deposit, requests acceptance (generation 1) + // and proves the anchor. + async function makeAcceptedReservation(custodian = walletPubKeyHash) { + const fundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: depositAmount, + script: p2wshScript( + buildDepositScript( + thirdParty.address, + blindingFactor, + custodian, + refundPubKeyHash, + refundLocktime + ) + ), + }, + ] + ) + + await bridge.connect(thirdParty).revealDeposit(fundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash: custodian, + refundPubKeyHash, + refundLocktime, + vault: reservationVault.address, + }) + + const reservationKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) + + await bridge + .connect(thirdParty) + .requestReservationAcceptance(reservationKey, custodian) + + const anchorTx = buildTx( + [{ txHash: fundingTx.txHash, index: 0 }], + [{ valueSat: anchorAmount, script: p2wpkhScript(custodian) }] + ) + + await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Acceptance, + anchorTx.info, + proofFor(anchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 1 + ) + + return { fundingTx, anchorTx, reservationKey } + } + + // Requests an in-kind redemption of the given accepted reservation via + // the real vault flow (fee paid in TBTC). + async function requestRedemption( + reservationKey: BigNumber, + redeemerScript: string + ) { + const redemptionFee = grossTbtc.mul(20).div(10000) + await tbtc + .connect(thirdParty) + .approve(reservationVault.address, grossTbtc.add(redemptionFee)) + await reservationVault + .connect(thirdParty) + .redeemReservation(reservationKey, redeemerScript) + } + + // Retries via the Bank-balance path after a timeout refund. + async function retryRedemption( + reservationKey: BigNumber, + redeemerScript: string + ) { + await bank + .connect(thirdParty) + .approveBalance(reservationVault.address, anchorAmount) + await reservationVault + .connect(thirdParty) + .retryRedeemReservation(reservationKey, redeemerScript) + } + + describe("claim double-spend regression (timeout racing a confirmed redemption)", () => { + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) + + it("settles the late proof without a second refund and defeats fraud exposure", async () => { + // Two acceptances: the second funds the owner with the TBTC needed + // for the gross surrender plus fee on the first. + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() + + const redeemerScript = randomRedeemerScript() + await requestRedemption(reservationKey, redeemerScript) + + // The wallet signs and broadcasts; the transaction confirms on + // Bitcoin (crafted below) but its proof does not reach the Bridge + // before the timeout. + const redemptionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(1000), + script: redeemerScript.slice(4), + }, + ] + ) + + const { redemptionTimeout } = await bridge.redemptionParameters() + await increaseTime(redemptionTimeout + 1) + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + + // The timeout refunded the escrowed claim to the redeemer. + const redeemerBankBalance = await bank.balanceOf(thirdParty.address) + expect(redeemerBankBalance).to.equal(anchorAmount) + const bridgeBankBalance = await bank.balanceOf(bridge.address) + + // The late proof settles against the terminal TimedOut record. + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + + await expect(tx) + .to.emit(bridge, "ReservationLateSettled") + .withArgs(reservationKey, 2, ActionType.Redemption) + await expect(tx) + .to.emit(bridge, "ReservedRedemptionCompleted") + .withArgs(reservationKey, 2, redemptionTx.txHash) + + // No second refund and no burn: the Bank moved nothing during the + // late settlement. + expect(await bank.balanceOf(thirdParty.address)).to.equal( + redeemerBankBalance + ) + expect(await bank.balanceOf(bridge.address)).to.equal(bridgeBankBalance) + + // The lineage is closed and the consumed anchor is registered as + // honestly spent, so the wallet's signature defeats any fraud + // challenge. + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.Closed + ) + expect( + await bridge.spentMainUTXOs( + BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [anchorTx.txHash, 0] + ) + ) + ) + ).to.be.true + + // The settled generation cannot be settled twice. + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + ).to.be.revertedWith("Action is not settleable") + }) + }) + + describe("late proof against a position with a newer pending generation", () => { + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("forces settlement against a matching pending generation", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() + + const redeemerScript = randomRedeemerScript() + await requestRedemption(reservationKey, redeemerScript) + + const redemptionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(1000), + script: redeemerScript.slice(4), + }, + ] + ) + + const { redemptionTimeout } = await bridge.redemptionParameters() + await increaseTime(redemptionTimeout + 1) + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + + // The owner retries with the SAME redeemer script (generation 3). + await retryRedemption(reservationKey, redeemerScript) + + // The transaction satisfies both generation 2 (timed out) and + // generation 3 (pending). The late-settlement path must refuse it: + // settling the pending generation burns the escrow, which is the + // correct accounting. + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + ).to.be.revertedWith("Must settle the pending generation") + + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 3 + ) + await expect(tx) + .to.emit(bridge, "ReservedRedemptionCompleted") + .withArgs(reservationKey, 3, redemptionTx.txHash) + + // The pending settlement burned the escrow. + expect(await bank.balanceOf(bridge.address)).to.equal(0) + }) + + it("unwinds a non-matching pending generation and refunds its escrow", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() + + const redeemerScript = randomRedeemerScript() + await requestRedemption(reservationKey, redeemerScript) + + // Generation 2's transaction confirms on Bitcoin. + const redemptionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(1000), + script: redeemerScript.slice(4), + }, + ] + ) + + const { redemptionTimeout } = await bridge.redemptionParameters() + await increaseTime(redemptionTimeout + 1) + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + + // The owner retries toward a DIFFERENT redeemer script + // (generation 3) -- unaware the old transaction confirmed. + const otherScript = randomRedeemerScript() + await retryRedemption(reservationKey, otherScript) + expect(await bank.balanceOf(bridge.address)).to.equal(anchorAmount) + + // The late proof of generation 2 settles; generation 3 can never + // settle (its anchor is gone), so it is unwound and its escrow + // refunded. + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + + await expect(tx) + .to.emit(bridge, "ReservationActionSuperseded") + .withArgs(reservationKey, 3) + await expect(tx) + .to.emit(bridge, "ReservationLateSettled") + .withArgs(reservationKey, 2, ActionType.Redemption) + + expect( + (await bridge.reservationActions(reservationKey, 3)).state + ).to.equal(ActionState.Superseded) + // Generation 3's escrow returned to its redeemer. (The generation 2 + // timeout refund was re-spent on the retry, so the redeemer's net + // Bank balance is exactly the unwound escrow.) + expect(await bank.balanceOf(bridge.address)).to.equal(0) + expect(await bank.balanceOf(thirdParty.address)).to.equal(anchorAmount) + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.Closed + ) + }) + }) + + describe("watchtower authorization enforcement in the proof path", () => { + let redemptionWatchtower: Contract + + before(async () => { + await createSnapshot() + + redemptionWatchtower = await helpers.contracts.getContract( + "RedemptionWatchtower" + ) + const watchtowerOwner = await impersonateContract( + await redemptionWatchtower.owner() + ) + const guardians = (await ethers.getSigners()).slice(10, 13) + await redemptionWatchtower.connect(watchtowerOwner).enableWatchtower( + governance.address, + guardians.map((g) => g.address) + ) + await bridge + .connect(bridgeGovernanceSigner) + .setRedemptionWatchtower(redemptionWatchtower.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("rejects a proof before the delay elapses and accepts it afterwards", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() + + const redeemerScript = randomRedeemerScript() + await requestRedemption(reservationKey, redeemerScript) + + // A Byzantine wallet signs and broadcasts immediately; the + // transaction confirms on Bitcoin. The Bridge must not let it + // finalize before the guardians' window closes. + const redemptionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(1000), + script: redeemerScript.slice(4), + }, + ] + ) + + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + ).to.be.revertedWith("Watchtower delay has not elapsed") + + // Default delay is 2 hours. + await increaseTime(7300) + + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + await expect(tx) + .to.emit(bridge, "ReservedRedemptionCompleted") + .withArgs(reservationKey, 2, redemptionTx.txHash) + }) + + it("never settles a vetoed generation", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() + + const redeemerScript = randomRedeemerScript() + await requestRedemption(reservationKey, redeemerScript) + + const redemptionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(1000), + script: redeemerScript.slice(4), + }, + ] + ) + + const guardians = (await ethers.getSigners()).slice(10, 13) + await redemptionWatchtower + .connect(guardians[0]) + .raiseReservedObjection(reservationKey, 2) + await redemptionWatchtower + .connect(guardians[1]) + .raiseReservedObjection(reservationKey, 2) + await redemptionWatchtower + .connect(guardians[2]) + .raiseReservedObjection(reservationKey, 2) + + expect( + (await bridge.reservationActions(reservationKey, 2)).state + ).to.equal(ActionState.Vetoed) + + // The early-signed transaction is unprovable forever: the signature + // stays exposed to the fraud machinery, which is the intended + // consequence of signing before authorization. + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + ).to.be.revertedWith("Action is not settleable") + }) + }) + + describe("per-wallet dissolution lock (concurrent dissolutions)", () => { + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("serializes no-main-UTXO dissolutions of the same wallet", async () => { + const first = await makeAcceptedReservation() + const second = await makeAcceptedReservation() + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + + await bridge + .connect(thirdParty) + .requestReservationDissolution(first.reservationKey) + + // The second dissolution of the same wallet is locked out while the + // first is in flight. + await expect( + bridge + .connect(thirdParty) + .requestReservationDissolution(second.reservationKey) + ).to.be.revertedWith("Another dissolution is pending for the wallet") + + // Settle the first dissolution; its output becomes the main UTXO. + const dissolutionFee = 500 + const firstTx = buildTx( + [{ txHash: first.anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(dissolutionFee), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Dissolution, + firstTx.info, + proofFor(firstTx.txHash), + NO_MAIN_UTXO_PARAM, + first.reservationKey, + 2 + ) + + // The lock released; the second dissolution can now be requested, + // and its snapshot records the new main UTXO -- the second + // transaction must spend it (2-in-1-out). + await bridge + .connect(thirdParty) + .requestReservationDissolution(second.reservationKey) + + const mainUtxo = { + txHash: firstTx.txHash, + txOutputIndex: 0, + txOutputValue: anchorAmount.sub(dissolutionFee), + } + const secondTx = buildTx( + [ + { txHash: second.anchorTx.txHash, index: 0 }, + { txHash: firstTx.txHash, index: 0 }, + ], + [ + { + valueSat: anchorAmount + .sub(dissolutionFee) + .add(anchorAmount) + .sub(dissolutionFee), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Dissolution, + secondTx.info, + proofFor(secondTx.txHash), + mainUtxo, + second.reservationKey, + 2 + ) + await expect(tx) + .to.emit(bridge, "ReservationDissolved") + .withArgs(second.reservationKey, 2, walletPubKeyHash, secondTx.txHash) + }) + + it("re-registers a drifted dissolution output for the sweep machinery", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + await bridge + .connect(thirdParty) + .requestReservationDissolution(reservationKey) + + // The authorized 1-input dissolution confirms on Bitcoin, but before + // its proof lands a deposit sweep registers a new main UTXO. + const dissolutionFee = 500 + const dissolutionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(dissolutionFee), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + + const sweepUtxo = { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + txOutputIndex: 0, + txOutputValue: 7777777, + } + await bridge.setWalletMainUtxo(walletPubKeyHash, sweepUtxo) + const driftedMainUtxoHash = (await bridge.wallets(walletPubKeyHash)) + .mainUtxoHash + + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Dissolution, + dissolutionTx.info, + proofFor(dissolutionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + await expect(tx).to.emit(bridge, "ReservationDissolved") + + // The wallet's main UTXO is untouched; the dissolution output is + // registered as a moved-funds sweep request instead. + expect((await bridge.wallets(walletPubKeyHash)).mainUtxoHash).to.equal( + driftedMainUtxoHash + ) + const sweepRequest = await bridge.movedFundsSweepRequests( + BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [dissolutionTx.txHash, 0] + ) + ) + ) + expect(sweepRequest.walletPubKeyHash).to.equal(walletPubKeyHash) + expect(sweepRequest.value).to.equal(anchorAmount.sub(dissolutionFee)) + }) + }) + + describe("capacity reserved before signing (fill-then-prove)", () => { + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) + + it("settles an authorized acceptance even after the caps fill up", async () => { + // Authorize an acceptance while capacity is available. + const fundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: depositAmount, + script: p2wshScript( + buildDepositScript( + thirdParty.address, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime + ) + ), + }, + ] + ) + await bridge.connect(thirdParty).revealDeposit(fundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime, + vault: reservationVault.address, + }) + const reservationKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) + await bridge + .connect(thirdParty) + .requestReservationAcceptance(reservationKey, walletPubKeyHash) + + // The caps fill up after the authorization (a governance tightening + // to the current usage level models any competing fill). + const params = await bridge.reservationParameters() + await bridge.connect(bridgeGovernanceSigner).updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + params.reservationTotalAmount, // no room beyond what is reserved + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT + ) + + // A new authorization cannot be created any more... + const otherFundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: depositAmount, + script: p2wshScript( + buildDepositScript( + thirdParty.address, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime + ) + ), + }, + ] + ) + await bridge.connect(thirdParty).revealDeposit(otherFundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime, + vault: reservationVault.address, + }) + await expect( + bridge + .connect(thirdParty) + .requestReservationAcceptance( + BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [otherFundingTx.txHash, 0] + ) + ), + walletPubKeyHash + ) + ).to.be.revertedWith("Total reserved amount cap exceeded") + + // ...but the already-authorized (and, on Bitcoin, already signed) + // anchor still settles: capacity was reserved before signing. + const anchorTx = buildTx( + [{ txHash: fundingTx.txHash, index: 0 }], + [{ valueSat: anchorAmount, script: p2wpkhScript(walletPubKeyHash) }] + ) + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Acceptance, + anchorTx.info, + proofFor(anchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 1 + ) + await expect(tx).to.emit(bridge, "ReservationAccepted") + }) + }) + + describe("acceptance authorization timeout", () => { + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) + + it("releases capacity, allows re-authorization, and still settles the late anchor", async () => { + const fundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: depositAmount, + script: p2wshScript( + buildDepositScript( + thirdParty.address, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime + ) + ), + }, + ] + ) + await bridge.connect(thirdParty).revealDeposit(fundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime, + vault: reservationVault.address, + }) + const reservationKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) + await bridge + .connect(thirdParty) + .requestReservationAcceptance(reservationKey, walletPubKeyHash) + + const totalBefore = (await bridge.reservationParameters()) + .reservationTotalAmount + + await increaseTime(RESERVATION_ACTION_TIMEOUT + 1) + const timeoutTx = await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + await expect(timeoutTx) + .to.emit(bridge, "ReservationActionTimedOut") + .withArgs(reservationKey, 1, ActionType.Acceptance) + + // The reserved capacity was released. + expect( + (await bridge.reservationParameters()).reservationTotalAmount + ).to.equal(totalBefore.sub(depositAmount)) + + // The anchor -- signed while generation 1 was pending -- confirmed + // on Bitcoin anyway. Its late proof still settles and re-takes the + // capacity. + const anchorTx = buildTx( + [{ txHash: fundingTx.txHash, index: 0 }], + [{ valueSat: anchorAmount, script: p2wpkhScript(walletPubKeyHash) }] + ) + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Acceptance, + anchorTx.info, + proofFor(anchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 1 + ) + await expect(tx) + .to.emit(bridge, "ReservationLateSettled") + .withArgs(reservationKey, 1, ActionType.Acceptance) + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.Active + ) + }) + }) + + describe("reserved redemption gating", () => { + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("rejects requests after the grace period (stranding bound)", async () => { + const { reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + + const redemptionFee = grossTbtc.mul(20).div(10000) + await tbtc + .connect(thirdParty) + .approve(reservationVault.address, grossTbtc.add(redemptionFee)) + await expect( + reservationVault + .connect(thirdParty) + .redeemReservation(reservationKey, randomRedeemerScript()) + ).to.be.revertedWith("Reservation past grace period") + }) + + it("rejects requests while the wallet is not Live or MovingFunds", async () => { + const { reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() + + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Terminated, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + + const redemptionFee = grossTbtc.mul(20).div(10000) + await tbtc + .connect(thirdParty) + .approve(reservationVault.address, grossTbtc.add(redemptionFee)) + await expect( + reservationVault + .connect(thirdParty) + .redeemReservation(reservationKey, randomRedeemerScript()) + ).to.be.revertedWith("Wallet must be in Live or MovingFunds state") + }) + }) + + describe("wallet lifecycle integration", () => { + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) + + it("keeps a reservation-holding wallet out of the Closing state", async () => { + await makeAcceptedReservation() + + // A redemption-timeout-style transition of the (main-UTXO-less) + // wallet must land in MovingFunds, not Closing: the wallet still + // custodies an anchor. + const secondReservation = await makeAcceptedReservation() + const redeemerScript = randomRedeemerScript() + await requestRedemption(secondReservation.reservationKey, redeemerScript) + const { redemptionTimeout } = await bridge.redemptionParameters() + await increaseTime(redemptionTimeout + 1) + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(secondReservation.reservationKey, []) + + expect((await bridge.wallets(walletPubKeyHash)).state).to.equal( + walletState.MovingFunds + ) + }) + }) +}) diff --git a/solidity/test/bridge/ReservationRouter.test.ts b/solidity/test/bridge/ReservationRouter.test.ts index c675463d0..1f770d7e0 100644 --- a/solidity/test/bridge/ReservationRouter.test.ts +++ b/solidity/test/bridge/ReservationRouter.test.ts @@ -341,7 +341,8 @@ describe("ReservationRouter", () => { 31536000, 2592000, 100000000000, - 10 + 10, + 172800 ) ).to.be.revertedWith("Caller is not the governance") @@ -352,12 +353,27 @@ describe("ReservationRouter", () => { await expect( standaloneRouter .connect(thirdParty) - .requestReservedRedemption(1, thirdParty.address, "0x1600144b47c798") + .requestReservedRedemption( + 1, + thirdParty.address, + "0x1600144b47c798", + true, + false + ) ).to.be.revertedWith("Caller is not the reservation vault") await expect( - standaloneRouter.connect(thirdParty).notifyReservedRedemptionVeto(1) + standaloneRouter.connect(thirdParty).notifyReservedRedemptionVeto(1, 1) ).to.be.revertedWith("Caller is not the redemption watchtower") + + await expect( + standaloneRouter + .connect(thirdParty) + .requestReservationAcceptance( + 1, + "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + ) + ).to.be.revertedWith("Reservations are disabled") }) }) }) diff --git a/solidity/test/bridge/WalletProposalValidator.test.ts b/solidity/test/bridge/WalletProposalValidator.test.ts index 697f72921..90290b3c2 100644 --- a/solidity/test/bridge/WalletProposalValidator.test.ts +++ b/solidity/test/bridge/WalletProposalValidator.test.ts @@ -1,5 +1,5 @@ import crypto from "crypto" -import { ethers, helpers } from "hardhat" +import { artifacts, ethers, helpers } from "hardhat" import chai, { expect } from "chai" import { FakeContract, smock } from "@defi-wonderland/smock" import { BigNumber, BigNumberish, BytesLike } from "ethers" @@ -41,7 +41,27 @@ describe("WalletProposalValidator", () => { before(async () => { const { deployer } = await helpers.signers.getNamedSigners() - bridge = await smock.fake("Bridge") + // The validator consults the UTXO-reservation surface, which lives in + // the ReservationRouter and is reached through the Bridge's fallback at + // the Bridge address. Build the fake from the merged ABI so those calls + // return zeroed defaults (reservations disabled) instead of reverting. + const bridgeAbi = (await artifacts.readArtifact("Bridge")).abi + const routerAbi = (await artifacts.readArtifact("ReservationRouter")).abi + const bridgeInterface = new ethers.utils.Interface(bridgeAbi) + const bridgeSignatures = new Set( + bridgeInterface.fragments + .filter((f) => f.type === "function" || f.type === "event") + .map((f) => f.format()) + ) + const routerExtras = new ethers.utils.Interface(routerAbi).fragments.filter( + (f) => + (f.type === "function" || f.type === "event") && + !bridgeSignatures.has(f.format()) + ) + bridge = await smock.fake([ + ...bridgeInterface.fragments, + ...routerExtras, + ]) const WalletProposalValidator = await ethers.getContractFactory( "WalletProposalValidator" diff --git a/solidity/test/deploy/85_deploy_tip109_governance_upgrade.test.ts b/solidity/test/deploy/85_deploy_tip109_governance_upgrade.test.ts index b8839bfce..8f82e9610 100644 --- a/solidity/test/deploy/85_deploy_tip109_governance_upgrade.test.ts +++ b/solidity/test/deploy/85_deploy_tip109_governance_upgrade.test.ts @@ -260,7 +260,7 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { expect(directBridgeCall).to.be.undefined }) - it("should define all 7 required libraries for Bridge implementation deployment", async () => { + it("should define all 6 required libraries for Bridge implementation deployment", async () => { await func(mockHre) const bridgeCall = deployCalls.find( @@ -278,10 +278,9 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { "Wallets", "Fraud", "MovingFunds", - "Reservation", ] const actualKeys = Object.keys(libraries) - expect(actualKeys).to.have.lengthOf(7) + expect(actualKeys).to.have.lengthOf(6) expectedLibKeys.forEach((key) => { expect(libraries).to.have.property(key) @@ -295,7 +294,6 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { expect(libraries.Wallets).to.equal(WALLETS_ADDRESS) expect(libraries.Fraud).to.equal(FRAUD_ADDRESS) expect(libraries.MovingFunds).to.equal(MOVING_FUNDS_ADDRESS) - expect(libraries.Reservation).to.equal(RESERVATION_ADDRESS) }) it("should deploy RebateStaking implementation with distinct artifact name", async () => { @@ -777,11 +775,11 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { }) }) - it("should have libraries with all 7 entries", () => { + it("should have libraries with all 6 entries", () => { expect(summary).to.not.be.null const libs = summary.libraries - expect(Object.keys(libs)).to.have.lengthOf(7) + expect(Object.keys(libs)).to.have.lengthOf(6) const requiredKeys = [ "Deposit", @@ -790,7 +788,6 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { "Wallets", "Fraud", "MovingFunds", - "Reservation", ] requiredKeys.forEach((key) => { From 6b39a00d134809479db4666f3d7fa61e454a3017 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 8 Aug 2026 10:45:13 -0400 Subject: [PATCH 04/14] fix(ci): slither 0.9.0 compatibility and lint errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI slither (0.9.0) crashes with an internal AssertionError while generating slithir for qualified cross-library event emissions (emit Reservation.X(...) from ReservationProofs) — reproduced locally against the pinned version. Declare the settlement-side events in the library that emits them instead; the router keeps re-declaring the full set for the ABI, so nothing changes for off-chain consumers. Annotate the new reentrancy-events findings (all behind trusted-Bank refunds in the late-settlement unwind path) and the deliberate enabled-timestamp strict-equality check, matching the repository's existing conventions. Also make the upgrades-core validation shim loop-free (the repo's eslint config forbids for-of) and fix a template-literal-without- interpolation lint error in the router test. --- .../contracts/bridge/RedemptionWatchtower.sol | 1 + solidity/contracts/bridge/Reservation.sol | 42 ----------- .../contracts/bridge/ReservationProofs.sol | 70 +++++++++++++++---- solidity/hardhat.config.ts | 48 +++++++------ .../test/bridge/ReservationRouter.test.ts | 2 +- 5 files changed, 86 insertions(+), 77 deletions(-) diff --git a/solidity/contracts/bridge/RedemptionWatchtower.sol b/solidity/contracts/bridge/RedemptionWatchtower.sol index ee13f497a..a27a50d88 100644 --- a/solidity/contracts/bridge/RedemptionWatchtower.sol +++ b/solidity/contracts/bridge/RedemptionWatchtower.sol @@ -529,6 +529,7 @@ contract RedemptionWatchtower is OwnableUpgradeable { uint256 reservationKey, uint64 requestNonce ) external view returns (uint32) { + // slither-disable-next-line incorrect-equality if (watchtowerEnabledAt == 0) { return 0; } diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index e6e556b7b..5cd226bf6 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -230,16 +230,6 @@ library Reservation { uint32 timeoutAt ); - event ReservationAccepted( - uint256 indexed reservationKey, - uint64 requestNonce, - bytes20 indexed walletPubKeyHash, - address indexed owner, - bytes32 anchorTxHash, - uint64 anchorAmount, - uint32 expiresAt - ); - event ReservationExtended( uint256 indexed reservationKey, uint32 newExpiresAt @@ -255,12 +245,6 @@ library Reservation { bool feePaid ); - event ReservedRedemptionCompleted( - uint256 indexed reservationKey, - uint64 requestNonce, - bytes32 redemptionTxHash - ); - event ReservationReanchorRequested( uint256 indexed reservationKey, uint64 requestNonce, @@ -269,14 +253,6 @@ library Reservation { uint64 txMaxFee ); - event ReservationReanchored( - uint256 indexed reservationKey, - uint64 requestNonce, - bytes20 indexed newWalletPubKeyHash, - bytes32 newAnchorTxHash, - uint64 newAnchorAmount - ); - event ReservationDissolutionRequested( uint256 indexed reservationKey, uint64 requestNonce, @@ -285,13 +261,6 @@ library Reservation { bytes32 expectedMainUtxoHash ); - event ReservationDissolved( - uint256 indexed reservationKey, - uint64 requestNonce, - bytes20 indexed walletPubKeyHash, - bytes32 dissolutionTxHash - ); - event ReservationActionTimedOut( uint256 indexed reservationKey, uint64 requestNonce, @@ -303,17 +272,6 @@ library Reservation { uint64 requestNonce ); - event ReservationActionSuperseded( - uint256 indexed reservationKey, - uint64 requestNonce - ); - - event ReservationLateSettled( - uint256 indexed reservationKey, - uint64 requestNonce, - ActionType actionType - ); - event ReservationRetryCreditMinted(uint256 indexed reservationKey); event ReservationParametersUpdated( diff --git a/solidity/contracts/bridge/ReservationProofs.sol b/solidity/contracts/bridge/ReservationProofs.sol index 98dc4a263..967fd4b40 100644 --- a/solidity/contracts/bridge/ReservationProofs.sol +++ b/solidity/contracts/bridge/ReservationProofs.sol @@ -67,6 +67,48 @@ library ReservationProofs { using BTCUtils for bytes; using BytesLib for bytes; + event ReservationAccepted( + uint256 indexed reservationKey, + uint64 requestNonce, + bytes20 indexed walletPubKeyHash, + address indexed owner, + bytes32 anchorTxHash, + uint64 anchorAmount, + uint32 expiresAt + ); + + event ReservedRedemptionCompleted( + uint256 indexed reservationKey, + uint64 requestNonce, + bytes32 redemptionTxHash + ); + + event ReservationReanchored( + uint256 indexed reservationKey, + uint64 requestNonce, + bytes20 indexed newWalletPubKeyHash, + bytes32 newAnchorTxHash, + uint64 newAnchorAmount + ); + + event ReservationDissolved( + uint256 indexed reservationKey, + uint64 requestNonce, + bytes20 indexed walletPubKeyHash, + bytes32 dissolutionTxHash + ); + + event ReservationActionSuperseded( + uint256 indexed reservationKey, + uint64 requestNonce + ); + + event ReservationLateSettled( + uint256 indexed reservationKey, + uint64 requestNonce, + Reservation.ActionType actionType + ); + /// @notice Represents the type of a reservation lifecycle SPV proof. /// Numbering matches `Reservation.ActionType` minus `None`. enum ProofType { @@ -300,7 +342,7 @@ library ReservationProofs { // already confirmed on Bitcoin. self.reservationTotalAmount += anchorAmount; self.walletReservationsCount[action.targetWalletPubKeyHash] += 1; - emit Reservation.ReservationLateSettled( + emit ReservationLateSettled( reservationKey, requestNonce, Reservation.ActionType.Acceptance @@ -339,7 +381,7 @@ library ReservationProofs { ] = reservationKey; // slither-disable-next-line reentrancy-events - emit Reservation.ReservationAccepted( + emit ReservationAccepted( reservationKey, requestNonce, action.targetWalletPubKeyHash, @@ -471,7 +513,7 @@ library ReservationProofs { outputValue ); - emit Reservation.ReservationLateSettled( + emit ReservationLateSettled( reservationKey, requestNonce, Reservation.ActionType.Redemption @@ -485,7 +527,7 @@ library ReservationProofs { self.closeReservation(reservation); // slither-disable-next-line reentrancy-events - emit Reservation.ReservedRedemptionCompleted( + emit ReservedRedemptionCompleted( reservationKey, requestNonce, redemptionTxHash @@ -583,7 +625,8 @@ library ReservationProofs { unwindPendingAction(self, reservation, reservationKey); } - emit Reservation.ReservationLateSettled( + // slither-disable-next-line reentrancy-events + emit ReservationLateSettled( reservationKey, requestNonce, Reservation.ActionType.Reanchor @@ -610,7 +653,8 @@ library ReservationProofs { uint256(keccak256(abi.encodePacked(reanchorTxHash, uint32(0)))) ] = reservationKey; - emit Reservation.ReservationReanchored( + // slither-disable-next-line reentrancy-events + emit ReservationReanchored( reservationKey, requestNonce, newWalletPubKeyHash, @@ -787,7 +831,8 @@ library ReservationProofs { ) { unwindPendingAction(self, reservation, reservationKey); } - emit Reservation.ReservationLateSettled( + // slither-disable-next-line reentrancy-events + emit ReservationLateSettled( reservationKey, requestNonce, Reservation.ActionType.Dissolution @@ -807,7 +852,8 @@ library ReservationProofs { action.state = Reservation.ActionState.Settled; self.closeReservation(reservation); - emit Reservation.ReservationDissolved( + // slither-disable-next-line reentrancy-events + emit ReservationDissolved( reservationKey, requestNonce, walletPubKeyHash, @@ -901,10 +947,10 @@ library ReservationProofs { } } - emit Reservation.ReservationActionSuperseded( - reservationKey, - pendingNonce - ); + // The Bank is a trusted protocol contract; the refund above cannot + // reenter in a way that makes this event misleading. + // slither-disable-next-line reentrancy-events + emit ReservationActionSuperseded(reservationKey, pendingNonce); } /// @notice Parses the given output vector and returns its single output. diff --git a/solidity/hardhat.config.ts b/solidity/hardhat.config.ts index cb724f0f5..90c2cd599 100644 --- a/solidity/hardhat.config.ts +++ b/solidity/hardhat.config.ts @@ -47,29 +47,33 @@ loadEnv({ path: path.join(__dirname, "..", ".env") }) // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any queryModule.getUnlinkedBytecode = (data: any, bytecode: string) => { const dataV3 = normalizeValidationData(data) - for (const validation of dataV3.log) { - const linkableContracts = Object.keys(validation).filter( - (name) => validation[name].linkReferences.length > 0 - ) - for (const name of linkableContracts) { - try { - const unlinkedBytecode = unlinkBytecode( - bytecode, - validation[name].linkReferences - ) - const version = getVersion(unlinkedBytecode) - if ( - validation[name].version?.withMetadata === version.withMetadata - ) { - return unlinkedBytecode + let result = bytecode + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dataV3.log.some((validation: any) => + Object.keys(validation) + .filter((name) => validation[name].linkReferences.length > 0) + .some((name) => { + try { + const unlinkedBytecode = unlinkBytecode( + bytecode, + validation[name].linkReferences + ) + const version = getVersion(unlinkedBytecode) + if ( + validation[name].version?.withMetadata === version.withMetadata + ) { + result = unlinkedBytecode + return true + } + } catch { + // A foreign contract's link references mangled this bytecode; + // it cannot be the contract being deployed — skip the + // candidate. } - } catch { - // A foreign contract's link references mangled this bytecode; - // it cannot be the contract being deployed — skip the candidate. - } - } - } - return bytecode + return false + }) + ) + return result } } diff --git a/solidity/test/bridge/ReservationRouter.test.ts b/solidity/test/bridge/ReservationRouter.test.ts index 1f770d7e0..cbf6189f4 100644 --- a/solidity/test/bridge/ReservationRouter.test.ts +++ b/solidity/test/bridge/ReservationRouter.test.ts @@ -51,7 +51,7 @@ async function getStorageLayout( if (!layout) { throw new Error( `No storage layout for ${sourceName}:${contractName}; ` + - `is the storageLayout output selection enabled?` + "is the storageLayout output selection enabled?" ) } return layout From 5275143a35c90a15e7cb52aab5108b7c134c7022 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 9 Aug 2026 21:42:41 -0400 Subject: [PATCH 05/14] fix(reservation): preserve settlement lifecycle invariants --- .../contracts/bridge/BridgeGovernance.sol | 16 ++ solidity/contracts/bridge/BridgeState.sol | 15 ++ solidity/contracts/bridge/Deposit.sol | 32 ++- solidity/contracts/bridge/MovingFunds.sol | 2 + solidity/contracts/bridge/Reservation.sol | 32 ++- .../contracts/bridge/ReservationProofs.sol | 53 +++++ solidity/contracts/bridge/Wallets.sol | 34 +-- .../test/bridge/Bridge.Reservation.test.ts | 146 ++++++++++++- .../Bridge.ReservationSettlement.test.ts | 206 +++++++++++++++++- solidity/test/bridge/Bridge.Wallets.test.ts | 37 ++++ .../test/bridge/ReservationRouter.test.ts | 94 ++++++++ 11 files changed, 633 insertions(+), 34 deletions(-) diff --git a/solidity/contracts/bridge/BridgeGovernance.sol b/solidity/contracts/bridge/BridgeGovernance.sol index 3eb0c50d5..5c0e9452b 100644 --- a/solidity/contracts/bridge/BridgeGovernance.sol +++ b/solidity/contracts/bridge/BridgeGovernance.sol @@ -291,6 +291,22 @@ contract BridgeGovernance is Ownable { event TreasuryUpdateStarted(address newTreasury, uint256 timestamp); event TreasuryUpdated(address treasury); + // Emitted by the linked BridgeGovernanceParameters library through + // delegatecall, hence its address is this BridgeGovernance contract. + // Redeclare the exact signature so governance tooling can decode it + // from the BridgeGovernance ABI. + event ReservationParametersUpdateStarted( + address newReservationVault, + uint64 newReservationMinAmount, + uint64 newReservationTxMaxFee, + uint32 newReservationTermSeconds, + uint32 newReservationGracePeriod, + uint64 newReservationMaxTotalAmount, + uint32 newMaxReservationsPerWallet, + uint32 newReservationActionTimeout, + uint256 timestamp + ); + constructor(Bridge _bridge, uint256 _governanceDelay) { bridge = _bridge; governanceDelays[0] = _governanceDelay; diff --git a/solidity/contracts/bridge/BridgeState.sol b/solidity/contracts/bridge/BridgeState.sol index ca66daa4f..1dbb0ed57 100644 --- a/solidity/contracts/bridge/BridgeState.sol +++ b/solidity/contracts/bridge/BridgeState.sol @@ -29,6 +29,17 @@ import "./MovingFunds.sol"; import "../bank/Bank.sol"; library BridgeState { + /// @notice Reveal-time facts for a deposit routed to the reservation + /// vault. Both fields fit in one storage word. + struct ReservedDepositInfo { + // Wallet committed by the deposit script and therefore the only + // wallet that can be authorized to anchor the deposit. + bytes20 walletPubKeyHash; + // Exact Bitcoin refund locktime validated at reveal time. Zero is a + // sentinel used when the reveal-ahead validation was disabled. + uint32 refundDeadline; + } + struct Storage { // Address of the Bank the Bridge belongs to. Bank bank; @@ -394,6 +405,10 @@ library BridgeState { // dissolutions of a no-main-UTXO wallet could all confirm on // Bitcoin with only the first being provable. mapping(bytes20 => uint256) walletPendingDissolution; + // Reveal-time facts for deposits routed to the reservation vault. + // The exact refund deadline is immutable even when governance later + // changes `depositRevealAheadPeriod`. + mapping(uint256 => ReservedDepositInfo) reservedDeposits; // Reserved storage space in case we need to add more variables. // The convention from OpenZeppelin suggests the storage space should // add up to 50 slots. Here we want to have more slots as there are diff --git a/solidity/contracts/bridge/Deposit.sol b/solidity/contracts/bridge/Deposit.sol index 6086f2d4e..5e2eb68cc 100644 --- a/solidity/contracts/bridge/Deposit.sol +++ b/solidity/contracts/bridge/Deposit.sol @@ -207,8 +207,12 @@ library Deposit { "Vault is not trusted" ); + uint32 refundDeadline; if (self.depositRevealAheadPeriod > 0) { - validateDepositRefundLocktime(self, reveal.refundLocktime); + refundDeadline = validateDepositRefundLocktime( + self, + reveal.refundLocktime + ); } bytes memory expectedScript; @@ -317,13 +321,12 @@ library Deposit { ) .hash256View(); - DepositRequest storage deposit = self.deposits[ - uint256( - keccak256( - abi.encodePacked(fundingTxHash, reveal.fundingOutputIndex) - ) + uint256 depositKey = uint256( + keccak256( + abi.encodePacked(fundingTxHash, reveal.fundingOutputIndex) ) - ]; + ); + DepositRequest storage deposit = self.deposits[depositKey]; require(deposit.revealedAt == 0, "Deposit already revealed"); uint64 fundingOutputAmount = fundingOutput.extractValue(); @@ -352,6 +355,15 @@ library Deposit { ); } + if ( + reveal.vault != address(0) && reveal.vault == self.reservationVault + ) { + self.reservedDeposits[depositKey] = BridgeState.ReservedDepositInfo( + reveal.walletPubKeyHash, + refundDeadline + ); + } + _emitDepositRevealedEvent(fundingTxHash, fundingOutputAmount, reveal); } @@ -426,10 +438,10 @@ library Deposit { function validateDepositRefundLocktime( BridgeState.Storage storage self, bytes4 refundLocktime - ) internal view { + ) internal view returns (uint32 depositRefundableTimestamp) { // Convert the refund locktime byte array to a LE integer. This is // the moment in time when the deposit become refundable. - uint32 depositRefundableTimestamp = BTCUtils.reverseUint32( + depositRefundableTimestamp = BTCUtils.reverseUint32( uint32(refundLocktime) ); // According to https://developer.bitcoin.org/devguide/transactions.html#locktime-and-sequence-number @@ -450,5 +462,7 @@ library Deposit { depositRefundableTimestamp, "Deposit refund locktime is too close" ); + + return depositRefundableTimestamp; } } diff --git a/solidity/contracts/bridge/MovingFunds.sol b/solidity/contracts/bridge/MovingFunds.sol index e7f22c6a1..21d531be3 100644 --- a/solidity/contracts/bridge/MovingFunds.sol +++ b/solidity/contracts/bridge/MovingFunds.sol @@ -595,6 +595,8 @@ library MovingFunds { /// on the Ethereum chain. /// @dev Requirements: /// - The wallet must be in the MovingFunds state, + /// - The wallet must not custody reservation anchors or have pending + /// moved-funds sweep requests, /// - The `mainUtxo` components must point to the recent main UTXO /// of the given wallet, as currently known on the Ethereum chain. /// If the wallet has no main UTXO, this parameter can be empty as it diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index 5cd226bf6..9f924a6ac 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -353,9 +353,8 @@ library Reservation { /// the transaction fee allowance, so a compliant anchor always /// satisfies the minimum after fees, /// - The authorization window (now + action timeout) must end - /// before the deposit's guaranteed refund-locktime margin - /// (`revealedAt + depositRevealAheadPeriod`), so an authorized - /// anchor can never race the depositor's refund, + /// before the deposit's exact reveal-time refund deadline, so an + /// authorized anchor can never race the depositor's refund, /// - Reservation capacity (total amount, per-wallet count) must /// allow the deposit; both are reserved by this call and /// released if the authorization times out. @@ -377,6 +376,13 @@ library Reservation { "Deposit not routed to the reservation vault" ); + BridgeState.ReservedDepositInfo storage reservedDeposit = self + .reservedDeposits[reservationKey]; + require( + reservedDeposit.walletPubKeyHash == walletPubKeyHash, + "Wallet is not the deposit's designated wallet" + ); + ReservationRequest storage reservation = self.reservations[ reservationKey ]; @@ -406,14 +412,13 @@ library Reservation { uint32 timeoutAt = uint32(block.timestamp) + self.reservationActionTimeout; - // The reveal-ahead validation guarantees the deposit refund - // locktime is at least `depositRevealAheadPeriod` after the reveal. - // The authorization window must fit inside that guaranteed margin: - // the wallet must never hold an authorization that is still valid - // when the depositor's refund path may open. - if (self.depositRevealAheadPeriod > 0) { + // Use the exact refund locktime captured at reveal. A later + // governance update of `depositRevealAheadPeriod` must neither + // extend nor shorten this deposit's authorization window. Zero + // means the reveal-ahead validation was disabled at reveal time. + if (reservedDeposit.refundDeadline != 0) { require( - timeoutAt <= deposit.revealedAt + self.depositRevealAheadPeriod, + timeoutAt <= reservedDeposit.refundDeadline, "Authorization window would overlap the deposit refund window" ); } @@ -619,6 +624,7 @@ library Reservation { /// away from Live wallets. /// @dev Requirements: /// - The reservation must be Active, + /// - The reservation must not yet be eligible for dissolution, /// - The source wallet must be in the MovingFunds state (anyone /// may then request — migration is the system's duty), or Live /// with the governance as the caller (approved rotation), @@ -639,6 +645,12 @@ library Reservation { reservation.state == ReservationState.Active, "Reservation is not active" ); + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp <= + uint256(reservation.expiresAt) + reservation.gracePeriod, + "Reservation past grace period" + ); { Wallets.WalletState sourceState = self diff --git a/solidity/contracts/bridge/ReservationProofs.sol b/solidity/contracts/bridge/ReservationProofs.sol index 967fd4b40..0ae9184a9 100644 --- a/solidity/contracts/bridge/ReservationProofs.sol +++ b/solidity/contracts/bridge/ReservationProofs.sol @@ -298,6 +298,7 @@ library ReservationProofs { /* solhint-disable-next-line not-rely-on-time */ deposit.sweptAt = uint32(block.timestamp); + delete self.reservedDeposits[reservationKey]; } /// @notice Parses the anchor transaction's single output, validates it @@ -793,6 +794,18 @@ library ReservationProofs { ]; if (wallet.mainUtxoHash == action.expectedMainUtxoHash) { + // A timed-out dissolution may settle after a different + // reservation acquired the wallet lock against the same main + // UTXO. This proof consumes that shared snapshot, so the newer + // generation can no longer confirm and must be superseded now. + if (late && action.expectedMainUtxoHash != bytes32(0)) { + supersedeConflictingDissolution( + self, + walletPubKeyHash, + reservationKey + ); + } + // The dissolution output becomes the wallet's new main UTXO: // the reserved backing rejoins the pooled supply. wallet.mainUtxoHash = keccak256( @@ -823,6 +836,7 @@ library ReservationProofs { sweepRequest.state = MovingFunds .MovedFundsSweepRequestState .Pending; + wallet.pendingMovedFundsSweepRequestsCount++; } if (late) { @@ -861,6 +875,45 @@ library ReservationProofs { ); } + /// @notice Supersedes another reservation's pending dissolution when a + /// late proof consumes the wallet main UTXO it snapshotted. + function supersedeConflictingDissolution( + BridgeState.Storage storage self, + bytes20 walletPubKeyHash, + uint256 settledReservationKey + ) internal { + uint256 pendingReservationKey = self.walletPendingDissolution[ + walletPubKeyHash + ]; + if ( + pendingReservationKey == 0 || + pendingReservationKey == settledReservationKey + ) { + return; + } + + Reservation.ReservationRequest storage pendingReservation = self + .reservations[pendingReservationKey]; + Reservation.ReservationAction storage pendingAction = self + .reservationActions[ + Reservation.actionKey( + pendingReservationKey, + pendingReservation.requestNonce + ) + ]; + require( + pendingReservation.state == + Reservation.ReservationState.ActionPending && + pendingAction.actionType == + Reservation.ActionType.Dissolution && + pendingAction.state == Reservation.ActionState.Pending, + "Invalid wallet dissolution lock" + ); + + unwindPendingAction(self, pendingReservation, pendingReservationKey); + pendingReservation.state = Reservation.ReservationState.Active; + } + /// @notice Resolves a late redemption settlement against the position's /// current pending generation. If the pending generation is a /// redemption that matches the proven transaction as well, the diff --git a/solidity/contracts/bridge/Wallets.sol b/solidity/contracts/bridge/Wallets.sol index a4a65a15e..f525faf7a 100644 --- a/solidity/contracts/bridge/Wallets.sol +++ b/solidity/contracts/bridge/Wallets.sol @@ -432,10 +432,15 @@ library Wallets { "Target wallets don't correspond to the commitment" ); - // If funds were moved, the wallet has no longer a main UTXO. + // If funds were moved, the wallet no longer has a main UTXO. delete wallet.mainUtxoHash; - beginWalletClosing(self, walletPubKeyHash); + if ( + self.walletReservationsCount[walletPubKeyHash] == 0 && + wallet.pendingMovedFundsSweepRequestsCount == 0 + ) { + beginWalletClosing(self, walletPubKeyHash); + } } /// @notice Called when a MovingFunds wallet has a balance below the dust @@ -595,11 +600,11 @@ library Wallets { if ( wallet.mainUtxoHash == bytes32(0) && - self.walletReservationsCount[walletPubKeyHash] == 0 + self.walletReservationsCount[walletPubKeyHash] == 0 && + wallet.pendingMovedFundsSweepRequestsCount == 0 ) { - // If the wallet has no main UTXO and no reservation anchors, - // its BTC balance is zero and the wallet closing should begin - // immediately. + // If the wallet has no tracked BTC obligations, its BTC balance + // is zero and the wallet closing should begin immediately. beginWalletClosing(self, walletPubKeyHash); } else { // The wallet holds funds: a main UTXO, reservation anchors, or @@ -635,18 +640,21 @@ library Wallets { BridgeState.Storage storage self, bytes20 walletPubKeyHash ) internal { - // A wallet that still custodies reservation anchors (or reserved - // capacity of pending reservation actions) has not finished moving - // its funds: reservations must first be redeemed, re-anchored to - // other wallets or dissolved into the main UTXO. Entering the - // Closing state would strand them — reservation actions require a - // Live or MovingFunds wallet. + // A wallet that still custodies reservation anchors, reserved + // capacity, or pending moved-funds sweep requests has not finished + // moving its funds. Entering Closing would make those obligations + // unprocessable because their proof and timeout paths require a Live + // or MovingFunds wallet. + Wallet storage wallet = self.registeredWallets[walletPubKeyHash]; require( self.walletReservationsCount[walletPubKeyHash] == 0, "Wallet still custodies reservations" ); + require( + wallet.pendingMovedFundsSweepRequestsCount == 0, + "Wallet has pending moved funds sweep requests" + ); - Wallet storage wallet = self.registeredWallets[walletPubKeyHash]; // Initialize the closing period. wallet.state = WalletState.Closing; /* solhint-disable-next-line not-rely-on-time */ diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index de947ef20..2499c3a40 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -1010,7 +1010,7 @@ describe("Bridge - Reservation", () => { }) it("stages the parameters and applies them after the governance delay", async () => { - await bridgeGovernance + const beginTx = await bridgeGovernance .connect(governance) .beginReservationParametersUpdate( reservationVault.address, @@ -1022,6 +1022,24 @@ describe("Bridge - Reservation", () => { MAX_RESERVATIONS_PER_WALLET, RESERVATION_ACTION_TIMEOUT ) + const beginReceipt = await beginTx.wait() + const beginTimestamp = ( + await ethers.provider.getBlock(beginReceipt.blockNumber) + ).timestamp + + await expect(beginTx) + .to.emit(bridgeGovernance, "ReservationParametersUpdateStarted") + .withArgs( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT, + beginTimestamp + ) await expect( bridgeGovernance @@ -1673,6 +1691,102 @@ describe("Bridge - Reservation", () => { ).to.be.revertedWith("Action type mismatch") }) + it("uses the exact reveal-time refund deadline after parameter updates", async () => { + const revealAhead = 3 * 24 * 60 * 60 + const refundDeadline = (await lastBlockTime()) + 5 * 24 * 60 * 60 + const exactRefundLocktime = `0x${toLE(refundDeadline, 4)}` + await bridge.setDepositRevealAheadPeriod(revealAhead) + + const fundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: depositAmount, + script: p2wshScript( + buildDepositScript( + thirdParty.address, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + exactRefundLocktime + ) + ), + }, + ] + ) + await bridge.connect(thirdParty).revealDeposit(fundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime: exactRefundLocktime, + vault: reservationVault.address, + }) + const reservationKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) + + await liveWallet(secondWalletPubKeyHash) + await expect( + bridge + .connect(thirdParty) + .requestReservationAcceptance(reservationKey, secondWalletPubKeyHash) + ).to.be.revertedWith("Wallet is not the deposit's designated wallet") + + // Raising the live reveal-ahead period must not manufacture a later + // deadline for this already-revealed deposit. A six-day authorization + // overlaps its exact five-day Bitcoin refund locktime. + await bridge.setDepositRevealAheadPeriod(30 * 24 * 60 * 60) + await bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET, + 6 * 24 * 60 * 60 + ) + + await expect( + bridge + .connect(thirdParty) + .requestReservationAcceptance(reservationKey, walletPubKeyHash) + ).to.be.revertedWith( + "Authorization window would overlap the deposit refund window" + ) + + // A window that still fits the immutable Bitcoin deadline remains + // valid even though the live ahead period is now much larger. + await bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET, + 4 * 24 * 60 * 60 + ) + await expect( + bridge + .connect(thirdParty) + .requestReservationAcceptance(reservationKey, walletPubKeyHash) + ).to.emit(bridge, "ReservationAcceptanceRequested") + }) + it("accepts a proven anchor and credits the gross amount", async () => { const { anchorTx, acceptTx, reservationKey } = await makeAcceptedReservation() @@ -2051,6 +2165,36 @@ describe("Bridge - Reservation", () => { ).to.be.revertedWith("Output must pay the authorized target wallet") }) + it("rejects permissionless re-anchors once dissolution is due", async () => { + const { reservationKey } = await makeAcceptedReservation() + await liveWallet(secondWalletPubKeyHash) + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: await lastBlockTime(), + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.MovingFunds, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + + await expect( + bridge + .connect(thirdParty) + .requestReservationReanchor(reservationKey, secondWalletPubKeyHash) + ).to.be.revertedWith("Reservation past grace period") + + // The terminal cleanup path is available instead of another + // non-slashing re-anchor generation. + await expect( + bridge.connect(thirdParty).requestReservationDissolution(reservationKey) + ).to.emit(bridge, "ReservationDissolutionRequested") + }) + it("dissolves an expired reservation into the wallet main UTXO", async () => { const { anchorTx, reservationKey } = await makeAcceptedReservation() diff --git a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts index 8cc6a68b7..f7ca3f0e7 100644 --- a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts +++ b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts @@ -830,9 +830,91 @@ describe("Bridge - Reservation settlement", () => { .withArgs(second.reservationKey, 2, walletPubKeyHash, secondTx.txHash) }) + it("supersedes a cross-reservation lock when a late dissolution consumes its main UTXO", async () => { + const first = await makeAcceptedReservation() + const second = await makeAcceptedReservation() + const mainUtxo = { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + txOutputIndex: 0, + txOutputValue: 7000000, + } + await bridge.setWalletMainUtxo(walletPubKeyHash, mainUtxo) + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + await bridge + .connect(thirdParty) + .requestReservationDissolution(first.reservationKey) + + // Generation A times out and releases the wallet lock, but its + // confirmed Bitcoin transaction remains late-settleable. + await increaseTime(RESERVATION_ACTION_TIMEOUT + 1) + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(first.reservationKey, []) + + // Generation B acquires the lock against the same registry main UTXO. + await bridge + .connect(thirdParty) + .requestReservationDissolution(second.reservationKey) + expect(await bridge.walletPendingDissolution(walletPubKeyHash)).to.equal( + second.reservationKey + ) + + const dissolutionFee = 500 + const firstTx = buildTx( + [ + { txHash: first.anchorTx.txHash, index: 0 }, + { txHash: mainUtxo.txHash, index: mainUtxo.txOutputIndex }, + ], + [ + { + valueSat: anchorAmount + .add(mainUtxo.txOutputValue) + .sub(dissolutionFee), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Dissolution, + firstTx.info, + proofFor(firstTx.txHash), + mainUtxo, + first.reservationKey, + 2 + ) + + await expect(tx) + .to.emit(bridge, "ReservationActionSuperseded") + .withArgs(second.reservationKey, 2) + expect( + (await bridge.reservationActions(second.reservationKey, 2)).state + ).to.equal(ActionState.Superseded) + expect((await bridge.reservations(second.reservationKey)).state).to.equal( + ReservationState.Active + ) + expect(await bridge.walletPendingDissolution(walletPubKeyHash)).to.equal( + 0 + ) + }) + it("re-registers a drifted dissolution output for the sweep machinery", async () => { const { anchorTx, reservationKey } = await makeAcceptedReservation() + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: await lastBlockTime(), + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.MovingFunds, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) await bridge .connect(thirdParty) @@ -854,7 +936,7 @@ describe("Bridge - Reservation settlement", () => { const sweepUtxo = { txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), txOutputIndex: 0, - txOutputValue: 7777777, + txOutputValue: 1, } await bridge.setWalletMainUtxo(walletPubKeyHash, sweepUtxo) const driftedMainUtxoHash = (await bridge.wallets(walletPubKeyHash)) @@ -887,6 +969,60 @@ describe("Bridge - Reservation settlement", () => { ) expect(sweepRequest.walletPubKeyHash).to.equal(walletPubKeyHash) expect(sweepRequest.value).to.equal(anchorAmount.sub(dissolutionFee)) + expect( + (await bridge.wallets(walletPubKeyHash)) + .pendingMovedFundsSweepRequestsCount + ).to.equal(1) + + // The drifted output remains a tracked obligation after the last + // reservation closes, so even the below-dust path cannot put the + // wallet in Closing and strand the sweep. + await expect( + bridge.notifyMovingFundsBelowDust(walletPubKeyHash, sweepUtxo) + ).to.be.revertedWith("Wallet has pending moved funds sweep requests") + expect((await bridge.wallets(walletPubKeyHash)).state).to.equal( + walletState.MovingFunds + ) + + // The normal sweep proof path remains available and balances the + // counter created by the dissolution settlement. + await bridge.setMovedFundsSweepTxMaxTotalFee(2000) + const movedFundsSweepTx = buildTx( + [ + { txHash: dissolutionTx.txHash, index: 0 }, + { txHash: sweepUtxo.txHash, index: sweepUtxo.txOutputIndex }, + ], + [ + { + valueSat: anchorAmount.sub(dissolutionFee).sub(500).add(1), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + await bridge + .connect(spvMaintainer) + .submitMovedFundsSweepProof( + movedFundsSweepTx.info, + proofFor(movedFundsSweepTx.txHash), + sweepUtxo + ) + + expect( + (await bridge.wallets(walletPubKeyHash)) + .pendingMovedFundsSweepRequestsCount + ).to.equal(0) + expect( + ( + await bridge.movedFundsSweepRequests( + BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [dissolutionTx.txHash, 0] + ) + ) + ) + ).state + ).to.equal(2) // Processed }) }) @@ -1194,5 +1330,73 @@ describe("Bridge - Reservation settlement", () => { walletState.MovingFunds ) }) + + it("records a confirmed main-UTXO move while reservation obligations remain", async () => { + await makeAcceptedReservation() + await liveWallet(secondWalletPubKeyHash) + + const mainUtxo = { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + txOutputIndex: 0, + txOutputValue: 7000000, + } + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: await lastBlockTime(), + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.MovingFunds, + movingFundsTargetWalletsCommitmentHash: ethers.utils.solidityKeccak256( + ["bytes20[]"], + [[secondWalletPubKeyHash]] + ), + }) + await bridge.setWalletMainUtxo(walletPubKeyHash, mainUtxo) + + const movingFundsTx = buildTx( + [{ txHash: mainUtxo.txHash, index: mainUtxo.txOutputIndex }], + [ + { + valueSat: BigNumber.from(mainUtxo.txOutputValue).sub(500), + script: p2wpkhScript(secondWalletPubKeyHash), + }, + ] + ) + + await expect( + bridge + .connect(spvMaintainer) + .submitMovingFundsProof( + movingFundsTx.info, + proofFor(movingFundsTx.txHash), + mainUtxo, + walletPubKeyHash + ) + ) + .to.emit(bridge, "MovingFundsCompleted") + .withArgs(walletPubKeyHash, movingFundsTx.txHash) + + const sourceWallet = await bridge.wallets(walletPubKeyHash) + expect(sourceWallet.mainUtxoHash).to.equal(ZERO_BYTES32) + expect(sourceWallet.state).to.equal(walletState.MovingFunds) + + const targetRequest = await bridge.movedFundsSweepRequests( + BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [movingFundsTx.txHash, 0] + ) + ) + ) + expect(targetRequest.walletPubKeyHash).to.equal(secondWalletPubKeyHash) + expect(targetRequest.state).to.equal(1) // Pending + expect( + (await bridge.wallets(secondWalletPubKeyHash)) + .pendingMovedFundsSweepRequestsCount + ).to.equal(1) + }) }) }) diff --git a/solidity/test/bridge/Bridge.Wallets.test.ts b/solidity/test/bridge/Bridge.Wallets.test.ts index 8ae490de5..6756bcc45 100644 --- a/solidity/test/bridge/Bridge.Wallets.test.ts +++ b/solidity/test/bridge/Bridge.Wallets.test.ts @@ -648,6 +648,43 @@ describe("Bridge - Wallets", () => { }) context("when wallet balance is zero", () => { + context("when a moved-funds sweep request is pending", () => { + const movedFundsUtxo = { + txHash: + "0x8a5d799af0f9872f435eb49750c953b4257c37df69502b7f38f6367db62034cb", + txOutputIndex: 0, + txOutputValue: 100000, + } + + before(async () => { + await createSnapshot() + await bridge.setPendingMovedFundsSweepRequest( + ecdsaWalletTestData.pubKeyHash160, + movedFundsUtxo + ) + + await bridge + .connect(walletRegistry.wallet) + .__ecdsaWalletHeartbeatFailedCallback( + ecdsaWalletTestData.walletID, + ecdsaWalletTestData.publicKeyX, + ecdsaWalletTestData.publicKeyY + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should retain MovingFunds until the sweep is handled", async () => { + const wallet = await bridge.wallets( + ecdsaWalletTestData.pubKeyHash160 + ) + expect(wallet.state).to.equal(walletState.MovingFunds) + expect(wallet.pendingMovedFundsSweepRequestsCount).to.equal(1) + }) + }) + context("when wallet is the active one", () => { let tx: ContractTransaction diff --git a/solidity/test/bridge/ReservationRouter.test.ts b/solidity/test/bridge/ReservationRouter.test.ts index cbf6189f4..ae12ee971 100644 --- a/solidity/test/bridge/ReservationRouter.test.ts +++ b/solidity/test/bridge/ReservationRouter.test.ts @@ -1,6 +1,13 @@ import { artifacts, ethers, helpers, waffle } from "hardhat" import { expect } from "chai" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" +import * as fs from "fs" +import * as path from "path" +import { getStorageUpgradeErrors } from "@openzeppelin/upgrades-core" +import { normalizeValidationData } from "@openzeppelin/upgrades-core/dist/validate/data" +import type { ValidationData } from "@openzeppelin/upgrades-core/dist/validate/data" +import { unfoldStorageLayout } from "@openzeppelin/upgrades-core/dist/validate/query" +import type { StorageLayout as OZStorageLayout } from "@openzeppelin/upgrades-core/dist/storage/layout" import bridgeFixture from "../fixtures/bridge" import type { Bridge, BridgeStub, ReservationRouter } from "../../typechain" @@ -135,6 +142,93 @@ describe("ReservationRouter", () => { }) describe("storage layout parity", () => { + it("should pass the OpenZeppelin upgrade check from the deployed Bridge gap", () => { + const validations = normalizeValidationData( + JSON.parse( + fs.readFileSync( + path.resolve(__dirname, "../../cache/validations.json"), + "utf8" + ) + ) as ValidationData + ) + + const currentLayout = validations.log.reduce( + (matchingLayout, run) => { + if (!run.Bridge) return matchingLayout + + const candidate = unfoldStorageLayout(run, "Bridge") + const selfEntry = candidate.storage.find( + (entry) => entry.label === "self" + ) + if (!selfEntry) return matchingLayout + + const members = candidate.types[selfEntry.type].members as + | StorageEntry[] + | undefined + return members?.some((member) => member.label === "reservedDeposits") + ? candidate + : matchingLayout + }, + undefined + ) + + expect(currentLayout, "compiled Bridge validation layout").not.to.be + .undefined + + const selfEntry = currentLayout!.storage.find( + (entry) => entry.label === "self" + )! + const members = currentLayout!.types[selfEntry.type] + .members as StorageEntry[] + const rebateStakingIndex = members.findIndex( + (member) => member.label === "rebateStaking" + ) + expect(rebateStakingIndex).to.be.greaterThan(-1) + + const currentGap = members.find((member) => member.label === "__gap")! + expect(currentGap.slot).to.equal("39") + expect(currentLayout!.types[currentGap.type].label).to.equal( + "uint256[39]" + ) + + const asStorageItem = (entry: StorageEntry) => ({ + ...entry, + contract: "Bridge", + src: "compiled-layout", + }) + const legacyGap = { + label: "__gap", + slot: "30", + offset: 0, + type: "t_array(t_uint256)48_storage", + contract: "Bridge", + src: "deployed-layout", + } + const legacyTypes = { + ...currentLayout!.types, + [legacyGap.type]: { + label: "uint256[48]", + numberOfBytes: "1536", + }, + } + + const errors = getStorageUpgradeErrors( + { + storage: [ + ...members.slice(0, rebateStakingIndex + 1).map(asStorageItem), + legacyGap, + ], + types: legacyTypes, + }, + { + storage: members.map(asStorageItem), + types: currentLayout!.types, + } + ) + + expect(errors).to.deep.equal([]) + }) + it("should give the router the exact storage layout of the Bridge", async () => { const bridgeLayout = await getStorageLayout( "contracts/bridge/Bridge.sol", From 7dac6f68a399f193ea427fcddd9e52a17bebb2d7 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 9 Aug 2026 22:00:58 -0400 Subject: [PATCH 06/14] fix(moving-funds): close late-proof timeout races --- solidity/contracts/bridge/BridgeState.sol | 4 +- solidity/contracts/bridge/Deposit.sol | 9 +- solidity/contracts/bridge/DepositSweep.sol | 1 + solidity/contracts/bridge/MovingFunds.sol | 66 ++- solidity/contracts/bridge/Reservation.sol | 4 +- .../contracts/bridge/ReservationProofs.sol | 3 +- solidity/contracts/bridge/Wallets.sol | 65 ++- solidity/test/bridge/Bridge.Deposit.test.ts | 24 +- .../test/bridge/Bridge.MovingFunds.test.ts | 386 +++++++++++++----- .../Bridge.ReservationSettlement.test.ts | 80 +++- .../test/bridge/ReservationRouter.test.ts | 4 +- 11 files changed, 500 insertions(+), 146 deletions(-) diff --git a/solidity/contracts/bridge/BridgeState.sol b/solidity/contracts/bridge/BridgeState.sol index 1dbb0ed57..802695c1d 100644 --- a/solidity/contracts/bridge/BridgeState.sol +++ b/solidity/contracts/bridge/BridgeState.sol @@ -31,7 +31,7 @@ import "../bank/Bank.sol"; library BridgeState { /// @notice Reveal-time facts for a deposit routed to the reservation /// vault. Both fields fit in one storage word. - struct ReservedDepositInfo { + struct PendingReservedDeposit { // Wallet committed by the deposit script and therefore the only // wallet that can be authorized to anchor the deposit. bytes20 walletPubKeyHash; @@ -408,7 +408,7 @@ library BridgeState { // Reveal-time facts for deposits routed to the reservation vault. // The exact refund deadline is immutable even when governance later // changes `depositRevealAheadPeriod`. - mapping(uint256 => ReservedDepositInfo) reservedDeposits; + mapping(uint256 => PendingReservedDeposit) pendingReservedDeposit; // Reserved storage space in case we need to add more variables. // The convention from OpenZeppelin suggests the storage space should // add up to 50 slots. Here we want to have more slots as there are diff --git a/solidity/contracts/bridge/Deposit.sol b/solidity/contracts/bridge/Deposit.sol index 5e2eb68cc..20487cba9 100644 --- a/solidity/contracts/bridge/Deposit.sol +++ b/solidity/contracts/bridge/Deposit.sol @@ -358,10 +358,11 @@ library Deposit { if ( reveal.vault != address(0) && reveal.vault == self.reservationVault ) { - self.reservedDeposits[depositKey] = BridgeState.ReservedDepositInfo( - reveal.walletPubKeyHash, - refundDeadline - ); + self.pendingReservedDeposit[depositKey] = BridgeState + .PendingReservedDeposit( + reveal.walletPubKeyHash, + refundDeadline + ); } _emitDepositRevealedEvent(fundingTxHash, fundingOutputAmount, reveal); diff --git a/solidity/contracts/bridge/DepositSweep.sol b/solidity/contracts/bridge/DepositSweep.sol index 1ae6e72f3..3692601c9 100644 --- a/solidity/contracts/bridge/DepositSweep.sol +++ b/solidity/contracts/bridge/DepositSweep.sol @@ -242,6 +242,7 @@ library DepositSweep { wallet.mainUtxoHash = keccak256( abi.encodePacked(sweepTxHash, uint32(0), sweepTxOutputValue) ); + Wallets.rearmMovingFundsTimeout(self, walletPubKeyHash); // slither-disable-next-line reentrancy-events emit DepositsSwept(walletPubKeyHash, sweepTxHash); diff --git a/solidity/contracts/bridge/MovingFunds.sol b/solidity/contracts/bridge/MovingFunds.sol index 21d531be3..7a79c9252 100644 --- a/solidity/contracts/bridge/MovingFunds.sol +++ b/solidity/contracts/bridge/MovingFunds.sol @@ -136,6 +136,8 @@ library MovingFunds { /// wallets that the source wallet commits to move the funds to. /// @dev Requirements: /// - The source wallet must be in the MovingFunds state, + /// - The source wallet's current moving-funds generation must not be + /// already completed, /// - The source wallet must not have pending redemption requests, /// - The source wallet must not have pending moved funds sweep requests, /// - The source wallet must not have submitted its commitment already, @@ -178,6 +180,11 @@ library MovingFunds { "Source wallet must be in MovingFunds state" ); + require( + wallet.movingFundsRequestedAt != 0, + "Moving funds process already completed" + ); + require( wallet.pendingRedemptionsValue == 0, "Source wallet must handle all pending redemptions first" @@ -267,6 +274,8 @@ library MovingFunds { /// @param walletPubKeyHash 20-byte public key hash of the moving funds wallet /// @dev Requirements: /// - The wallet must be in the MovingFunds state, + /// - The wallet's current moving-funds generation must not be already + /// completed, /// - The target wallets commitment must not be already submitted for /// the given moving funds wallet, /// - Live wallets count must be zero, @@ -284,6 +293,11 @@ library MovingFunds { "Wallet must be in MovingFunds state" ); + require( + wallet.movingFundsRequestedAt != 0, + "Moving funds process already completed" + ); + // If the moving funds wallet already submitted their target wallets // commitment, there is no point to reset the timeout since the // wallet can make the BTC transaction and submit the proof. @@ -497,10 +511,10 @@ library MovingFunds { // by the target wallet. The target wallet must sweep the // received funds with their own main UTXO in order to update // their BTC balance. Worth noting there is no need to check - // if the sweep request already exists in the system because - // the moving funds wallet is moved to the Closing state after - // submitting the moving funds proof so there is no possibility - // to submit the proof again and register the sweep request twice. + // if the sweep request already exists in the system because the + // successfully proven source main UTXO is deleted. Even when + // other tracked obligations retain MovingFunds, the same proof + // therefore cannot register the request twice. self.movedFundsSweepRequests[ uint256( keccak256( @@ -569,11 +583,19 @@ library MovingFunds { bytes20 walletPubKeyHash, uint32[] calldata walletMembersIDs ) external { - // Wallet state is validated in `notifyWalletMovingFundsTimeout`. + Wallets.Wallet storage wallet = self.registeredWallets[ + walletPubKeyHash + ]; + require( + wallet.state == Wallets.WalletState.MovingFunds, + "Wallet must be in MovingFunds state" + ); - uint32 movingFundsRequestedAt = self - .registeredWallets[walletPubKeyHash] - .movingFundsRequestedAt; + uint32 movingFundsRequestedAt = wallet.movingFundsRequestedAt; + require( + movingFundsRequestedAt != 0, + "Moving funds process already completed" + ); require( /* solhint-disable-next-line not-rely-on-time */ @@ -661,7 +683,9 @@ library MovingFunds { /// - `mainUtxo` components must point to the recent main UTXO /// of the sweeping wallet, as currently known on the Ethereum chain. /// If there is no main UTXO, this parameter is ignored, - /// - The sweeping wallet must be in the Live or MovingFunds state, + /// - The sweeping wallet must be in the Live, MovingFunds, or Closing + /// state. A Closing wallet is returned to MovingFunds without being + /// counted as Live again, /// - The total Bitcoin transaction fee must be lesser or equal /// to `movedFundsSweepTxMaxTotalFee` governable parameter. function submitMovedFundsSweepProof( @@ -708,6 +732,7 @@ library MovingFunds { wallet.mainUtxoHash = keccak256( abi.encodePacked(sweepTxHash, uint32(0), sweepTxOutputValue) ); + Wallets.rearmMovingFundsTimeout(self, walletPubKeyHash); // slither-disable-next-line reentrancy-events emit MovedFundsSwept(walletPubKeyHash, sweepTxHash); @@ -768,7 +793,9 @@ library MovingFunds { /// the chain state. If the validation went well, this is the /// plain-text main UTXO corresponding to the `wallet.mainUtxoHash`. /// @dev Requirements: - /// - Sweeping wallet must be either in Live or MovingFunds state, + /// - Sweeping wallet must be in Live, MovingFunds, or Closing state. + /// A Closing wallet is reactivated with a fresh moving-funds + /// deadline; its Live-wallet count is not changed, /// - If the main UTXO of the sweeping wallet exists in the storage, /// the passed `mainUTXO` parameter must be equal to the stored one. function resolveMovedFundsSweepingWallet( @@ -777,7 +804,6 @@ library MovingFunds { BitcoinTx.UTXO calldata mainUtxo ) internal - view returns ( Wallets.Wallet storage wallet, BitcoinTx.UTXO memory resolvedMainUtxo @@ -788,10 +814,20 @@ library MovingFunds { Wallets.WalletState walletState = wallet.state; require( walletState == Wallets.WalletState.Live || - walletState == Wallets.WalletState.MovingFunds, - "Wallet must be in Live or MovingFunds state" + walletState == Wallets.WalletState.MovingFunds || + walletState == Wallets.WalletState.Closing, + "Wallet must be in Live or MovingFunds or Closing state" ); + if (walletState == Wallets.WalletState.Closing) { + wallet.state = Wallets.WalletState.MovingFunds; + delete wallet.movingFundsRequestedAt; + delete wallet.closingStartedAt; + // Closing wallets from earlier generations may retain a stale + // commitment. It must not constrain the freshly rearmed process. + delete wallet.movingFundsTargetWalletsCommitmentHash; + } + // Check if the main UTXO for given wallet exists. If so, validate // passed main UTXO data against the stored hash and use them for // further processing. If no main UTXO exists, use empty data. @@ -1017,8 +1053,8 @@ library MovingFunds { /// @dev Requirements: /// - The moved funds sweep request must be in the Pending state, /// - The moved funds sweep timeout must be actually exceeded, - /// - The wallet must be either in the Live or MovingFunds or - /// Terminated state,, + /// - The wallet must be in the Live, MovingFunds, Closing, Closed, + /// or Terminated state, /// - The expression `keccak256(abi.encode(walletMembersIDs))` must /// be exactly the same as the hash stored under `membersIdsHash` /// for the given `walletID`. Those IDs are not directly stored diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index 9f924a6ac..691295212 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -376,8 +376,8 @@ library Reservation { "Deposit not routed to the reservation vault" ); - BridgeState.ReservedDepositInfo storage reservedDeposit = self - .reservedDeposits[reservationKey]; + BridgeState.PendingReservedDeposit storage reservedDeposit = self + .pendingReservedDeposit[reservationKey]; require( reservedDeposit.walletPubKeyHash == walletPubKeyHash, "Wallet is not the deposit's designated wallet" diff --git a/solidity/contracts/bridge/ReservationProofs.sol b/solidity/contracts/bridge/ReservationProofs.sol index 0ae9184a9..4e38d19a3 100644 --- a/solidity/contracts/bridge/ReservationProofs.sol +++ b/solidity/contracts/bridge/ReservationProofs.sol @@ -298,7 +298,7 @@ library ReservationProofs { /* solhint-disable-next-line not-rely-on-time */ deposit.sweptAt = uint32(block.timestamp); - delete self.reservedDeposits[reservationKey]; + delete self.pendingReservedDeposit[reservationKey]; } /// @notice Parses the anchor transaction's single output, validates it @@ -811,6 +811,7 @@ library ReservationProofs { wallet.mainUtxoHash = keccak256( abi.encodePacked(dissolutionTxHash, uint32(0), outputValue) ); + Wallets.rearmMovingFundsTimeout(self, walletPubKeyHash); } else { // Registry drift: another transaction (e.g. a deposit sweep) // registered a main UTXO after this no-main-UTXO dissolution diff --git a/solidity/contracts/bridge/Wallets.sol b/solidity/contracts/bridge/Wallets.sol index f525faf7a..363094534 100644 --- a/solidity/contracts/bridge/Wallets.sol +++ b/solidity/contracts/bridge/Wallets.sol @@ -76,7 +76,8 @@ library Wallets { // XXX: Unsigned 32-bit int unix seconds, will break February 7th 2106. uint32 createdAt; // UNIX timestamp indicating the moment the wallet was requested to - // move their funds. + // move their funds. Zero marks a successfully proven generation whose + // remaining reservation or sweep obligations kept it in MovingFunds. // XXX: Unsigned 32-bit int unix seconds, will break February 7th 2106. uint32 movingFundsRequestedAt; // UNIX timestamp indicating the moment the wallet's closing period @@ -375,21 +376,14 @@ library Wallets { "Closing period has not elapsed yet" ); - // A wallet still custodying reservation anchors must not close - // ultimately; its reservations must first be re-anchored to other - // wallets or dissolved into the main UTXO. - require( - self.walletReservationsCount[walletPubKeyHash] == 0, - "Wallet still custodies reservations" - ); - finalizeWalletClosing(self, walletPubKeyHash); } /// @notice Notifies that the wallet completed the moving funds process /// successfully. Checks if the funds were moved to the expected - /// target wallets. Closes the source wallet if everything went - /// good and reverts otherwise. + /// target wallets. Closes the source wallet when no tracked + /// obligations remain; otherwise disarms the completed generation's + /// timeout and retains MovingFunds until those obligations clear. /// @param walletPubKeyHash 20-byte public key hash of the wallet. /// @param targetWalletsHash 32-byte keccak256 hash over the list of /// 20-byte public key hashes of the target wallets actually used @@ -434,6 +428,11 @@ library Wallets { // If funds were moved, the wallet no longer has a main UTXO. delete wallet.mainUtxoHash; + // A proof records successful completion even when reservation or + // incoming-sweep obligations prevent the state from entering Closing. + // Zero is the completion sentinel and cannot be reported as a timeout. + delete wallet.movingFundsRequestedAt; + delete wallet.movingFundsTargetWalletsCommitmentHash; if ( self.walletReservationsCount[walletPubKeyHash] == 0 && @@ -443,6 +442,27 @@ library Wallets { } } + /// @notice Starts a fresh moving-funds deadline when a completed wallet + /// later gains a new main UTXO. An already-running deadline is + /// deliberately not extended. + function rearmMovingFundsTimeout( + BridgeState.Storage storage self, + bytes20 walletPubKeyHash + ) internal { + Wallet storage wallet = self.registeredWallets[walletPubKeyHash]; + if ( + wallet.state == WalletState.MovingFunds && + wallet.movingFundsRequestedAt == 0 + ) { + /* solhint-disable-next-line not-rely-on-time */ + wallet.movingFundsRequestedAt = uint32(block.timestamp); + + // Reuse the lifecycle event so monitoring sees that the wallet has + // acquired a fresh balance and a new timeout is now running. + emit WalletMovingFunds(wallet.ecdsaWalletID, walletPubKeyHash); + } + } + /// @notice Called when a MovingFunds wallet has a balance below the dust /// threshold. Begins the wallet closing. /// @param walletPubKeyHash 20-byte public key hash of the wallet. @@ -502,8 +522,8 @@ library Wallets { /// supposed to sweep funds. /// @param walletMembersIDs Identifiers of the wallet signing group members. /// @dev Requirements: - /// - The wallet must be in the `Live`, `MovingFunds`, - /// or `Terminated` state. + /// - The wallet must be in the `Live`, `MovingFunds`, `Closing`, + /// `Closed`, or `Terminated` state. function notifyWalletMovedFundsSweepTimeout( BridgeState.Storage storage self, bytes20 walletPubKeyHash, @@ -515,8 +535,10 @@ library Wallets { require( walletState == WalletState.Live || walletState == WalletState.MovingFunds || + walletState == WalletState.Closing || + walletState == WalletState.Closed || walletState == WalletState.Terminated, - "Wallet must be in Live or MovingFunds or Terminated state" + "Wallet must be in Live or MovingFunds or Closing or Closed or Terminated state" ); if ( @@ -533,6 +555,9 @@ library Wallets { terminateWallet(self, walletPubKeyHash); } + // Closing, Closed, and Terminated wallets may receive a sweep request + // from a source proof submitted after their state transition. Resolve + // those requests without another slash or registry state transition. } /// @notice Called when a wallet which was challenged for a fraud did not @@ -675,6 +700,18 @@ library Wallets { ) internal { Wallet storage wallet = self.registeredWallets[walletPubKeyHash]; + // A wallet still custodying reservation anchors or targeted by pending + // moved-funds sweeps must not close ultimately. A late source-wallet + // proof can register a new sweep after this wallet entered Closing. + require( + self.walletReservationsCount[walletPubKeyHash] == 0, + "Wallet still custodies reservations" + ); + require( + wallet.pendingMovedFundsSweepRequestsCount == 0, + "Wallet has pending moved funds sweep requests" + ); + wallet.state = WalletState.Closed; emit WalletClosed(wallet.ecdsaWalletID, walletPubKeyHash); diff --git a/solidity/test/bridge/Bridge.Deposit.test.ts b/solidity/test/bridge/Bridge.Deposit.test.ts index 3e1450c2a..81fba35e5 100644 --- a/solidity/test/bridge/Bridge.Deposit.test.ts +++ b/solidity/test/bridge/Bridge.Deposit.test.ts @@ -4268,17 +4268,19 @@ describe("Bridge - Deposit", () => { await restoreSnapshot() }) - it("should succeed", async () => { - await expect( - bridge - .connect(spvMaintainer) - .submitDepositSweepProof( - data.sweepTx, - data.sweepProof, - data.mainUtxo, - ethers.constants.AddressZero - ) - ).not.to.be.reverted + it("should succeed and rearm a completed moving-funds timeout", async () => { + await bridge + .connect(spvMaintainer) + .submitDepositSweepProof( + data.sweepTx, + data.sweepProof, + data.mainUtxo, + ethers.constants.AddressZero + ) + + const wallet = await bridge.wallets(reveal.walletPubKeyHash) + expect(wallet.state).to.equal(walletState.MovingFunds) + expect(wallet.movingFundsRequestedAt).to.equal(await lastBlockTime()) }) }) diff --git a/solidity/test/bridge/Bridge.MovingFunds.test.ts b/solidity/test/bridge/Bridge.MovingFunds.test.ts index de2b32007..d8322635f 100644 --- a/solidity/test/bridge/Bridge.MovingFunds.test.ts +++ b/solidity/test/bridge/Bridge.MovingFunds.test.ts @@ -99,7 +99,7 @@ describe("Bridge - Moving funds", () => { mainUtxoHash: ethers.constants.HashZero, pendingRedemptionsValue: 0, createdAt: 0, - movingFundsRequestedAt: 0, + movingFundsRequestedAt: 1, closingStartedAt: 0, pendingMovedFundsSweepRequestsCount: 0, state: walletState.Unknown, @@ -120,6 +120,33 @@ describe("Bridge - Moving funds", () => { await restoreSnapshot() }) + context("when the current moving-funds generation is completed", () => { + before(async () => { + await createSnapshot() + + await bridge.setWallet(ecdsaWalletTestData.pubKeyHash160, { + ...(await bridge.wallets(ecdsaWalletTestData.pubKeyHash160)), + movingFundsRequestedAt: 0, + }) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should reject a second commitment", async () => { + await expect( + bridge.submitMovingFundsCommitment( + ecdsaWalletTestData.pubKeyHash160, + NO_MAIN_UTXO, + [], + 0, + [] + ) + ).to.be.revertedWith("Moving funds process already completed") + }) + }) + context("when source wallet has no pending redemptions", () => { // The wallet created using the `walletDraft` has no pending redemptions // by default. No need to do anything here. @@ -732,7 +759,7 @@ describe("Bridge - Moving funds", () => { mainUtxoHash: ethers.constants.HashZero, pendingRedemptionsValue: 0, createdAt: 0, - movingFundsRequestedAt: 0, + movingFundsRequestedAt: 1, closingStartedAt: 0, pendingMovedFundsSweepRequestsCount: 0, state: walletState.Unknown, @@ -753,6 +780,27 @@ describe("Bridge - Moving funds", () => { await restoreSnapshot() }) + context("when the current moving-funds generation is completed", () => { + before(async () => { + await createSnapshot() + + await bridge.setWallet(ecdsaWalletTestData.pubKeyHash160, { + ...(await bridge.wallets(ecdsaWalletTestData.pubKeyHash160)), + movingFundsRequestedAt: 0, + }) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should reject a timeout reset", async () => { + await expect( + bridge.resetMovingFundsTimeout(ecdsaWalletTestData.pubKeyHash160) + ).to.be.revertedWith("Moving funds process already completed") + }) + }) + context("when the wallet's commitment is not submitted yet", () => { context("when Live wallets count is zero", () => { // No need to do any specific setup. There is only one MovingFunds @@ -2114,6 +2162,116 @@ describe("Bridge - Moving funds", () => { }) }) + describe("late moving-funds proof target lifecycle", () => { + const data: MovingFundsTestData = SingleTargetWallet + const targetWalletPubKeyHash = data.targetWalletsCommitment[0] + const targetWalletID = ethers.utils.hexlify(ethers.utils.randomBytes(32)) + const sweepRequest = data.expectedMovedFundsSweepRequests[0] + const sweepRequestKey = ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [sweepRequest.txHash, sweepRequest.txOutputIndex] + ) + + async function proveAfterTargetTransition(state: number) { + await runMovingFundsScenario(data, async () => { + await bridge.setWallet(targetWalletPubKeyHash, { + ecdsaWalletID: targetWalletID, + mainUtxoHash: ethers.constants.HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state, + movingFundsTargetWalletsCommitmentHash: ethers.constants.HashZero, + }) + }) + } + + beforeEach(async () => { + await createSnapshot() + walletRegistry.closeWallet.reset() + walletRegistry.seize.reset() + }) + + afterEach(async () => { + walletRegistry.closeWallet.reset() + walletRegistry.seize.reset() + await restoreSnapshot() + }) + + it("holds a Closing target open until its late request can time out", async () => { + await proveAfterTargetTransition(walletState.Closing) + + expect( + (await bridge.wallets(targetWalletPubKeyHash)) + .pendingMovedFundsSweepRequestsCount + ).to.equal(1) + await expect( + bridge.notifyWalletClosingPeriodElapsed(targetWalletPubKeyHash) + ).to.be.revertedWith("Wallet has pending moved funds sweep requests") + + await increaseTime(movedFundsSweepTimeout + 1) + await expect( + bridge + .connect(thirdParty) + .notifyMovedFundsSweepTimeout( + sweepRequest.txHash, + sweepRequest.txOutputIndex, + [] + ) + ) + .to.emit(bridge, "MovedFundsSweepTimedOut") + .withArgs( + targetWalletPubKeyHash, + sweepRequest.txHash, + sweepRequest.txOutputIndex + ) + + expect( + (await bridge.movedFundsSweepRequests(sweepRequestKey)).state + ).to.equal(movedFundsSweepRequestState.TimedOut) + const targetWallet = await bridge.wallets(targetWalletPubKeyHash) + expect(targetWallet.pendingMovedFundsSweepRequestsCount).to.equal(0) + expect(targetWallet.state).to.equal(walletState.Closing) + expect(walletRegistry.seize).not.to.have.been.called + expect(walletRegistry.closeWallet).not.to.have.been.called + + await expect( + bridge.notifyWalletClosingPeriodElapsed(targetWalletPubKeyHash) + ) + .to.emit(bridge, "WalletClosed") + .withArgs(targetWalletID, targetWalletPubKeyHash) + }) + + it("resolves without slashing when the target closed before the late proof", async () => { + await proveAfterTargetTransition(walletState.Closed) + + expect( + (await bridge.wallets(targetWalletPubKeyHash)) + .pendingMovedFundsSweepRequestsCount + ).to.equal(1) + + await increaseTime(movedFundsSweepTimeout + 1) + await bridge + .connect(thirdParty) + .notifyMovedFundsSweepTimeout( + sweepRequest.txHash, + sweepRequest.txOutputIndex, + [] + ) + + expect( + (await bridge.movedFundsSweepRequests(sweepRequestKey)).state + ).to.equal(movedFundsSweepRequestState.TimedOut) + const targetWallet = await bridge.wallets(targetWalletPubKeyHash) + expect(targetWallet.pendingMovedFundsSweepRequestsCount).to.equal(0) + expect(targetWallet.state).to.equal(walletState.Closed) + expect(walletRegistry.seize).not.to.have.been.called + expect(walletRegistry.closeWallet).not.to.have.been.called + }) + }) + describe("notifyMovingFundsTimeout", () => { const walletDraft = { ecdsaWalletID: ecdsaWalletTestData.walletID, @@ -2519,7 +2677,7 @@ describe("Bridge - Moving funds", () => { context("when the single output is 20-byte", () => { context("when single output is either P2PKH or P2WPKH", () => { context( - "when sweeping wallet is either in the Live or MovingFunds state", + "when sweeping wallet is in the Live, MovingFunds, or Closing state", () => { context("when sweeping wallet is in the Live state", () => { context("when main UTXO data are valid", () => { @@ -3237,71 +3395,120 @@ describe("Bridge - Moving funds", () => { await restoreSnapshot() }) - it("should succeed", async () => { - // The assertions were already performed for Live wallet - // scenarios. Here we just make sure the transaction - // succeeds for a MovingFunds wallet. + it("should succeed and rearm a completed moving-funds timeout", async () => { await expect(tx).to.not.be.reverted + + const wallet = await bridge.wallets( + data.wallet.pubKeyHash + ) + expect(wallet.state).to.equal(walletState.MovingFunds) + expect(wallet.movingFundsRequestedAt).to.equal( + await lastBlockTime() + ) }) } ) - } - ) - context( - "when sweeping wallet is neither in the Live nor MovingFunds state", - () => { - const testData = [ - { - testName: "when sweeping wallet is in the Unknown state", - walletState: walletState.Unknown, - }, - { - testName: "when sweeping wallet is in the Closing state", - walletState: walletState.Closing, - }, - { - testName: "when sweeping wallet is in the Closed state", - walletState: walletState.Closed, - }, - { - testName: "when sweeping wallet is in the Terminated state", - walletState: walletState.Terminated, - }, - ] - - testData.forEach((test) => { - context(test.testName, () => { - const data: MovedFundsSweepTestData = - MovedFundsSweepWithoutMainUtxo + context("when sweeping wallet is in the Closing state", () => { + const data: MovedFundsSweepTestData = + MovedFundsSweepWithoutMainUtxo - let tx: Promise + let tx: Promise - before(async () => { - await createSnapshot() + before(async () => { + await createSnapshot() - tx = runMovedFundsSweepScenario({ + tx = runMovedFundsSweepScenario( + { ...data, wallet: { ...data.wallet, - state: test.walletState, + state: walletState.Closing, }, - }) - }) + }, + async () => { + await bridge.setWallet(data.wallet.pubKeyHash, { + ...(await bridge.wallets(data.wallet.pubKeyHash)), + movingFundsTargetWalletsCommitmentHash: + ethers.utils.hexlify(ethers.utils.randomBytes(32)), + }) + } + ) + }) - after(async () => { - await restoreSnapshot() - }) + after(async () => { + await restoreSnapshot() + }) - it("should revert", async () => { - await expect(tx).to.be.revertedWith( - "Wallet must be in Live or MovingFunds state" + it("should reactivate the wallet and start a fresh moving-funds timeout", async () => { + await expect(tx) + .to.emit(bridge, "WalletMovingFunds") + .withArgs( + data.wallet.ecdsaWalletID, + data.wallet.pubKeyHash ) - }) + + const wallet = await bridge.wallets(data.wallet.pubKeyHash) + expect(wallet.state).to.equal(walletState.MovingFunds) + expect(wallet.movingFundsRequestedAt).to.equal( + await lastBlockTime() + ) + expect(wallet.closingStartedAt).to.equal(0) + expect( + wallet.movingFundsTargetWalletsCommitmentHash + ).to.equal(ethers.constants.HashZero) }) }) } ) + + context("when sweeping wallet is not in a sweepable state", () => { + const testData = [ + { + testName: "when sweeping wallet is in the Unknown state", + walletState: walletState.Unknown, + }, + { + testName: "when sweeping wallet is in the Closed state", + walletState: walletState.Closed, + }, + { + testName: "when sweeping wallet is in the Terminated state", + walletState: walletState.Terminated, + }, + ] + + testData.forEach((test) => { + context(test.testName, () => { + const data: MovedFundsSweepTestData = + MovedFundsSweepWithoutMainUtxo + + let tx: Promise + + before(async () => { + await createSnapshot() + + tx = runMovedFundsSweepScenario({ + ...data, + wallet: { + ...data.wallet, + state: test.walletState, + }, + }) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect(tx).to.be.revertedWith( + "Wallet must be in Live or MovingFunds or Closing state" + ) + }) + }) + }) + }) }) context("when single output is neither P2PKH nor P2WPKH", () => { @@ -3993,59 +4200,48 @@ describe("Bridge - Moving funds", () => { }) }) - context( - "when the wallet is neither in the Live nor MovingFunds nor Terminated state", - () => { - const testData = [ - { - testName: "when the wallet is in the Unknown state", - walletState: walletState.Unknown, - }, - { - testName: "when the wallet is in the Closing state", - walletState: walletState.Closing, - }, - { - testName: "when the wallet is in the Closed state", - walletState: walletState.Closed, - }, - ] + context("when the wallet is not in a resolvable state", () => { + const testData = [ + { + testName: "when the wallet is in the Unknown state", + walletState: walletState.Unknown, + }, + ] - testData.forEach((test) => { - context(test.testName, async () => { - before(async () => { - await createSnapshot() + testData.forEach((test) => { + context(test.testName, async () => { + before(async () => { + await createSnapshot() - await bridge.setWallet( - movedFundsSweepRequest.walletPubKeyHash, - { - ...(await bridge.wallets( - movedFundsSweepRequest.walletPubKeyHash - )), - state: test.walletState, - } - ) - }) + await bridge.setWallet( + movedFundsSweepRequest.walletPubKeyHash, + { + ...(await bridge.wallets( + movedFundsSweepRequest.walletPubKeyHash + )), + state: test.walletState, + } + ) + }) - after(async () => { - await restoreSnapshot() - }) + after(async () => { + await restoreSnapshot() + }) - it("should revert", async () => { - await expect( - bridge.notifyMovedFundsSweepTimeout( - movedFundsSweepRequest.txHash, - movedFundsSweepRequest.txOutputIndex, - walletMembersIDs - ) - ).to.be.revertedWith( - "Wallet must be in Live or MovingFunds or Terminated state" + it("should revert", async () => { + await expect( + bridge.notifyMovedFundsSweepTimeout( + movedFundsSweepRequest.txHash, + movedFundsSweepRequest.txOutputIndex, + walletMembersIDs ) - }) + ).to.be.revertedWith( + "Wallet must be in Live or MovingFunds or Closing or Closed or Terminated state" + ) }) }) - } - ) + }) + }) }) context("when moved funds sweep request has not timed out yet", () => { diff --git a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts index f7ca3f0e7..4c7ef74d7 100644 --- a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts +++ b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts @@ -830,6 +830,44 @@ describe("Bridge - Reservation settlement", () => { .withArgs(second.reservationKey, 2, walletPubKeyHash, secondTx.txHash) }) + it("rearms a completed moving-funds timeout when dissolution creates a main UTXO", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await bridge.setWallet(walletPubKeyHash, { + ...(await bridge.wallets(walletPubKeyHash)), + state: walletState.MovingFunds, + movingFundsRequestedAt: 0, + }) + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + await bridge + .connect(thirdParty) + .requestReservationDissolution(reservationKey) + + const dissolutionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(500), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Dissolution, + dissolutionTx.info, + proofFor(dissolutionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + + const wallet = await bridge.wallets(walletPubKeyHash) + expect(wallet.mainUtxoHash).not.to.equal(ZERO_BYTES32) + expect(wallet.movingFundsRequestedAt).to.equal(await lastBlockTime()) + }) + it("supersedes a cross-reservation lock when a late dissolution consumes its main UTXO", async () => { const first = await makeAcceptedReservation() const second = await makeAcceptedReservation() @@ -1332,7 +1370,7 @@ describe("Bridge - Reservation settlement", () => { }) it("records a confirmed main-UTXO move while reservation obligations remain", async () => { - await makeAcceptedReservation() + const { anchorTx, reservationKey } = await makeAcceptedReservation() await liveWallet(secondWalletPubKeyHash) const mainUtxo = { @@ -1382,6 +1420,10 @@ describe("Bridge - Reservation settlement", () => { const sourceWallet = await bridge.wallets(walletPubKeyHash) expect(sourceWallet.mainUtxoHash).to.equal(ZERO_BYTES32) expect(sourceWallet.state).to.equal(walletState.MovingFunds) + expect(sourceWallet.movingFundsRequestedAt).to.equal(0) + expect(sourceWallet.movingFundsTargetWalletsCommitmentHash).to.equal( + ZERO_BYTES32 + ) const targetRequest = await bridge.movedFundsSweepRequests( BigNumber.from( @@ -1397,6 +1439,42 @@ describe("Bridge - Reservation settlement", () => { (await bridge.wallets(secondWalletPubKeyHash)) .pendingMovedFundsSweepRequestsCount ).to.equal(1) + + // Move the final reservation obligation away from the source wallet. + // Its already-proven moving-funds generation must stay completed rather + // than becoming slashable again when the retained count reaches zero. + await bridge + .connect(thirdParty) + .requestReservationReanchor(reservationKey, secondWalletPubKeyHash) + const reanchorTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(500), + script: p2wpkhScript(secondWalletPubKeyHash), + }, + ] + ) + await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Reanchor, + reanchorTx.info, + proofFor(reanchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + const { movingFundsTimeout } = await bridge.movingFundsParameters() + await increaseTime(movingFundsTimeout + 1) + await expect( + bridge + .connect(thirdParty) + .notifyMovingFundsTimeout(walletPubKeyHash, []) + ).to.be.revertedWith("Moving funds process already completed") + expect((await bridge.wallets(walletPubKeyHash)).state).to.equal( + walletState.MovingFunds + ) }) }) }) diff --git a/solidity/test/bridge/ReservationRouter.test.ts b/solidity/test/bridge/ReservationRouter.test.ts index ae12ee971..e7a3df108 100644 --- a/solidity/test/bridge/ReservationRouter.test.ts +++ b/solidity/test/bridge/ReservationRouter.test.ts @@ -165,7 +165,9 @@ describe("ReservationRouter", () => { const members = candidate.types[selfEntry.type].members as | StorageEntry[] | undefined - return members?.some((member) => member.label === "reservedDeposits") + return members?.some( + (member) => member.label === "pendingReservedDeposit" + ) ? candidate : matchingLayout }, From 011949a3b33d554f61bd3b6b6437e26a557a4234 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 9 Aug 2026 22:26:12 -0400 Subject: [PATCH 07/14] Fix late dissolutions after wallet termination --- .../contracts/bridge/ReservationProofs.sol | 84 ++++++--- .../Bridge.ReservationSettlement.test.ts | 167 +++++++++++++++++- 2 files changed, 220 insertions(+), 31 deletions(-) diff --git a/solidity/contracts/bridge/ReservationProofs.sol b/solidity/contracts/bridge/ReservationProofs.sol index 4e38d19a3..ee2aee85b 100644 --- a/solidity/contracts/bridge/ReservationProofs.sol +++ b/solidity/contracts/bridge/ReservationProofs.sol @@ -772,9 +772,12 @@ library ReservationProofs { /// @notice Finalizes a dissolution settlement: registers the output as /// the wallet's new main UTXO (or as a moved-funds sweep - /// request when the registry drifted), releases the per-wallet - /// main-UTXO action lock, unwinds a superseded pending - /// generation on late settlements and closes the position. + /// request when the registry drifted), unless the wallet was + /// terminated after authorization. In that case, settles the + /// confirmed spend as stranded recovery evidence instead. + /// Releases the per-wallet main-UTXO action lock, unwinds a + /// superseded pending generation on late settlements and closes + /// or strands the position. function settleDissolution( BridgeState.Storage storage self, uint256 reservationKey, @@ -792,6 +795,7 @@ library ReservationProofs { Wallets.Wallet storage wallet = self.registeredWallets[ walletPubKeyHash ]; + bool walletTerminated = wallet.state == Wallets.WalletState.Terminated; if (wallet.mainUtxoHash == action.expectedMainUtxoHash) { // A timed-out dissolution may settle after a different @@ -806,38 +810,52 @@ library ReservationProofs { ); } - // The dissolution output becomes the wallet's new main UTXO: - // the reserved backing rejoins the pooled supply. - wallet.mainUtxoHash = keccak256( - abi.encodePacked(dissolutionTxHash, uint32(0), outputValue) - ); - Wallets.rearmMovingFundsTimeout(self, walletPubKeyHash); + if (walletTerminated) { + // A nonzero snapshot was consumed by the proven transaction. + // Do not leave the terminated wallet pointing at that spent + // UTXO. The dissolution output is deliberately not installed + // as a replacement: Bridge spending paths reject Terminated + // wallets and their registry group is already closed. + if (action.expectedMainUtxoHash != bytes32(0)) { + delete wallet.mainUtxoHash; + } + } else { + // The dissolution output becomes the wallet's new main UTXO: + // the reserved backing rejoins the pooled supply. + wallet.mainUtxoHash = keccak256( + abi.encodePacked(dissolutionTxHash, uint32(0), outputValue) + ); + Wallets.rearmMovingFundsTimeout(self, walletPubKeyHash); + } } else { - // Registry drift: another transaction (e.g. a deposit sweep) - // registered a main UTXO after this no-main-UTXO dissolution - // was authorized. The confirmed dissolution output is a - // wallet-held UTXO the registry must keep tracking; register - // it for the moved-funds sweep machinery to consolidate. require( action.expectedMainUtxoHash == bytes32(0), "Recorded main UTXO can no longer be spent" ); - MovingFunds.MovedFundsSweepRequest storage sweepRequest = self - .movedFundsSweepRequests[ - uint256( - keccak256( - abi.encodePacked(dissolutionTxHash, uint32(0)) + + if (!walletTerminated) { + // Registry drift: another transaction (e.g. a deposit sweep) + // registered a main UTXO after this no-main-UTXO dissolution + // was authorized. The confirmed dissolution output is a + // wallet-held UTXO the registry must keep tracking; register + // it for the moved-funds sweep machinery to consolidate. + MovingFunds.MovedFundsSweepRequest storage sweepRequest = self + .movedFundsSweepRequests[ + uint256( + keccak256( + abi.encodePacked(dissolutionTxHash, uint32(0)) + ) ) - ) - ]; - sweepRequest.walletPubKeyHash = walletPubKeyHash; - sweepRequest.value = outputValue; - /* solhint-disable-next-line not-rely-on-time */ - sweepRequest.createdAt = uint32(block.timestamp); - sweepRequest.state = MovingFunds - .MovedFundsSweepRequestState - .Pending; - wallet.pendingMovedFundsSweepRequestsCount++; + ]; + sweepRequest.walletPubKeyHash = walletPubKeyHash; + sweepRequest.value = outputValue; + /* solhint-disable-next-line not-rely-on-time */ + sweepRequest.createdAt = uint32(block.timestamp); + sweepRequest.state = MovingFunds + .MovedFundsSweepRequestState + .Pending; + wallet.pendingMovedFundsSweepRequestsCount++; + } } if (late) { @@ -866,6 +884,14 @@ library ReservationProofs { action.state = Reservation.ActionState.Settled; self.closeReservation(reservation); + if (walletTerminated) { + // The confirmed transaction and the event below provide the + // evidence needed for off-chain recovery, while the owner's + // minted balance remains an ordinary pooled claim. Classifying + // the position as Stranded socializes the unavailable backing in + // the same way as any other Terminated-wallet UTXO. + reservation.state = Reservation.ReservationState.Stranded; + } // slither-disable-next-line reentrancy-events emit ReservationDissolved( diff --git a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts index 4c7ef74d7..3846db024 100644 --- a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts +++ b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts @@ -868,6 +868,169 @@ describe("Bridge - Reservation settlement", () => { expect(wallet.movingFundsRequestedAt).to.equal(await lastBlockTime()) }) + it("strands a late dissolution output after the source wallet terminates", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + await bridge + .connect(thirdParty) + .requestReservationDissolution(reservationKey) + + // The dissolution generation times out and moves the source wallet + // into MovingFunds. A subsequent moving-funds timeout terminates the + // wallet before the already-confirmed dissolution proof reaches the + // Bridge. + await increaseTime(RESERVATION_ACTION_TIMEOUT + 1) + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + const { movingFundsTimeout } = await bridge.movingFundsParameters() + await increaseTime(movingFundsTimeout + 1) + await bridge + .connect(thirdParty) + .notifyMovingFundsTimeout(walletPubKeyHash, []) + expect((await bridge.wallets(walletPubKeyHash)).state).to.equal( + walletState.Terminated + ) + + const dissolutionFee = 500 + const dissolutionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(dissolutionFee), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Dissolution, + dissolutionTx.info, + proofFor(dissolutionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + await expect(tx) + .to.emit(bridge, "ReservationLateSettled") + .withArgs(reservationKey, 2, ActionType.Dissolution) + await expect(tx) + .to.emit(bridge, "ReservationDissolved") + .withArgs(reservationKey, 2, walletPubKeyHash, dissolutionTx.txHash) + + const wallet = await bridge.wallets(walletPubKeyHash) + expect(wallet.state).to.equal(walletState.Terminated) + expect(wallet.mainUtxoHash).to.equal(ZERO_BYTES32) + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.Stranded + ) + expect( + (await bridge.reservationActions(reservationKey, 2)).state + ).to.equal(ActionState.Settled) + expect( + (await bridge.reservationParameters()).reservationTotalAmount + ).to.equal(0) + expect(await bridge.walletPendingDissolution(walletPubKeyHash)).to.equal( + 0 + ) + + const outputKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [dissolutionTx.txHash, 0] + ) + ) + expect((await bridge.movedFundsSweepRequests(outputKey)).state).to.equal( + 0 + ) + const anchorKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [anchorTx.txHash, 0] + ) + ) + expect(await bridge.spentMainUTXOs(anchorKey)).to.equal(true) + }) + + it("clears a consumed main UTXO when a late dissolution settles after termination", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + const mainUtxo = { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + txOutputIndex: 1, + txOutputValue: 7000000, + } + await bridge.setWalletMainUtxo(walletPubKeyHash, mainUtxo) + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + await bridge + .connect(thirdParty) + .requestReservationDissolution(reservationKey) + await increaseTime(RESERVATION_ACTION_TIMEOUT + 1) + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + const { movingFundsTimeout } = await bridge.movingFundsParameters() + await increaseTime(movingFundsTimeout + 1) + await bridge + .connect(thirdParty) + .notifyMovingFundsTimeout(walletPubKeyHash, []) + + const dissolutionFee = 500 + const dissolutionTx = buildTx( + [ + { txHash: anchorTx.txHash, index: 0 }, + { txHash: mainUtxo.txHash, index: mainUtxo.txOutputIndex }, + ], + [ + { + valueSat: anchorAmount + .add(mainUtxo.txOutputValue) + .sub(dissolutionFee), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Dissolution, + dissolutionTx.info, + proofFor(dissolutionTx.txHash), + mainUtxo, + reservationKey, + 2 + ) + + const wallet = await bridge.wallets(walletPubKeyHash) + expect(wallet.state).to.equal(walletState.Terminated) + expect(wallet.mainUtxoHash).to.equal(ZERO_BYTES32) + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.Stranded + ) + expect(await bridge.walletPendingDissolution(walletPubKeyHash)).to.equal( + 0 + ) + + const mainUtxoKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [mainUtxo.txHash, mainUtxo.txOutputIndex] + ) + ) + expect(await bridge.spentMainUTXOs(mainUtxoKey)).to.equal(true) + const outputKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [dissolutionTx.txHash, 0] + ) + ) + expect((await bridge.movedFundsSweepRequests(outputKey)).state).to.equal( + 0 + ) + }) + it("supersedes a cross-reservation lock when a late dissolution consumes its main UTXO", async () => { const first = await makeAcceptedReservation() const second = await makeAcceptedReservation() @@ -1341,11 +1504,11 @@ describe("Bridge - Reservation settlement", () => { }) describe("wallet lifecycle integration", () => { - before(async () => { + beforeEach(async () => { await createSnapshot() }) - after(async () => { + afterEach(async () => { await restoreSnapshot() }) From 345cd219e855a59b05ecbc482544048fd10063e4 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 07:50:29 -0400 Subject: [PATCH 08/14] Fix reservation branch CI checks --- solidity/contracts/bridge/Deposit.sol | 2 +- solidity/test/bridge/Bridge.Wallets.test.ts | 1 + solidity/test/maintainer/MaintainerProxy.test.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/solidity/contracts/bridge/Deposit.sol b/solidity/contracts/bridge/Deposit.sol index 20487cba9..396366cbe 100644 --- a/solidity/contracts/bridge/Deposit.sol +++ b/solidity/contracts/bridge/Deposit.sol @@ -207,7 +207,7 @@ library Deposit { "Vault is not trusted" ); - uint32 refundDeadline; + uint32 refundDeadline = 0; if (self.depositRevealAheadPeriod > 0) { refundDeadline = validateDepositRefundLocktime( self, diff --git a/solidity/test/bridge/Bridge.Wallets.test.ts b/solidity/test/bridge/Bridge.Wallets.test.ts index 6756bcc45..d8d0c502e 100644 --- a/solidity/test/bridge/Bridge.Wallets.test.ts +++ b/solidity/test/bridge/Bridge.Wallets.test.ts @@ -1497,6 +1497,7 @@ describe("Bridge - Wallets", () => { before(async () => { await createSnapshot() + walletRegistry.closeWallet.reset() await increaseTime( ( diff --git a/solidity/test/maintainer/MaintainerProxy.test.ts b/solidity/test/maintainer/MaintainerProxy.test.ts index dc14cc5bb..b2808760d 100644 --- a/solidity/test/maintainer/MaintainerProxy.test.ts +++ b/solidity/test/maintainer/MaintainerProxy.test.ts @@ -2057,7 +2057,7 @@ describe("MaintainerProxy", () => { expect(diff).to.be.gt(0) expect(diff).to.be.lt( - ethers.utils.parseUnits("2000000", "gwei") // 0,002 ETH + ethers.utils.parseUnits("2300000", "gwei") // 0,0023 ETH ) }) }) From a3512ddc942b82b56e4ca1433db487061712f7db Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 08:25:38 -0400 Subject: [PATCH 09/14] Allow Closing wallets to validate sweep proposals --- solidity/contracts/bridge/Bridge.sol | 4 +++- solidity/contracts/bridge/WalletProposalValidator.sol | 10 ++++++---- solidity/test/bridge/WalletProposalValidator.test.ts | 10 +++++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/solidity/contracts/bridge/Bridge.sol b/solidity/contracts/bridge/Bridge.sol index fe0190fec..1f53443cb 100644 --- a/solidity/contracts/bridge/Bridge.sol +++ b/solidity/contracts/bridge/Bridge.sol @@ -979,7 +979,9 @@ contract Bridge is /// - `mainUtxo` components must point to the recent main UTXO /// of the sweeping wallet, as currently known on the Ethereum chain. /// If there is no main UTXO, this parameter is ignored, - /// - The sweeping wallet must be in the Live or MovingFunds state, + /// - The sweeping wallet must be in the Live, MovingFunds, or Closing + /// state. A Closing wallet is returned to MovingFunds without being + /// counted as Live again, /// - The total Bitcoin transaction fee must be lesser or equal /// to `movedFundsSweepTxMaxTotalFee` governable parameter. function submitMovedFundsSweepProof( diff --git a/solidity/contracts/bridge/WalletProposalValidator.sol b/solidity/contracts/bridge/WalletProposalValidator.sol index 47e40ab76..a6324e368 100644 --- a/solidity/contracts/bridge/WalletProposalValidator.sol +++ b/solidity/contracts/bridge/WalletProposalValidator.sol @@ -825,7 +825,8 @@ contract WalletProposalValidator { /// @param proposal The moved funds sweep proposal to validate. /// @return True if the proposal is valid. Reverts otherwise. /// @dev Requirements: - /// - The source wallet must be in the Live or MovingFunds state, + /// - The sweeping wallet must be in the Live, MovingFunds, or Closing + /// state, /// - The moved funds sweep request identified by the proposed /// transaction hash and output index must be in the Pending state, /// - The transaction hash and output index from the proposal must @@ -843,11 +844,12 @@ contract WalletProposalValidator { proposal.walletPubKeyHash ); - // Make sure the wallet is in Live or MovingFunds state. + // Make sure the wallet is in Live, MovingFunds, or Closing state. require( wallet.state == Wallets.WalletState.Live || - wallet.state == Wallets.WalletState.MovingFunds, - "Source wallet is not in Live or MovingFunds state" + wallet.state == Wallets.WalletState.MovingFunds || + wallet.state == Wallets.WalletState.Closing, + "Sweeping wallet is not in Live or MovingFunds or Closing state" ); // Make sure the moved funds sweep request is valid. diff --git a/solidity/test/bridge/WalletProposalValidator.test.ts b/solidity/test/bridge/WalletProposalValidator.test.ts index 90290b3c2..1f66ceb6a 100644 --- a/solidity/test/bridge/WalletProposalValidator.test.ts +++ b/solidity/test/bridge/WalletProposalValidator.test.ts @@ -2619,10 +2619,6 @@ describe("WalletProposalValidator", () => { testName: "when wallet state is Unknown", walletState: walletState.Unknown, }, - { - testName: "when wallet state is Closing", - walletState: walletState.Closing, - }, { testName: "when wallet state is Closed", walletState: walletState.Closed, @@ -2666,7 +2662,7 @@ describe("WalletProposalValidator", () => { movedFundsSweepTxFee: 0, }) ).to.be.revertedWith( - "Source wallet is not in Live or MovingFunds state" + "Sweeping wallet is not in Live or MovingFunds or Closing state" ) }) }) @@ -2683,6 +2679,10 @@ describe("WalletProposalValidator", () => { testName: "when wallet state is MovingFunds", walletState: walletState.MovingFunds, }, + { + testName: "when wallet state is Closing", + walletState: walletState.Closing, + }, ] testData.forEach((test) => { From a74b31f64c16b3bd6bbc2544e9cd7b739e6a7abc Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 10:51:37 -0400 Subject: [PATCH 10/14] Fix residual reservation dissolution timeout --- solidity/contracts/bridge/Reservation.sol | 12 + .../Bridge.ReservationSettlement.test.ts | 251 +++++++++++++++--- 2 files changed, 221 insertions(+), 42 deletions(-) diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index 691295212..7c68cd311 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -880,6 +880,10 @@ library Reservation { reservation.state = ReservationState.Active; delete self.walletPendingDissolution[reservation.walletPubKeyHash]; + bool walletWasMovingFunds = self + .registeredWallets[reservation.walletPubKeyHash] + .state == Wallets.WalletState.MovingFunds; + // A wallet failing its dissolution duty is slashed like a // wallet failing a redemption: dissolution is the mechanism // that makes term + grace a hard stranding bound. @@ -887,6 +891,14 @@ library Reservation { reservation.walletPubKeyHash, walletMembersIDs ); + + // A Live wallet enters MovingFunds on its first failure and keeps + // the ordinary moving-funds deadline. A wallet already in + // MovingFunds has now also refused the terminal cleanup of its + // residual anchor, so terminate it at the dissolution bound. + if (walletWasMovingFunds) { + self.terminateWallet(reservation.walletPubKeyHash); + } } // slither-disable-next-line reentrancy-events diff --git a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts index 3846db024..42bf42bbb 100644 --- a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts +++ b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts @@ -16,6 +16,7 @@ import type { BankStub, Bridge, BridgeStub, + IWalletRegistry, IRelay, ReservationRouter, ReservationVault, @@ -82,6 +83,7 @@ describe("Bridge - Reservation settlement", () => { let bank: Bank & BankStub let relay: FakeContract + let walletRegistry: FakeContract let bridge: Bridge & BridgeStub & ReservationRouter let tbtc: TBTC & Contract let tbtcVault: TBTCVault & Contract @@ -113,6 +115,7 @@ describe("Bridge - Reservation settlement", () => { thirdParty, bank, relay, + walletRegistry, bridge, tbtc, tbtcVault, @@ -177,6 +180,21 @@ describe("Bridge - Reservation settlement", () => { }) } + async function expectWalletSeized(ecdsaWalletID: string) { + const { + redemptionTimeoutSlashingAmount, + redemptionTimeoutNotifierRewardMultiplier, + } = await bridge.redemptionParameters() + + expect(walletRegistry.seize).to.have.been.calledWith( + redemptionTimeoutSlashingAmount, + redemptionTimeoutNotifierRewardMultiplier, + await thirdParty.getAddress(), + ecdsaWalletID, + [] + ) + } + // ---- Bitcoin fixture crafting (regtest-style difficulty) ---- const REGTEST_BITS_LE = "ffff7f20" @@ -348,6 +366,52 @@ describe("Bridge - Reservation settlement", () => { return { fundingTx, anchorTx, reservationKey } } + async function completeMovingFundsWhileReservationsRemain() { + await liveWallet(secondWalletPubKeyHash) + + const mainUtxo = { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + txOutputIndex: 0, + txOutputValue: 7000000, + } + const ecdsaWalletID = ethers.utils.hexlify(ethers.utils.randomBytes(32)) + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID, + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: await lastBlockTime(), + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.MovingFunds, + movingFundsTargetWalletsCommitmentHash: ethers.utils.solidityKeccak256( + ["bytes20[]"], + [[secondWalletPubKeyHash]] + ), + }) + await bridge.setWalletMainUtxo(walletPubKeyHash, mainUtxo) + + const movingFundsTx = buildTx( + [{ txHash: mainUtxo.txHash, index: mainUtxo.txOutputIndex }], + [ + { + valueSat: BigNumber.from(mainUtxo.txOutputValue).sub(500), + script: p2wpkhScript(secondWalletPubKeyHash), + }, + ] + ) + const tx = await bridge + .connect(spvMaintainer) + .submitMovingFundsProof( + movingFundsTx.info, + proofFor(movingFundsTx.txHash), + mainUtxo, + walletPubKeyHash + ) + + return { ecdsaWalletID, movingFundsTx, tx } + } + // Requests an in-kind redemption of the given accepted reservation via // the real vault flow (fee paid in TBTC). async function requestRedemption( @@ -1534,49 +1598,10 @@ describe("Bridge - Reservation settlement", () => { it("records a confirmed main-UTXO move while reservation obligations remain", async () => { const { anchorTx, reservationKey } = await makeAcceptedReservation() - await liveWallet(secondWalletPubKeyHash) - - const mainUtxo = { - txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), - txOutputIndex: 0, - txOutputValue: 7000000, - } - await bridge.setWallet(walletPubKeyHash, { - ecdsaWalletID: ethers.utils.randomBytes(32), - mainUtxoHash: ZERO_BYTES32, - pendingRedemptionsValue: 0, - createdAt: await lastBlockTime(), - movingFundsRequestedAt: await lastBlockTime(), - closingStartedAt: 0, - pendingMovedFundsSweepRequestsCount: 0, - state: walletState.MovingFunds, - movingFundsTargetWalletsCommitmentHash: ethers.utils.solidityKeccak256( - ["bytes20[]"], - [[secondWalletPubKeyHash]] - ), - }) - await bridge.setWalletMainUtxo(walletPubKeyHash, mainUtxo) + const { movingFundsTx, tx } = + await completeMovingFundsWhileReservationsRemain() - const movingFundsTx = buildTx( - [{ txHash: mainUtxo.txHash, index: mainUtxo.txOutputIndex }], - [ - { - valueSat: BigNumber.from(mainUtxo.txOutputValue).sub(500), - script: p2wpkhScript(secondWalletPubKeyHash), - }, - ] - ) - - await expect( - bridge - .connect(spvMaintainer) - .submitMovingFundsProof( - movingFundsTx.info, - proofFor(movingFundsTx.txHash), - mainUtxo, - walletPubKeyHash - ) - ) + await expect(tx) .to.emit(bridge, "MovingFundsCompleted") .withArgs(walletPubKeyHash, movingFundsTx.txHash) @@ -1639,5 +1664,147 @@ describe("Bridge - Reservation settlement", () => { walletState.MovingFunds ) }) + + it("terminates a completed MovingFunds wallet that refuses residual reservation dissolution", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + const { ecdsaWalletID } = + await completeMovingFundsWhileReservationsRemain() + + const completedWallet = await bridge.wallets(walletPubKeyHash) + expect(completedWallet.state).to.equal(walletState.MovingFunds) + expect(completedWallet.movingFundsRequestedAt).to.equal(0) + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + await bridge + .connect(thirdParty) + .requestReservationDissolution(reservationKey) + + const dissolutionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(500), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + + await increaseTime(RESERVATION_ACTION_TIMEOUT + 1) + await walletRegistry.closeWallet.reset() + await walletRegistry.seize.reset() + const timeoutTx = await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + + await expect(timeoutTx) + .to.emit(bridge, "WalletTerminated") + .withArgs(ecdsaWalletID, walletPubKeyHash) + expect((await bridge.wallets(walletPubKeyHash)).state).to.equal( + walletState.Terminated + ) + await expectWalletSeized(ecdsaWalletID) + expect(walletRegistry.closeWallet).to.have.been.calledWith(ecdsaWalletID) + + // A dissolution transaction that was already confirmed remains + // late-settleable and supplies the evidence to strand the position. + await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Dissolution, + dissolutionTx.info, + proofFor(dissolutionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.Stranded + ) + }) + + it("keeps the existing moving-funds path for a Live wallet after dissolution timeout", async () => { + const { reservationKey } = await makeAcceptedReservation() + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + await bridge + .connect(thirdParty) + .requestReservationDissolution(reservationKey) + await increaseTime(RESERVATION_ACTION_TIMEOUT + 1) + + await walletRegistry.closeWallet.reset() + await walletRegistry.seize.reset() + const tx = await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + + await expect(tx).to.emit(bridge, "WalletMovingFunds") + const wallet = await bridge.wallets(walletPubKeyHash) + expect(wallet.state).to.equal(walletState.MovingFunds) + expect(wallet.movingFundsRequestedAt).to.equal(await lastBlockTime()) + await expectWalletSeized(wallet.ecdsaWalletID) + expect(walletRegistry.closeWallet).not.to.have.been.called + }) + + it("terminates an active MovingFunds wallet despite a reset deadline", async () => { + const { reservationKey } = await makeAcceptedReservation() + const movingFundsRequestedAt = await lastBlockTime() + await bridge.setWallet(walletPubKeyHash, { + ...(await bridge.wallets(walletPubKeyHash)), + state: walletState.MovingFunds, + movingFundsRequestedAt, + }) + await bridge.setLiveWalletsCount(0) + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + await bridge.resetMovingFundsTimeout(walletPubKeyHash) + const resetRequestedAt = (await bridge.wallets(walletPubKeyHash)) + .movingFundsRequestedAt + expect(resetRequestedAt).to.be.greaterThan(movingFundsRequestedAt) + + await bridge + .connect(thirdParty) + .requestReservationDissolution(reservationKey) + await increaseTime(RESERVATION_ACTION_TIMEOUT + 1) + + await walletRegistry.closeWallet.reset() + await walletRegistry.seize.reset() + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + + const wallet = await bridge.wallets(walletPubKeyHash) + expect(wallet.state).to.equal(walletState.Terminated) + expect(wallet.movingFundsRequestedAt).to.equal(resetRequestedAt) + await expectWalletSeized(wallet.ecdsaWalletID) + expect(walletRegistry.closeWallet).to.have.been.calledWith( + wallet.ecdsaWalletID + ) + }) + + it("does not slash or terminate an already-Terminated wallet again", async () => { + const { reservationKey } = await makeAcceptedReservation() + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + await bridge + .connect(thirdParty) + .requestReservationDissolution(reservationKey) + await bridge.setWallet(walletPubKeyHash, { + ...(await bridge.wallets(walletPubKeyHash)), + state: walletState.Terminated, + }) + await increaseTime(RESERVATION_ACTION_TIMEOUT + 1) + + await walletRegistry.closeWallet.reset() + await walletRegistry.seize.reset() + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + + expect((await bridge.wallets(walletPubKeyHash)).state).to.equal( + walletState.Terminated + ) + expect(walletRegistry.seize).not.to.have.been.called + expect(walletRegistry.closeWallet).not.to.have.been.called + }) }) }) From 8ca01c6247a89c5257e13e720bd35c533890f6fe Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 11:13:26 -0400 Subject: [PATCH 11/14] Restore retry credit after late reanchor --- solidity/contracts/bridge/Reservation.sol | 10 +- .../contracts/bridge/ReservationProofs.sol | 28 ++- .../Bridge.ReservationSettlement.test.ts | 209 ++++++++++++++++++ 3 files changed, 239 insertions(+), 8 deletions(-) diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index 7c68cd311..d41ad2c02 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -164,8 +164,9 @@ library Reservation { uint64 requestNonce; // True when the owner holds a single-use, fee-free redemption // retry entitlement, minted when a fee-paid redemption request - // times out through the wallet's fault. Consumed by the next - // retry request; voided by a dissolution request. + // times out through the wallet's fault. It is returned if a late + // re-anchor supersedes the retry that consumed it. Consumed by the + // next retry request; voided by a dissolution request. bool retryCredit; // Custody term length in seconds, snapshotted at acceptance. // Extensions extend by this value; later governance changes apply @@ -219,6 +220,10 @@ library Reservation { // Zero for other action types, and for dissolutions of wallets // with no main UTXO. bytes32 expectedMainUtxoHash; + // True when this redemption generation consumed the reservation's + // single-use retry entitlement. Needed to return the entitlement if + // a late re-anchor makes the generation impossible to settle. + bool usedRetryCredit; } event ReservationAcceptanceRequested( @@ -589,6 +594,7 @@ library Reservation { action.timeoutAt = uint32(block.timestamp) + self.redemptionTimeout; action.txMaxFee = self.reservationTxMaxFee; action.feePaid = feePaid; + action.usedRetryCredit = useRetryCredit; action.redeemer = redeemer; action.amount = reservation.mintedAmount; action.redeemerOutputScriptHash = keccak256(redeemerOutputScriptMem); diff --git a/solidity/contracts/bridge/ReservationProofs.sol b/solidity/contracts/bridge/ReservationProofs.sol index ee2aee85b..9a4c1aa9d 100644 --- a/solidity/contracts/bridge/ReservationProofs.sol +++ b/solidity/contracts/bridge/ReservationProofs.sol @@ -103,6 +103,8 @@ library ReservationProofs { uint64 requestNonce ); + event ReservationRetryCreditMinted(uint256 indexed reservationKey); + event ReservationLateSettled( uint256 indexed reservationKey, uint64 requestNonce, @@ -623,7 +625,7 @@ library ReservationProofs { if ( reservation.state == Reservation.ReservationState.ActionPending ) { - unwindPendingAction(self, reservation, reservationKey); + unwindPendingAction(self, reservation, reservationKey, true); } // slither-disable-next-line reentrancy-events @@ -862,7 +864,7 @@ library ReservationProofs { if ( reservation.state == Reservation.ReservationState.ActionPending ) { - unwindPendingAction(self, reservation, reservationKey); + unwindPendingAction(self, reservation, reservationKey, false); } // slither-disable-next-line reentrancy-events emit ReservationLateSettled( @@ -937,7 +939,12 @@ library ReservationProofs { "Invalid wallet dissolution lock" ); - unwindPendingAction(self, pendingReservation, pendingReservationKey); + unwindPendingAction( + self, + pendingReservation, + pendingReservationKey, + false + ); pendingReservation.state = Reservation.ReservationState.Active; } @@ -975,7 +982,7 @@ library ReservationProofs { // The pending generation cannot claim the transaction; its anchor // is gone, so unwind it. - unwindPendingAction(self, reservation, reservationKey); + unwindPendingAction(self, reservation, reservationKey, false); } /// @notice Unwinds the position's current pending generation during a @@ -983,11 +990,15 @@ library ReservationProofs { /// pending generation was authorized to spend has provably been /// consumed, so the generation can never settle. Its escrow is /// refunded (redemptions), its reserved capacity and locks are - /// released, and it is terminally marked `Superseded`. + /// released, and it is terminally marked `Superseded`. Retry + /// credit restoration is enabled only by the late-reanchor call + /// site, whose successful settlement leaves the reservation + /// Active on a replacement anchor. function unwindPendingAction( BridgeState.Storage storage self, Reservation.ReservationRequest storage reservation, - uint256 reservationKey + uint256 reservationKey, + bool restoreRetryCredit ) internal { uint64 pendingNonce = reservation.requestNonce; Reservation.ReservationAction storage pendingAction = self @@ -1002,6 +1013,11 @@ library ReservationProofs { pendingAction.state = Reservation.ActionState.Superseded; if (pendingAction.actionType == Reservation.ActionType.Redemption) { + if (restoreRetryCredit && pendingAction.usedRetryCredit) { + reservation.retryCredit = true; + emit ReservationRetryCreditMinted(reservationKey); + } + // Return the escrowed balance: the redeemer surrendered it for // an anchor that no longer exists. self.bank.transferBalance( diff --git a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts index 42bf42bbb..18be755f6 100644 --- a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts +++ b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts @@ -440,6 +440,33 @@ describe("Bridge - Reservation settlement", () => { .retryRedeemReservation(reservationKey, redeemerScript) } + async function requestAndTimeoutReanchor( + reservationKey: BigNumber, + anchorTxHash: string + ) { + await liveWallet(secondWalletPubKeyHash) + await bridge + .connect(thirdParty) + .requestReservationReanchor(reservationKey, secondWalletPubKeyHash) + + const reanchorTx = buildTx( + [{ txHash: anchorTxHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(500), + script: p2wpkhScript(secondWalletPubKeyHash), + }, + ] + ) + + await increaseTime(RESERVATION_ACTION_TIMEOUT + 1) + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + + return reanchorTx + } + describe("claim double-spend regression (timeout racing a confirmed redemption)", () => { before(async () => { await createSnapshot() @@ -665,6 +692,11 @@ describe("Bridge - Reservation settlement", () => { expect( (await bridge.reservationActions(reservationKey, 3)).state ).to.equal(ActionState.Superseded) + expect( + (await bridge.reservationActions(reservationKey, 3)).usedRetryCredit + ).to.be.true + expect((await bridge.reservations(reservationKey)).retryCredit).to.be + .false // Generation 3's escrow returned to its redeemer. (The generation 2 // timeout refund was re-spent on the retry, so the redeemer's net // Bank balance is exactly the unwound escrow.) @@ -676,6 +708,183 @@ describe("Bridge - Reservation settlement", () => { }) }) + describe("retry-credit restoration after a late re-anchor", () => { + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("restores a retry credit when the late re-anchor supersedes the retry", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() + + // Generation 2 is fee-paid. Its timeout refunds the escrow, mints the + // one-shot retry credit, and naturally moves the custody wallet into + // MovingFunds so anyone can request the migration below. + await requestRedemption(reservationKey, randomRedeemerScript()) + const { redemptionTimeout } = await bridge.redemptionParameters() + await increaseTime(redemptionTimeout + 1) + await bridge + .connect(thirdParty) + .notifyReservationActionTimeout(reservationKey, []) + expect((await bridge.reservations(reservationKey)).retryCredit).to.be.true + + // Generation 3 re-anchors the original reservation output but its + // proof is delayed until after the authorization times out. + const reanchorTx = await requestAndTimeoutReanchor( + reservationKey, + anchorTx.txHash + ) + + // Generation 4 consumes the one-shot credit and escrows the timeout + // refund for a fee-free retry against the still-current old anchor. + const retryScript = randomRedeemerScript() + await retryRedemption(reservationKey, retryScript) + expect((await bridge.reservations(reservationKey)).retryCredit).to.be + .false + expect(await bank.balanceOf(thirdParty.address)).to.equal(0) + expect(await bank.balanceOf(bridge.address)).to.equal(anchorAmount) + + const retryTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(1000), + script: retryScript.slice(4), + }, + ] + ) + + // The already-confirmed generation 3 transaction consumes the old + // anchor. Generation 4 can never settle, so it is superseded and its + // escrow is refunded. The consumed retry entitlement must return to + // the now-Active position. + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Reanchor, + reanchorTx.info, + proofFor(reanchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 3 + ) + + await expect(tx) + .to.emit(bridge, "ReservationActionSuperseded") + .withArgs(reservationKey, 4) + await expect(tx) + .to.emit(bridge, "ReservationRetryCreditMinted") + .withArgs(reservationKey) + + const reservation = await bridge.reservations(reservationKey) + expect(reservation.state).to.equal(ReservationState.Active) + expect(reservation.retryCredit).to.be.true + expect(reservation.walletPubKeyHash).to.equal(secondWalletPubKeyHash) + expect( + (await bridge.reservationActions(reservationKey, 4)).state + ).to.equal(ActionState.Superseded) + expect( + (await bridge.reservationActions(reservationKey, 4)).usedRetryCredit + ).to.be.true + expect(await bank.balanceOf(thirdParty.address)).to.equal(anchorAmount) + expect(await bank.balanceOf(bridge.address)).to.equal(0) + + // Supersession is terminal and the escrow was refunded exactly once. + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + retryTx.info, + proofFor(retryTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 4 + ) + ).to.be.revertedWith("Action is not settleable") + expect(await bank.balanceOf(thirdParty.address)).to.equal(anchorAmount) + expect(await bank.balanceOf(bridge.address)).to.equal(0) + + // The restored entitlement is usable again and is consumed exactly + // once by a new fee-free retry against the re-anchored position. + await retryRedemption(reservationKey, randomRedeemerScript()) + expect((await bridge.reservations(reservationKey)).retryCredit).to.be + .false + expect((await bridge.reservations(reservationKey)).state).to.equal( + ReservationState.ActionPending + ) + expect( + (await bridge.reservationActions(reservationKey, 5)).usedRetryCredit + ).to.be.true + expect(await bank.balanceOf(thirdParty.address)).to.equal(0) + expect(await bank.balanceOf(bridge.address)).to.equal(anchorAmount) + }) + + it("does not mint credit when the superseded redemption paid a fee", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() + + // Put the source wallet on its ordinary migration path without first + // creating a retry entitlement. + await bridge.setWallet(walletPubKeyHash, { + ...(await bridge.wallets(walletPubKeyHash)), + state: walletState.MovingFunds, + movingFundsRequestedAt: await lastBlockTime(), + }) + + const reanchorTx = await requestAndTimeoutReanchor( + reservationKey, + anchorTx.txHash + ) + + // Generation 3 is a fresh fee-paid redemption, not a retry. + const redeemerScript = randomRedeemerScript() + await requestRedemption(reservationKey, redeemerScript) + expect(await bank.balanceOf(bridge.address)).to.equal(anchorAmount) + + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Reanchor, + reanchorTx.info, + proofFor(reanchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + + await expect(tx) + .to.emit(bridge, "ReservationActionSuperseded") + .withArgs(reservationKey, 3) + await expect(tx).not.to.emit(bridge, "ReservationRetryCreditMinted") + + const reservation = await bridge.reservations(reservationKey) + expect(reservation.state).to.equal(ReservationState.Active) + expect(reservation.retryCredit).to.be.false + expect( + (await bridge.reservationActions(reservationKey, 3)).state + ).to.equal(ActionState.Superseded) + expect( + (await bridge.reservationActions(reservationKey, 3)).usedRetryCredit + ).to.be.false + expect(await bank.balanceOf(thirdParty.address)).to.equal(anchorAmount) + expect(await bank.balanceOf(bridge.address)).to.equal(0) + + await bank + .connect(thirdParty) + .approveBalance(reservationVault.address, anchorAmount) + await expect( + reservationVault + .connect(thirdParty) + .retryRedeemReservation(reservationKey, randomRedeemerScript()) + ).to.be.revertedWith("No retry entitlement") + }) + }) + describe("watchtower authorization enforcement in the proof path", () => { let redemptionWatchtower: Contract From eb239e07677efc6ae18e722700d2bd61493263c3 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 14:03:57 -0400 Subject: [PATCH 12/14] Reject inactive reservation reanchor targets --- .../bridge/WalletProposalValidator.sol | 10 ++- .../test/bridge/Bridge.Reservation.test.ts | 79 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/solidity/contracts/bridge/WalletProposalValidator.sol b/solidity/contracts/bridge/WalletProposalValidator.sol index a6324e368..e6717c4dc 100644 --- a/solidity/contracts/bridge/WalletProposalValidator.sol +++ b/solidity/contracts/bridge/WalletProposalValidator.sol @@ -1173,8 +1173,8 @@ contract WalletProposalValidator { /// - The reservation must be Active and custodied by the source /// wallet, /// - The named re-anchor authorization generation must be pending, - /// target the proposal's target wallet, and not be within the - /// timeout safety margin, + /// target the proposal's target wallet, whose current state must + /// still be Live, and not be within the timeout safety margin, /// - The proposed fee must be positive, within the authorization's /// snapshotted fee bound, and leave the re-anchored amount above /// the dust floor. @@ -1201,6 +1201,12 @@ contract WalletProposalValidator { "Re-anchor authorized for different target wallet" ); + require( + bridge.wallets(proposal.targetWalletPubKeyHash).state == + Wallets.WalletState.Live, + "Target wallet must be in Live state" + ); + require( /* solhint-disable-next-line not-rely-on-time */ block.timestamp < diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index 2499c3a40..a0eb6d5eb 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -2133,6 +2133,85 @@ describe("Bridge - Reservation", () => { expect(reservation.state).to.equal(ReservationState.Active) }) + it("rejects a terminated re-anchor target at signing but still settles an already-built transaction", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await liveWallet(secondWalletPubKeyHash) + + await bridge + .connect(bridgeGovernanceSigner) + .requestReservationReanchor(reservationKey, secondWalletPubKeyHash) + + const validator = await helpers.contracts.getContract( + "WalletProposalValidator" + ) + const proposal = { + sourceWalletPubKeyHash: walletPubKeyHash, + reservationKey, + requestNonce: 2, + targetWalletPubKeyHash: secondWalletPubKeyHash, + reanchorTxFee: 1000, + } + + // A transaction authorized while the target is Live may already have + // been signed and broadcast before the target's registry state changes. + expect(await validator.validateReservationReanchorProposal(proposal)).to + .be.true + const reanchorTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(proposal.reanchorTxFee), + script: p2wpkhScript(secondWalletPubKeyHash), + }, + ] + ) + + await bridge.setWallet(secondWalletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Terminated, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + + // The wallet must not sign a fresh transaction to a target that is no + // longer Live. + await expect( + validator.validateReservationReanchorProposal(proposal) + ).to.be.revertedWith("Target wallet must be in Live state") + + // Proof settlement deliberately remains state-independent: rejecting a + // transaction that was already broadcast would drop its BTC from the + // reservation accounting. + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Reanchor, + reanchorTx.info, + proofFor(reanchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + + await expect(tx) + .to.emit(bridge, "ReservationReanchored") + .withArgs( + reservationKey, + 2, + secondWalletPubKeyHash, + reanchorTx.txHash, + anchorAmount.sub(proposal.reanchorTxFee) + ) + expect( + (await bridge.reservations(reservationKey)).walletPubKeyHash + ).to.equal(secondWalletPubKeyHash) + }) + it("rejects a re-anchor paying a wallet other than the authorized target", async () => { const { anchorTx, reservationKey } = await makeAcceptedReservation() From 7ab98e8511b7708d714414ac6d40956cb469d714 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 14:30:16 -0400 Subject: [PATCH 13/14] Snapshot reserved redemption veto delays --- solidity/contracts/bridge/Redemption.sol | 24 +- .../contracts/bridge/RedemptionWatchtower.sol | 99 ++++- solidity/contracts/bridge/Reservation.sol | 20 + .../test/bridge/Bridge.Reservation.test.ts | 381 ++++++++++++++++++ 4 files changed, 502 insertions(+), 22 deletions(-) diff --git a/solidity/contracts/bridge/Redemption.sol b/solidity/contracts/bridge/Redemption.sol index b1fbbca65..426761baf 100644 --- a/solidity/contracts/bridge/Redemption.sol +++ b/solidity/contracts/bridge/Redemption.sol @@ -64,9 +64,10 @@ interface IRedemptionWatchtower { returns (uint32); /// @notice Returns the applicable veto delay for the given pending - /// reserved redemption generation. Zero when the watchtower is - /// not enabled (no guardians can object) or was permanently - /// disabled. + /// reserved redemption generation, selected from the immutable + /// schedule captured when the generation was requested. A + /// permanently disabled watchtower overrides the snapshot with + /// zero. /// @param reservationKey The key of the reservation. /// @param requestNonce The redemption generation. /// @return Reserved redemption veto delay. @@ -75,6 +76,23 @@ interface IRedemptionWatchtower { uint64 requestNonce ) external view returns (uint32); + /// @notice Returns the current three-level veto delay schedule to + /// snapshot for a newly requested reserved redemption. All + /// values are zero when the watchtower is not enabled, has been + /// disabled, or the amount is waived. + /// @param requestedAmount Reserved redemption amount in satoshis. + /// @return defaultDelay Delay before any guardian objection. + /// @return levelOneDelay Delay after one guardian objection. + /// @return levelTwoDelay Delay after two guardian objections. + function getReservedRedemptionDelaySchedule(uint64 requestedAmount) + external + view + returns ( + uint32 defaultDelay, + uint32 levelOneDelay, + uint32 levelTwoDelay + ); + /// @notice Determines whether a reserved redemption request is /// considered safe: neither the reservation owner nor the /// redeemer may be banned. Objection state is per generation, diff --git a/solidity/contracts/bridge/RedemptionWatchtower.sol b/solidity/contracts/bridge/RedemptionWatchtower.sol index a27a50d88..2497f3c2a 100644 --- a/solidity/contracts/bridge/RedemptionWatchtower.sol +++ b/solidity/contracts/bridge/RedemptionWatchtower.sol @@ -441,8 +441,9 @@ contract RedemptionWatchtower is OwnableUpgradeable { /// - The generation must not have been vetoed already, /// - The guardian must not have already objected to it, /// - The generation must be the reservation's pending redemption, - /// - The generation must be within its veto delay period, unless - /// it was requested before the watchtower was enabled. + /// - The generation must be within the veto delay snapshotted for + /// its current objection level; a permanently disabled watchtower + /// makes the effective delay zero. function raiseReservedObjection(uint256 reservationKey, uint64 requestNonce) external onlyGuardian @@ -470,17 +471,13 @@ contract RedemptionWatchtower is OwnableUpgradeable { "Reserved redemption does not exist" ); - if (action.requestedAt >= watchtowerEnabledAt) { - require( - /* solhint-disable-next-line not-rely-on-time */ - block.timestamp < - action.requestedAt + - _redemptionDelay(veto.objectionsCount, action.amount), - "Redemption veto delay period expired" - ); - } else { - emit VetoPeriodCheckOmitted(vetoKey); - } + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp < + uint256(action.requestedAt) + + _reservedRedemptionDelay(action, veto.objectionsCount), + "Redemption veto delay period expired" + ); objections[objectionKey] = true; veto.redeemer = action.redeemer; @@ -522,13 +519,15 @@ contract RedemptionWatchtower is OwnableUpgradeable { /// @param reservationKey The key of the reservation. /// @param requestNonce The redemption generation. /// @return Reserved redemption veto delay. - /// @dev The delay is zero when the watchtower has not been enabled - /// (no guardians exist, so a delay would protect nothing) or has - /// been permanently disabled. + /// @dev The returned level is selected from the immutable schedule the + /// Bridge captured when the generation was requested. A permanently + /// disabled watchtower overrides every captured schedule with zero. function getReservedRedemptionDelay( uint256 reservationKey, uint64 requestNonce ) external view returns (uint32) { + // Preserve the pre-enable API behavior: no guardians exist, so no + // reservation lookup is needed to establish that the delay is zero. // slither-disable-next-line incorrect-equality if (watchtowerEnabledAt == 0) { return 0; @@ -544,13 +543,75 @@ contract RedemptionWatchtower is OwnableUpgradeable { ); return - _redemptionDelay( + _reservedRedemptionDelay( + action, vetoProposals[reservedVetoKey(reservationKey, requestNonce)] - .objectionsCount, - action.amount + .objectionsCount ); } + /// @notice Returns the current three-level veto delay schedule to + /// snapshot for a newly requested reserved redemption. + /// @param requestedAmount Reserved redemption amount in satoshis. + /// @return reservedDefaultDelay Delay before any guardian objection. + /// @return reservedLevelOneDelay Delay after one guardian objection. + /// @return reservedLevelTwoDelay Delay after two guardian objections. + /// @dev Returns an all-zero schedule when no guardian veto policy applies + /// at request time: the watchtower is not enabled, has been disabled, + /// or the requested amount is below the waiver limit. + function getReservedRedemptionDelaySchedule(uint64 requestedAmount) + external + view + returns ( + uint32 reservedDefaultDelay, + uint32 reservedLevelOneDelay, + uint32 reservedLevelTwoDelay + ) + { + if ( + // slither-disable-next-line incorrect-equality + watchtowerEnabledAt == 0 || + watchtowerDisabledAt != 0 || + requestedAmount < waivedAmountLimit + ) { + return (0, 0, 0); + } + + return (defaultDelay, levelOneDelay, levelTwoDelay); + } + + /// @notice Selects a reserved redemption generation's snapshotted veto + /// delay for the given number of objections. + /// @dev A permanently disabled watchtower makes the effective delay zero + /// for every generation, including those requested before shutdown. + function _reservedRedemptionDelay( + Reservation.ReservationAction memory action, + uint8 objectionsCount + ) internal view returns (uint32) { + if (watchtowerDisabledAt != 0) { + return 0; + } + + if ( + // slither-disable-next-line incorrect-equality + objectionsCount == 0 + ) { + return action.watchtowerDefaultDelay; + } else if ( + // slither-disable-next-line incorrect-equality + objectionsCount == 1 + ) { + return action.watchtowerLevelOneDelay; + } else if ( + // slither-disable-next-line incorrect-equality + objectionsCount == 2 + ) { + return action.watchtowerLevelTwoDelay; + } else { + revert("No delay for given objections count"); + } + } + /// @notice Determines whether a reserved redemption request is /// considered safe: neither the reservation owner nor the /// redeemer may be banned. Objection state is per generation, diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index d41ad2c02..466284f1e 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -224,6 +224,17 @@ library Reservation { // single-use retry entitlement. Needed to return the entitlement if // a late re-anchor makes the generation impossible to settle. bool usedRetryCredit; + // Reserved-redemption veto delay with no guardian objections, + // snapshotted from the watchtower policy at request time. Zero when + // the watchtower is absent, not enabled, disabled, or the amount is + // waived. + uint32 watchtowerDefaultDelay; + // Reserved-redemption veto delay after one guardian objection, + // snapshotted from the watchtower policy at request time. + uint32 watchtowerLevelOneDelay; + // Reserved-redemption veto delay after two guardian objections, + // snapshotted from the watchtower policy at request time. + uint32 watchtowerLevelTwoDelay; } event ReservationAcceptanceRequested( @@ -599,6 +610,15 @@ library Reservation { action.amount = reservation.mintedAmount; action.redeemerOutputScriptHash = keccak256(redeemerOutputScriptMem); + if (self.redemptionWatchtower != address(0)) { + ( + action.watchtowerDefaultDelay, + action.watchtowerLevelOneDelay, + action.watchtowerLevelTwoDelay + ) = IRedemptionWatchtower(self.redemptionWatchtower) + .getReservedRedemptionDelaySchedule(reservation.mintedAmount); + } + // slither-disable-next-line reentrancy-events emit ReservedRedemptionRequested( reservationKey, diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index a0eb6d5eb..0d1c261ff 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -1236,6 +1236,212 @@ describe("Bridge - Reservation", () => { }) }) + describe("RedemptionWatchtower reserved delay snapshots", () => { + const reservationKey = 667 + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const amountSat = BigNumber.from(100000000) + const redeemerOutputScript = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + let redemptionWatchtower: Contract + let guardianSigners: SignerWithAddress[] + let watchtowerManager: SignerWithAddress + + async function updateDelayPolicy( + defaultDelay: number, + levelOneDelay: number, + levelTwoDelay: number, + waivedAmountLimit: BigNumber | number + ) { + return redemptionWatchtower + .connect(watchtowerManager) + .updateWatchtowerParameters( + await redemptionWatchtower.watchtowerLifetime(), + 20, + await redemptionWatchtower.vetoFreezePeriod(), + defaultDelay, + levelOneDelay, + levelTwoDelay, + waivedAmountLimit + ) + } + + async function requestRedemption() { + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + } + + before(async () => { + await createSnapshot() + await wireReservations() + + redemptionWatchtower = await helpers.contracts.getContract( + "RedemptionWatchtower" + ) + guardianSigners = (await ethers.getSigners()).slice(10, 13) + const watchtowerOwner = await impersonateContract( + await redemptionWatchtower.owner() + ) + await redemptionWatchtower.connect(watchtowerOwner).enableWatchtower( + governance.address, + guardianSigners.map((guardian) => guardian.address) + ) + watchtowerManager = await impersonateContract( + await redemptionWatchtower.manager() + ) + await bridge + .connect(bridgeGovernanceSigner) + .setRedemptionWatchtower(redemptionWatchtower.address) + + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + }) + + after(async () => { + await restoreSnapshot() + }) + + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("keeps every objection-level delay on the old generation and applies updates to the next one", async () => { + const defaultDelay = await redemptionWatchtower.defaultDelay() + const levelOneDelay = await redemptionWatchtower.levelOneDelay() + const levelTwoDelay = await redemptionWatchtower.levelTwoDelay() + await requestRedemption() + + const oldAction = await bridge.reservationActions(reservationKey, 1) + expect(oldAction.watchtowerDefaultDelay).to.equal(defaultDelay) + expect(oldAction.watchtowerLevelOneDelay).to.equal(levelOneDelay) + expect(oldAction.watchtowerLevelTwoDelay).to.equal(levelTwoDelay) + + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(defaultDelay) + + const newDefaultDelay = defaultDelay + 60 + const newLevelOneDelay = levelOneDelay + 120 + const newLevelTwoDelay = levelTwoDelay + 180 + await updateDelayPolicy( + newDefaultDelay, + newLevelOneDelay, + newLevelTwoDelay, + 0 + ) + + // Policy changes never rewrite the authorization window of an already + // requested generation. + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(defaultDelay) + + await redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey, 1) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(levelOneDelay) + + await redemptionWatchtower + .connect(guardianSigners[1]) + .raiseReservedObjection(reservationKey, 1) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(levelTwoDelay) + + await redemptionWatchtower + .connect(guardianSigners[2]) + .raiseReservedObjection(reservationKey, 1) + await redemptionWatchtower + .connect(watchtowerManager) + .unban(thirdParty.address) + + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 2) + ).to.equal(newDefaultDelay) + }) + + it("keeps a waived generation objection-free after the waiver policy changes", async () => { + const defaultDelay = await redemptionWatchtower.defaultDelay() + const levelOneDelay = await redemptionWatchtower.levelOneDelay() + const levelTwoDelay = await redemptionWatchtower.levelTwoDelay() + await updateDelayPolicy( + defaultDelay, + levelOneDelay, + levelTwoDelay, + amountSat.add(1) + ) + await requestRedemption() + + const action = await bridge.reservationActions(reservationKey, 1) + expect(action.watchtowerDefaultDelay).to.equal(0) + expect(action.watchtowerLevelOneDelay).to.equal(0) + expect(action.watchtowerLevelTwoDelay).to.equal(0) + + // Removing the waiver affects future generations only. This generation + // retains the zero schedule captured when it was requested. + await updateDelayPolicy(defaultDelay, levelOneDelay, levelTwoDelay, 0) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(0) + await expect( + redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey, 1) + ).to.be.revertedWith("Redemption veto delay period expired") + }) + + it("captures a zero schedule after the watchtower is disabled", async () => { + const lifetimeExpiresAt = + (await redemptionWatchtower.watchtowerEnabledAt()) + + (await redemptionWatchtower.watchtowerLifetime()) + await increaseTime(lifetimeExpiresAt - (await lastBlockTime())) + await redemptionWatchtower.connect(thirdParty).disableWatchtower() + + await requestRedemption() + + const action = await bridge.reservationActions(reservationKey, 1) + expect(action.watchtowerDefaultDelay).to.equal(0) + expect(action.watchtowerLevelOneDelay).to.equal(0) + expect(action.watchtowerLevelTwoDelay).to.equal(0) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(0) + await expect( + redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey, 1) + ).to.be.revertedWith("Redemption veto delay period expired") + }) + }) + describe("WalletProposalValidator", () => { const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" const secondWalletPubKeyHash = "0xafcdf88d15a0e0c2134dbbc9f6da24d0e26c8f21" @@ -1971,6 +2177,181 @@ describe("Bridge - Reservation", () => { expect(await tbtc.totalSupply()).to.equal(supplyBefore.sub(grossTbtc)) }) + it("keeps a signed reserved redemption provable after the manager raises delays", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + const redemptionWatchtower = await helpers.contracts.getContract( + "RedemptionWatchtower" + ) + const guardian = (await ethers.getSigners())[10] + const watchtowerOwner = await impersonateContract( + await redemptionWatchtower.owner() + ) + await redemptionWatchtower + .connect(watchtowerOwner) + .enableWatchtower(governance.address, [guardian.address]) + await bridge + .connect(bridgeGovernanceSigner) + .setRedemptionWatchtower(redemptionWatchtower.address) + + const redeemerScript = `0x16${p2wpkhScript( + ethers.utils.hexlify(ethers.utils.randomBytes(20)) + )}` + await requestRedemptionViaVault( + reservationKey, + anchorAmount, + thirdParty.address, + redeemerScript + ) + + const defaultDelay = await redemptionWatchtower.defaultDelay() + const levelOneDelay = await redemptionWatchtower.levelOneDelay() + const levelTwoDelay = await redemptionWatchtower.levelTwoDelay() + await increaseTime(defaultDelay + 1) + + const watchtowerManager = await impersonateContract( + await redemptionWatchtower.manager() + ) + await redemptionWatchtower + .connect(watchtowerManager) + .updateWatchtowerParameters( + await redemptionWatchtower.watchtowerLifetime(), + 20, + await redemptionWatchtower.vetoFreezePeriod(), + defaultDelay + 3600, + levelOneDelay + 3600, + levelTwoDelay + 3600, + 0 + ) + + // The old signing window is closed permanently. Raising live policy + // cannot reopen guardian objections against the already-signed spend. + await expect( + redemptionWatchtower + .connect(guardian) + .raiseReservedObjection(reservationKey, 2) + ).to.be.revertedWith("Redemption veto delay period expired") + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 2) + ).to.equal(defaultDelay) + + const validator = await helpers.contracts.getContract( + "WalletProposalValidator" + ) + expect( + await validator.validateReservedRedemptionProposal({ + walletPubKeyHash, + reservationKey, + requestNonce: 2, + redemptionTxFee: 1000, + }) + ).to.be.true + + const redemptionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(1000), + script: redeemerScript.slice(4), + }, + ] + ) + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + ) + .to.emit(bridge, "ReservedRedemptionCompleted") + .withArgs(reservationKey, 2, redemptionTx.txHash) + }) + + it("removes the delay from a pre-existing generation after permanent watchtower shutdown", async () => { + const redemptionWatchtower = await helpers.contracts.getContract( + "RedemptionWatchtower" + ) + const guardian = (await ethers.getSigners())[10] + const watchtowerOwner = await impersonateContract( + await redemptionWatchtower.owner() + ) + await redemptionWatchtower + .connect(watchtowerOwner) + .enableWatchtower(governance.address, [guardian.address]) + await bridge + .connect(bridgeGovernanceSigner) + .setRedemptionWatchtower(redemptionWatchtower.address) + + // Let the finite watchtower lifetime expire without disabling it yet. + // The generation requested after expiry still captures the live policy, + // then permanent shutdown must override that snapshot globally. + const lifetimeExpiresAt = + (await redemptionWatchtower.watchtowerEnabledAt()) + + (await redemptionWatchtower.watchtowerLifetime()) + await increaseTime(lifetimeExpiresAt - (await lastBlockTime())) + + // Create the reservation after the time jump so its redemption window + // remains active while the expired-but-not-yet-disabled watchtower still + // exposes its nonzero policy. + const { anchorTx, reservationKey } = await makeAcceptedReservation() + + const redeemerScript = `0x16${p2wpkhScript( + ethers.utils.hexlify(ethers.utils.randomBytes(20)) + )}` + await requestRedemptionViaVault( + reservationKey, + anchorAmount, + thirdParty.address, + redeemerScript + ) + + const defaultDelay = await redemptionWatchtower.defaultDelay() + const action = await bridge.reservationActions(reservationKey, 2) + expect(action.watchtowerDefaultDelay).to.equal(defaultDelay) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 2) + ).to.equal(defaultDelay) + + await redemptionWatchtower.connect(thirdParty).disableWatchtower() + + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 2) + ).to.equal(0) + await expect( + redemptionWatchtower + .connect(guardian) + .raiseReservedObjection(reservationKey, 2) + ).to.be.revertedWith("Redemption veto delay period expired") + + const redemptionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(1000), + script: redeemerScript.slice(4), + }, + ] + ) + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey, + 2 + ) + ) + .to.emit(bridge, "ReservedRedemptionCompleted") + .withArgs(reservationKey, 2, redemptionTx.txHash) + }) + it("keeps redemption provable when txMaxFee exceeds the anchor (underflow guard)", async () => { // Regression: the redemption fee bound is snapshotted at request // time, but a snapshot can still exceed the anchor value. The From 9da585112d4c34c1cd339f5c40c4b085e335115d Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 14:45:50 -0400 Subject: [PATCH 14/14] Adapt reservation settlement fee bounds --- .../bridge/Bridge.ReservationSettlement.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts index 18be755f6..a18c3b619 100644 --- a/solidity/test/bridge/Bridge.ReservationSettlement.test.ts +++ b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts @@ -424,7 +424,7 @@ describe("Bridge - Reservation settlement", () => { .approve(reservationVault.address, grossTbtc.add(redemptionFee)) await reservationVault .connect(thirdParty) - .redeemReservation(reservationKey, redeemerScript) + .redeemReservation(reservationKey, redeemerScript, redemptionFee) } // Retries via the Bank-balance path after a timeout refund. @@ -1744,7 +1744,11 @@ describe("Bridge - Reservation settlement", () => { await expect( reservationVault .connect(thirdParty) - .redeemReservation(reservationKey, randomRedeemerScript()) + .redeemReservation( + reservationKey, + randomRedeemerScript(), + redemptionFee + ) ).to.be.revertedWith("Reservation past grace period") }) @@ -1771,7 +1775,11 @@ describe("Bridge - Reservation settlement", () => { await expect( reservationVault .connect(thirdParty) - .redeemReservation(reservationKey, randomRedeemerScript()) + .redeemReservation( + reservationKey, + randomRedeemerScript(), + redemptionFee + ) ).to.be.revertedWith("Wallet must be in Live or MovingFunds state") }) })