diff --git a/solidity/contracts/bridge/BridgeGovernance.sol b/solidity/contracts/bridge/BridgeGovernance.sol index 21b2e780c..5c0e9452b 100644 --- a/solidity/contracts/bridge/BridgeGovernance.sol +++ b/solidity/contracts/bridge/BridgeGovernance.sol @@ -291,6 +291,10 @@ 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, @@ -299,6 +303,7 @@ contract BridgeGovernance is Ownable { uint32 newReservationGracePeriod, uint64 newReservationMaxTotalAmount, uint32 newMaxReservationsPerWallet, + uint32 newReservationActionTimeout, uint256 timestamp ); @@ -1834,7 +1839,8 @@ contract BridgeGovernance is Ownable { uint32 _newReservationTermSeconds, uint32 _newReservationGracePeriod, uint64 _newReservationMaxTotalAmount, - uint32 _newMaxReservationsPerWallet + uint32 _newMaxReservationsPerWallet, + uint32 _newReservationActionTimeout ) external onlyOwner { reservationData.beginReservationParametersUpdate( _newReservationVault, @@ -1843,7 +1849,8 @@ contract BridgeGovernance is Ownable { _newReservationTermSeconds, _newReservationGracePeriod, _newReservationMaxTotalAmount, - _newMaxReservationsPerWallet + _newMaxReservationsPerWallet, + _newReservationActionTimeout ); } @@ -1861,7 +1868,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..802695c1d 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 PendingReservedDeposit { + // 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; @@ -374,6 +385,30 @@ 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; + // Reveal-time facts for deposits routed to the reservation vault. + // The exact refund deadline is immutable even when governance later + // changes `depositRevealAheadPeriod`. + 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 @@ -381,7 +416,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/Deposit.sol b/solidity/contracts/bridge/Deposit.sol index 6086f2d4e..20487cba9 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,16 @@ library Deposit { ); } + if ( + reveal.vault != address(0) && reveal.vault == self.reservationVault + ) { + self.pendingReservedDeposit[depositKey] = BridgeState + .PendingReservedDeposit( + reveal.walletPubKeyHash, + refundDeadline + ); + } + _emitDepositRevealedEvent(fundingTxHash, fundingOutputAmount, reveal); } @@ -426,10 +439,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 +463,7 @@ library Deposit { depositRefundableTimestamp, "Deposit refund locktime is too close" ); + + return depositRefundableTimestamp; } } 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/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/MovingFunds.sol b/solidity/contracts/bridge/MovingFunds.sol index e7f22c6a1..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 */ @@ -595,6 +617,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 @@ -659,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( @@ -706,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); @@ -766,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( @@ -775,7 +804,6 @@ library MovingFunds { BitcoinTx.UTXO calldata mainUtxo ) internal - view returns ( Wallets.Wallet storage wallet, BitcoinTx.UTXO memory resolvedMainUtxo @@ -786,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. @@ -1015,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/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..a27a50d88 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,58 @@ 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) { + // slither-disable-next-line incorrect-equality + 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..691295212 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 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. + /// @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,33 +155,79 @@ 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. } - event ReservationAccepted( + /// @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, - address indexed owner, - bytes32 anchorTxHash, - uint64 anchorAmount, - uint32 expiresAt + uint64 depositAmount, + uint64 txMaxFee, + uint32 timeoutAt ); event ReservationExtended( @@ -155,249 +237,195 @@ library Reservation { event ReservedRedemptionRequested( uint256 indexed reservationKey, + uint64 requestNonce, address indexed redeemer, bytes redeemerOutputScript, uint64 mintedAmount, - uint64 txMaxFee + uint64 txMaxFee, + bool feePaid ); - event ReservedRedemptionCompleted( + event ReservationReanchorRequested( uint256 indexed reservationKey, - bytes32 redemptionTxHash + uint64 requestNonce, + bytes20 indexed sourceWalletPubKeyHash, + bytes20 indexed targetWalletPubKeyHash, + uint64 txMaxFee ); - event ReservedRedemptionTimedOut( + event ReservationDissolutionRequested( uint256 indexed reservationKey, - bytes20 indexed walletPubKeyHash + uint64 requestNonce, + bytes20 indexed walletPubKeyHash, + uint64 txMaxFee, + bytes32 expectedMainUtxoHash ); - event ReservedRedemptionVetoed(uint256 indexed reservationKey); - - event ReservationReanchored( + event ReservationActionTimedOut( uint256 indexed reservationKey, - bytes20 indexed newWalletPubKeyHash, - bytes32 newAnchorTxHash, - uint64 newAnchorAmount + uint64 requestNonce, + ActionType actionType ); - event ReservationDissolved( + event ReservedRedemptionVetoed( uint256 indexed reservationKey, - bytes20 indexed walletPubKeyHash, - bytes32 dissolutionTxHash + uint64 requestNonce ); + 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 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. + 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(deposit.revealedAt != 0, "Deposit not revealed"); + require(deposit.sweptAt == 0, "Deposit already swept"); require( - anchorAmount >= self.reservationMinAmount, - "Reservation amount too small" + deposit.vault == self.reservationVault, + "Deposit not routed to the reservation vault" ); - // 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. + + BridgeState.PendingReservedDeposit storage reservedDeposit = self + .pendingReservedDeposit[reservationKey]; require( - deposit.amount - anchorAmount <= self.reservationTxMaxFee, - "Transaction fee is too high" + reservedDeposit.walletPubKeyHash == walletPubKeyHash, + "Wallet is not the deposit's designated wallet" ); - 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; + + // 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 <= reservedDeposit.refundDeadline, + "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 +439,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 - ); - } - - /// @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" + deposit.amount, + action.txMaxFee, + timeoutAt ); - - 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 +525,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 +573,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 */ + action.requestedAt = uint32(block.timestamp); /* solhint-disable-next-line not-rely-on-time */ - reservation.redemptionRequestedAt = uint32(block.timestamp); - reservation.redemptionTxMaxFee = self.reservationTxMaxFee; + 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 +611,390 @@ 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 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), + /// - 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" + ); + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp <= + uint256(reservation.expiresAt) + reservation.gracePeriod, + "Reservation past grace period" ); - - 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 +1005,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 +1020,8 @@ library Reservation { uint32 reservationTermSeconds, uint32 reservationGracePeriod, uint64 reservationMaxTotalAmount, - uint32 maxReservationsPerWallet + uint32 maxReservationsPerWallet, + uint32 reservationActionTimeout ) external { require( reservationTxMaxFee > 0, @@ -1003,6 +1035,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 +1055,7 @@ library Reservation { self.reservationGracePeriod = reservationGracePeriod; self.reservationMaxTotalAmount = reservationMaxTotalAmount; self.maxReservationsPerWallet = maxReservationsPerWallet; + self.reservationActionTimeout = reservationActionTimeout; emit ReservationParametersUpdated( reservationMinAmount, @@ -1026,167 +1063,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..ee2aee85b --- /dev/null +++ b/solidity/contracts/bridge/ReservationProofs.sol @@ -0,0 +1,1192 @@ +// 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; + + 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 { + 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); + delete self.pendingReservedDeposit[reservationKey]; + } + + /// @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 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 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 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 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); + } + + // slither-disable-next-line reentrancy-events + emit 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; + + // slither-disable-next-line reentrancy-events + emit 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), 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, + 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 + ]; + bool walletTerminated = wallet.state == Wallets.WalletState.Terminated; + + 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 + ); + } + + 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 { + require( + action.expectedMainUtxoHash == bytes32(0), + "Recorded main UTXO can no longer be spent" + ); + + 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++; + } + } + + if (late) { + if ( + reservation.state == Reservation.ReservationState.ActionPending + ) { + unwindPendingAction(self, reservation, reservationKey); + } + // slither-disable-next-line reentrancy-events + emit 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); + 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( + reservationKey, + requestNonce, + walletPubKeyHash, + dissolutionTxHash + ); + } + + /// @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 + /// 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 + ]; + } + } + + // 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. + /// 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..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 @@ -432,10 +426,41 @@ 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; + // 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; - beginWalletClosing(self, walletPubKeyHash); + if ( + self.walletReservationsCount[walletPubKeyHash] == 0 && + wallet.pendingMovedFundsSweepRequestsCount == 0 + ) { + beginWalletClosing(self, walletPubKeyHash); + } + } + + /// @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 @@ -497,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, @@ -510,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 ( @@ -528,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 @@ -593,11 +623,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 + if ( + wallet.mainUtxoHash == bytes32(0) && + self.walletReservationsCount[walletPubKeyHash] == 0 && + wallet.pendingMovedFundsSweepRequestsCount == 0 + ) { + // 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 + // 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,7 +665,21 @@ library Wallets { BridgeState.Storage storage self, bytes20 walletPubKeyHash ) internal { + // 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" + ); + // Initialize the closing period. wallet.state = WalletState.Closing; /* solhint-disable-next-line not-rely-on-time */ @@ -647,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/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"], }, }) diff --git a/solidity/hardhat.config.ts b/solidity/hardhat.config.ts index 5e45d3a45..90c2cd599 100644 --- a/solidity/hardhat.config.ts +++ b/solidity/hardhat.config.ts @@ -19,6 +19,73 @@ 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) + 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. + } + return false + }) + ) + return result + } + } + + 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: { 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.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index 04c970ad2..2499c3a40 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,13 +1019,13 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) - const beginReceipt = await beginTx.wait() - const beginBlock = await ethers.provider.getBlock( - beginReceipt.blockNumber - ) + const beginTimestamp = ( + await ethers.provider.getBlock(beginReceipt.blockNumber) + ).timestamp await expect(beginTx) .to.emit(bridgeGovernance, "ReservationParametersUpdateStarted") @@ -860,7 +1037,8 @@ describe("Bridge - Reservation", () => { RESERVATION_GRACE, RESERVATION_MAX_TOTAL, MAX_RESERVATIONS_PER_WALLET, - beginBlock.timestamp + RESERVATION_ACTION_TIMEOUT, + beginTimestamp ) await expect( @@ -888,7 +1066,8 @@ describe("Bridge - Reservation", () => { RESERVATION_TERM, RESERVATION_GRACE, RESERVATION_MAX_TOTAL, - MAX_RESERVATIONS_PER_WALLET + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT ) }) }) @@ -902,6 +1081,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() @@ -950,7 +1132,7 @@ describe("Bridge - Reservation", () => { movingFundsRequestedAt: 0, closingStartedAt: 0, pendingMovedFundsSweepRequestsCount: 0, - state: walletState.Terminated, + state: walletState.Live, movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, }) await bridge.setReservation( @@ -958,57 +1140,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 @@ -1016,10 +1198,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", () => { @@ -1033,6 +1247,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 () => { @@ -1059,6 +1274,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( @@ -1098,6 +1324,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, @@ -1128,87 +1360,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 @@ -1216,7 +1489,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") @@ -1263,7 +1537,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( [ @@ -1297,6 +1572,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 }], [ @@ -1314,15 +1600,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 } } @@ -1355,6 +1635,158 @@ 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("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() @@ -1363,6 +1795,7 @@ describe("Bridge - Reservation", () => { .to.emit(bridge, "ReservationAccepted") .withArgs( reservationKey, + 1, walletPubKeyHash, thirdParty.address, anchorTx.txHash, @@ -1377,7 +1810,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) @@ -1394,9 +1834,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 () => { @@ -1405,6 +1846,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. @@ -1427,6 +1934,7 @@ describe("Bridge - Reservation", () => { await reservationVault .connect(thirdParty) .redeemReservation(reservationKey, redeemerScript) + const redemptionTx = buildTx( [{ txHash: anchorTx.txHash, index: 0 }], [ @@ -1444,30 +1952,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( @@ -1477,7 +1990,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( @@ -1504,20 +2018,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( @@ -1550,6 +2065,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) }] @@ -1561,17 +2085,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( @@ -1585,47 +2115,115 @@ 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("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() + + // 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( @@ -1633,12 +2231,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..3846db024 --- /dev/null +++ b/solidity/test/bridge/Bridge.ReservationSettlement.test.ts @@ -0,0 +1,1643 @@ +/* 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("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("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() + 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) + .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: 1, + } + 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)) + 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 + }) + }) + + 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", () => { + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(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 + ) + }) + + 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 = 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) + expect(sourceWallet.movingFundsRequestedAt).to.equal(0) + expect(sourceWallet.movingFundsTargetWalletsCommitmentHash).to.equal( + ZERO_BYTES32 + ) + + 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) + + // 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/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 c6625c9ac..e7a3df108 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,95 @@ 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 === "pendingReservedDeposit" + ) + ? 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", @@ -341,7 +437,8 @@ describe("ReservationRouter", () => { 31536000, 2592000, 100000000000, - 10 + 10, + 172800 ) ).to.be.revertedWith("Caller is not the governance") @@ -352,12 +449,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") }) }) })