From f362d6b354c4e348cd6fb9c017f0719ec6026e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Mon, 7 Sep 2026 10:09:08 +0000 Subject: [PATCH 1/2] feat(bridge): re-land reserved-redemption and veto surface from #1121 Re-applies the reverted #1121 diff (requestReservedRedemption, notifyReservedRedemptionVeto, extendReservation, RedemptionWatchtower) onto the current reservations-upgrade tip, unmodified, as the base for the P0 fixes identified in the #1116 multi-agent review before re-landing. --- .../contracts/bridge/IReservationBridge.sol | 24 + solidity/contracts/bridge/Redemption.sol | 45 + .../contracts/bridge/RedemptionWatchtower.sol | 231 +++ solidity/contracts/bridge/Reservation.sol | 431 ++++++ .../contracts/bridge/ReservationProofs.sol | 194 ++- .../contracts/bridge/ReservationRouter.sol | 78 + solidity/contracts/bridge/Wallets.sol | 32 + solidity/contracts/test/BridgeStub.sol | 36 + solidity/contracts/vault/ReservationVault.sol | 226 ++- .../bridge/Bridge.ReservedRedemption.test.ts | 1280 +++++++++++++++++ .../test/bridge/ReservationProofs.test.ts | 13 +- solidity/test/vault/ReservationVault.test.ts | 299 +++- 12 files changed, 2766 insertions(+), 123 deletions(-) create mode 100644 solidity/test/bridge/Bridge.ReservedRedemption.test.ts diff --git a/solidity/contracts/bridge/IReservationBridge.sol b/solidity/contracts/bridge/IReservationBridge.sol index ec571ad16..e3f28f6d8 100644 --- a/solidity/contracts/bridge/IReservationBridge.sol +++ b/solidity/contracts/bridge/IReservationBridge.sol @@ -37,6 +37,30 @@ interface IReservationBridge { bytes20 targetWalletPubKeyHash ) external; + /// @notice See `ReservationRouter.requestReservedRedemption`. + function requestReservedRedemption( + uint256 reservationKey, + address redeemer, + bytes calldata redeemerOutputScript, + bool feePaid, + bool useRetryCredit + ) external; + + /// @notice See `ReservationRouter.notifyReservationRedemptionTimedOut`. + function notifyReservationRedemptionTimedOut( + uint256 reservationKey, + uint32[] calldata walletMembersIDs + ) external; + + /// @notice See `ReservationRouter.notifyReservedRedemptionVeto`. + function notifyReservedRedemptionVeto( + uint256 reservationKey, + uint64 requestNonce + ) external; + + /// @notice See `ReservationRouter.extendReservation`. + function extendReservation(uint256 reservationKey) external; + /// @notice See `ReservationRouter.submitReservationProof`. function submitReservationProof( uint8 proofType, diff --git a/solidity/contracts/bridge/Redemption.sol b/solidity/contracts/bridge/Redemption.sol index b3111893e..41dc36537 100644 --- a/solidity/contracts/bridge/Redemption.sol +++ b/solidity/contracts/bridge/Redemption.sol @@ -62,6 +62,51 @@ interface IRedemptionWatchtower { external view returns (uint32); + + /// @notice Returns the applicable veto delay for the given pending + /// reserved redemption generation. This is the delay the + /// Bridge proof path enforces before a reserved redemption + /// generation can settle: wallets must not sign before it + /// elapses. + /// @param reservationKey The key of the reservation. + /// @param requestNonce The reserved redemption generation. + /// @return Reserved redemption veto delay. + function getReservedRedemptionDelay( + uint256 reservationKey, + uint64 requestNonce + ) external view returns (uint32); + + /// @notice Returns the current three-level veto delay schedule to + /// snapshot for a newly requested reserved redemption. + /// @param requestedAmount Reserved redemption amount in satoshis. + /// @return reservedDefaultDelay Delay before any guardian objection. + /// @return reservedLevelOneDelay Delay after one guardian objection. + /// @return reservedLevelTwoDelay Delay after two guardian objections. + /// @dev Returns an all-zero schedule when no guardian veto policy + /// applies at request time: the watchtower is not enabled, has + /// been disabled, or the requested amount is below the waiver + /// limit. + function getReservedRedemptionDelaySchedule(uint64 requestedAmount) + external + view + returns ( + uint32 reservedDefaultDelay, + uint32 reservedLevelOneDelay, + uint32 reservedLevelTwoDelay + ); + + /// @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); } /// @notice Aggregates functions common to the redemption transaction proof diff --git a/solidity/contracts/bridge/RedemptionWatchtower.sol b/solidity/contracts/bridge/RedemptionWatchtower.sol index dd42fbddb..388f6e65d 100644 --- a/solidity/contracts/bridge/RedemptionWatchtower.sol +++ b/solidity/contracts/bridge/RedemptionWatchtower.sol @@ -18,7 +18,9 @@ pragma solidity 0.8.17; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "./Bridge.sol"; +import "./IReservationBridge.sol"; import "./Redemption.sol"; +import "./Reservation.sol"; /// @title Redemption watchtower /// @notice This contract encapsulates the logic behind the redemption veto @@ -469,6 +471,235 @@ contract RedemptionWatchtower is OwnableUpgradeable { ); } + /// @notice Computes the veto-proposal key of a reserved redemption + /// generation. The key space is disjoint from the regular + /// redemption key space (`keccak256(keccak256(redeemerOutputScript) + /// | walletPubKeyHash)`): a reservation key and generation nonce + /// cannot collide with a redeemer output script hash. + 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 + /// 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 generation must not have been vetoed already, + /// - The guardian must not have already objected to it, + /// - The generation must be the reservation's pending redemption, + /// - The generation must be within the veto delay snapshotted for + /// its current objection level; a permanently disabled watchtower + /// makes the effective delay zero. + /// Deliberate divergence from the pooled path: a generation + /// requested before the watchtower was ever enabled snapshots an + /// all-zero delay schedule and can therefore never be objected to + /// (there is no `VetoPeriodCheckOmitted` escape hatch here). + /// Harmless under the deployment sequencing this stack assumes + /// (the watchtower is enabled before any reserved redemption is + /// requested). + function raiseReservedObjection(uint256 reservationKey, uint64 requestNonce) + external + onlyGuardian + { + uint256 vetoKey = reservedVetoKey(reservationKey, requestNonce); + VetoProposal storage veto = vetoProposals[vetoKey]; + + require( + veto.objectionsCount < REQUIRED_OBJECTIONS_COUNT, + "Reserved redemption already vetoed" + ); + + uint256 objectionKey = uint256( + keccak256(abi.encodePacked(vetoKey, msg.sender)) + ); + require(!objections[objectionKey], "Guardian already objected"); + + Reservation.ReservationAction memory action = IReservationBridge( + address(bridge) + ).reservationActions(reservationKey, requestNonce); + + require( + action.actionType == Reservation.ActionType.Redemption && + action.state == Reservation.ActionState.Pending, + "Reserved redemption does not exist" + ); + + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp < + uint256(action.requestedAt) + + _reservedRedemptionDelay(action, veto.objectionsCount), + "Redemption veto delay period expired" + ); + + objections[objectionKey] = true; + veto.redeemer = action.redeemer; + veto.objectionsCount++; + + emit ObjectionRaised(vetoKey, msg.sender); + + if (veto.objectionsCount == REQUIRED_OBJECTIONS_COUNT) { + uint64 penaltyFee = vetoPenaltyFeeDivisor > 0 + ? action.amount / vetoPenaltyFeeDivisor + : 0; + + veto.withdrawableAmount = action.amount - penaltyFee; + /* solhint-disable-next-line not-rely-on-time */ + veto.finalizedAt = uint32(block.timestamp); + isBanned[action.redeemer] = true; + + emit Banned(action.redeemer); + + emit VetoFinalized(vetoKey); + + // Notify the Bridge about the veto. As result of this call, + // this contract receives the escrowed gross amount + // (as Bank's balance) from the Bridge. + IReservationBridge(address(bridge)).notifyReservedRedemptionVeto( + reservationKey, + requestNonce + ); + // Burn the penalty fee but leave the claimable amount for the + // redeemer to withdraw after the freeze period. + bank.decreaseBalance(penaltyFee); + } + } + + /// @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 The returned level is selected from the immutable schedule the + /// Bridge captured when the generation was requested. A permanently + /// disabled watchtower overrides every captured schedule with zero. + function getReservedRedemptionDelay( + uint256 reservationKey, + uint64 requestNonce + ) external view returns (uint32) { + // Preserve the pre-enable API behavior: no guardians exist, so no + // reservation lookup is needed to establish that the delay is zero. + // slither-disable-next-line incorrect-equality + if (watchtowerEnabledAt == 0) { + return 0; + } + + Reservation.ReservationAction memory action = IReservationBridge( + address(bridge) + ).reservationActions(reservationKey, requestNonce); + + require( + action.actionType == Reservation.ActionType.Redemption, + "Reserved redemption does not exist" + ); + + return + _reservedRedemptionDelay( + action, + vetoProposals[reservedVetoKey(reservationKey, requestNonce)] + .objectionsCount + ); + } + + /// @notice Returns the current three-level veto delay schedule to + /// snapshot for a newly requested reserved redemption. + /// @param requestedAmount Reserved redemption amount in satoshis. + /// @return reservedDefaultDelay Delay before any guardian objection. + /// @return reservedLevelOneDelay Delay after one guardian objection. + /// @return reservedLevelTwoDelay Delay after two guardian objections. + /// @dev Returns an all-zero schedule when no guardian veto policy applies + /// at request time: the watchtower is not enabled, has been disabled, + /// or the requested amount is below the waiver limit. + function getReservedRedemptionDelaySchedule(uint64 requestedAmount) + external + view + returns ( + uint32 reservedDefaultDelay, + uint32 reservedLevelOneDelay, + uint32 reservedLevelTwoDelay + ) + { + if ( + // slither-disable-next-line incorrect-equality + watchtowerEnabledAt == 0 || + watchtowerDisabledAt != 0 || + requestedAmount < waivedAmountLimit + ) { + return (0, 0, 0); + } + + return (defaultDelay, levelOneDelay, levelTwoDelay); + } + + /// @notice Selects a reserved redemption generation's snapshotted veto + /// delay for the given number of objections. + /// @dev A permanently disabled watchtower makes the effective delay zero + /// for every generation, including those requested before shutdown. + function _reservedRedemptionDelay( + Reservation.ReservationAction memory action, + uint8 objectionsCount + ) internal view returns (uint32) { + if (watchtowerDisabledAt != 0) { + return 0; + } + + if ( + // slither-disable-next-line incorrect-equality + objectionsCount == 0 + ) { + return action.watchtowerDefaultDelay; + } else if ( + // slither-disable-next-line incorrect-equality + objectionsCount == 1 + ) { + return action.watchtowerLevelOneDelay; + } else if ( + // slither-disable-next-line incorrect-equality + objectionsCount == 2 + ) { + return action.watchtowerLevelTwoDelay; + } else { + revert("No delay for given objections count"); + } + } + + /// @notice Determines whether a reserved redemption request is + /// considered safe: neither the reservation owner nor the + /// redeemer may be banned. Objection state is per generation, + /// 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 Updates the watchtower parameters. /// @param _watchtowerLifetime Duration of the watchtower lifetime in seconds. /// @param _vetoPenaltyFeeDivisor Divisor used to compute the redemption veto diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index 84666564f..eab5d7ee8 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -15,9 +15,13 @@ 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 "./Redemption.sol"; import "./Wallets.sol"; import "./WalletProposalValidatorConstants.sol"; @@ -59,6 +63,8 @@ import "./WalletProposalValidatorConstants.sol"; /// only in-kind deductions. library Reservation { using Wallets for BridgeState.Storage; + using BTCUtils for bytes; + using BytesLib for bytes; /// @notice Hard protocol bounds on the custody term length. The /// governable term must stay within them; they bound the @@ -394,6 +400,37 @@ library Reservation { // because each emitting library must declare its own events. event ReservationOccupancyChanged(uint32 activeReservationsCount); + event ReservationRedemptionTimedOut( + uint256 indexed reservationKey, + uint64 requestNonce + ); + + event ReservationRedemptionRequested( + uint256 indexed reservationKey, + uint64 requestNonce, + address indexed redeemer, + bytes redeemerOutputScript, + uint64 mintedAmount, + uint64 txMaxFee, + bool feePaid + ); + + event ReservationRedemptionCompleted( + uint256 indexed reservationKey, + uint64 requestNonce, + bytes32 redemptionTxHash + ); + + event ReservationRedemptionVetoed( + uint256 indexed reservationKey, + uint64 requestNonce + ); + + event ReservationExtended( + uint256 indexed reservationKey, + uint32 newExpiresAt + ); + /// @notice Computes the storage key of the action record of the given /// reservation generation. function actionKey(uint256 reservationKey, uint64 requestNonce) @@ -956,6 +993,394 @@ library Reservation { self.walletReservationInfo[walletPubKeyHash].amount = walletAmount; } + /// @notice Releases the global and per-wallet capacity allocated for an + /// accepted or pending-acceptance reservation position. + function notifyReservationRedemptionTimedOut( + BridgeState.Storage storage self, + uint256 reservationKey, + uint32[] calldata walletMembersIDs + ) external { + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + uint64 requestNonce = reservation.requestNonce; + ReservationAction storage action = getAction( + self, + reservationKey, + requestNonce + ); + + require( + action.actionType == ActionType.Redemption, + "Unsupported action type for timeout" + ); + require( + reservation.state == ReservationState.ActionPending, + "Reservation is not in ActionPending state" + ); + require(action.state == ActionState.Pending, "Action is not pending"); + /* solhint-disable not-rely-on-time */ + require( + block.timestamp >= action.timeoutAt, + "Action has not timed out" + ); + /* solhint-enable not-rely-on-time */ + + action.state = ActionState.TimedOut; + reservation.state = ReservationState.Active; + + if (action.feePaid || action.usedRetryCredit) { + reservation.retryCredit = true; + self.reservationRetryCreditActionNonce[ + reservationKey + ] = requestNonce; + emit ReservationRetryCreditMinted(reservationKey); + } + + bytes20 walletPubKeyHash = reservation.walletPubKeyHash; + _slashWalletIfRedeemable(self, walletPubKeyHash, walletMembersIDs); + + // Return the escrowed balance to the redeemer as Bank balance. + self.bank.transferBalance(action.redeemer, action.amount); + + // slither-disable-next-line reentrancy-events + emit ReservationRedemptionTimedOut(reservationKey, requestNonce); + } + + /// @notice Requests an in-kind reserved redemption of a reservation's + /// anchor outpoint: the authorization for the custodying wallet + /// to spend the anchor in a 1-input-1-output transaction paying + /// the redeemer's output script. The full gross `mintedAmount` + /// is escrowed from the caller (the reservation vault) into the + /// Bridge at request time -- the reserved redemption always + /// burns the gross claim, so supply and backing reconcile + /// exactly when the reservation closes via redemption. + /// @param reservationKey The key of the reservation to redeem. + /// @param redeemer The address able to claim the escrowed balance back + /// should the redemption time out or be vetoed. + /// @param redeemerOutputScript The redeemer's length-prefixed output + /// script (P2PKH, P2WPKH, P2SH or P2WSH) that the redemption + /// transaction must pay. + /// @param feePaid True when the redemption fee was collected by the + /// vault; a fee-paid generation that times out through the + /// wallet's fault mints the retry entitlement. + /// @param useRetryCredit True when this request consumes the + /// reservation's single-use, fee-free retry entitlement instead + /// of a freshly paid fee; the request is then exempt from the + /// grace-period gate. + /// @dev Requirements: + /// - The caller must be the reservation vault, + /// - The redeemer must not be the zero address, + /// - The reservation must be Active, + /// - The custodying wallet must be Live or MovingFunds, + /// - A non-retry request must be made before the reservation's + /// dissolution eligibility time (its snapshotted + /// `expiresAt + reservationDissolutionDelay`), + /// - A retry request must hold the retry entitlement, which is + /// consumed by this call, + /// - The reservation owner and redeemer must be safe per the + /// redemption watchtower, when one is set, + /// - The redeemer output script must be a standard type and must + /// not point to the custodying wallet's public key hash. + function requestReservedRedemption( + BridgeState.Storage storage self, + uint256 reservationKey, + address redeemer, + bytes calldata redeemerOutputScript, + bool feePaid, + bool useRetryCredit + ) external { + require( + msg.sender == self.reservationVault, + "Caller is not the reservation vault" + ); + require( + redeemer != address(0), + "Redeemer must not be the zero address" + ); + + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == ReservationState.Active, + "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 { + // Base analog of the settlement branch's post-expiry grace + // gate: `dissolutionEligibleAt` snapshots + // `expiresAt + reservationDissolutionDelay` at term grant. + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp <= uint256(reservation.dissolutionEligibleAt), + "Reservation past grace period" + ); + } + + if (self.redemptionWatchtower != address(0)) { + require( + IRedemptionWatchtower(self.redemptionWatchtower) + .isSafeReservedRedemption(reservation.owner, redeemer), + "Redemption request rejected by the watchtower" + ); + } + + bytes memory redeemerOutputScriptMem = redeemerOutputScript; + + // Validate the redeemer output script is a correct standard type + // (P2PKH, P2WPKH, P2SH or P2WSH), the same way `Redemption` does. + bytes memory redeemerOutputScriptPayload = redeemerOutputScriptMem + .extractHashAt(0, redeemerOutputScriptMem.length); + require( + redeemerOutputScriptPayload.length > 0, + "Redeemer output script must be a standard type" + ); + require( + redeemerOutputScriptPayload.length != 20 || + reservation.walletPubKeyHash != + redeemerOutputScriptPayload.slice20(0), + "Redeemer output script must not point to the wallet PKH" + ); + + 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 */ + action.timeoutAt = uint32(block.timestamp) + self.redemptionTimeout; + action.txMaxFee = self.reservationTxMaxFee; + action.feePaid = feePaid; + action.usedRetryCredit = useRetryCredit; + action.redeemer = redeemer; + action.amount = reservation.mintedAmount; + action.actionDataHash = keccak256(redeemerOutputScriptMem); + action.sourceAnchorUtxoHash = anchorUtxoHash(reservation); + + if (self.redemptionWatchtower != address(0)) { + ( + action.watchtowerDefaultDelay, + action.watchtowerLevelOneDelay, + action.watchtowerLevelTwoDelay + ) = IRedemptionWatchtower(self.redemptionWatchtower) + .getReservedRedemptionDelaySchedule(reservation.mintedAmount); + } + + // slither-disable-next-line reentrancy-events + emit ReservationRedemptionRequested( + reservationKey, + requestNonce, + redeemer, + redeemerOutputScript, + reservation.mintedAmount, + action.txMaxFee, + feePaid + ); + + self.bank.transferBalanceFrom( + msg.sender, + address(this), + reservation.mintedAmount + ); + } + + /// @notice Finalizes a reserved-redemption veto: marks the generation + /// `Vetoed` and returns the escrowed gross amount to the + /// watchtower, which holds the withdrawable remainder for the + /// redeemer after the freeze period and burns the penalty fee. + /// @param reservationKey The key of the reservation. + /// @param requestNonce The vetoed redemption generation. + /// @dev Requirements: + /// - The caller must be the redemption watchtower, + /// - The named generation must be the reservation's current one, + /// - It must be a `Redemption` action in the `Pending` state. + function notifyReservedRedemptionVeto( + BridgeState.Storage storage self, + uint256 reservationKey, + uint64 requestNonce + ) external { + require( + msg.sender == self.redemptionWatchtower, + "Caller is not the redemption watchtower" + ); + + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.requestNonce == requestNonce, + "Not the current generation" + ); + + ReservationAction storage action = getAction( + self, + reservationKey, + requestNonce + ); + require( + action.actionType == ActionType.Redemption && + action.state == ActionState.Pending, + "No pending reserved redemption" + ); + + action.state = ActionState.Vetoed; + reservation.state = ReservationState.Active; + + // slither-disable-next-line reentrancy-events + emit ReservationRedemptionVetoed(reservationKey, requestNonce); + + self.bank.transferBalance(self.redemptionWatchtower, action.amount); + } + + /// @notice Extends the custody term of a reservation by the current + /// `reservationTermSeconds` parameter, re-granting the + /// dissolution eligibility window from the new expiry. The + /// extension fee 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 Active, or awaiting the settlement + /// of a pending redemption, + /// - The reservation must not be past its dissolution eligibility + /// time. + 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.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.dissolutionEligibleAt), + "Reservation past grace period" + ); + + uint32 base = reservation.expiresAt; + /* solhint-disable-next-line not-rely-on-time */ + if (base < block.timestamp) { + /* solhint-disable-next-line not-rely-on-time */ + base = uint32(block.timestamp); + } + reservation.expiresAt = base + self.reservationTermSeconds; + reservation.dissolutionEligibleAt = + reservation.expiresAt + + self.reservationDissolutionDelay; + + emit ReservationExtended(reservationKey, reservation.expiresAt); + } + + /// @notice Slashes a wallet for a reservation redemption or + /// dissolution timeout when its current state makes it + /// eligible. Live, MovingFunds, and Terminated wallets are + /// routed through the regular redemption-timeout path (Live and + /// MovingFunds slash the operators and reward the notifier; + /// Terminated is a true no-op, routed through only so callers + /// don't have to special-case it). A wallet that has moved to + /// Closing cannot use that path -- `notifyWalletRedemptionTimeout` + /// only accepts Live, MovingFunds, or Terminated and reverts + /// otherwise -- but reaching Closing must not let it dodge the + /// slashing consequence it would otherwise face, so it is slashed + /// directly here, mirroring `notifyWalletRedemptionTimeout`'s + /// own slashing branch. A Closed wallet is left untouched + /// intentionally: no slash occurs because the signing group is + /// already disbanded via `finalizeWalletClosing`, and no notifier + /// reward is paid because stake was already withdrawn and there + /// is nothing left to seize -- a permissionless monitor reporting + /// a legitimately-late Closed-wallet timeout gets no reward by + /// design, not by oversight. + /// @param walletPubKeyHash 20-byte public key hash of the wallet. + /// @param walletMembersIDs Identifiers of the wallet signing group + /// members, consulted for the slashing path. + /// @return walletState The pre-call lifecycle state of the wallet. + function _slashWalletIfRedeemable( + BridgeState.Storage storage self, + bytes20 walletPubKeyHash, + uint32[] calldata walletMembersIDs + ) internal returns (Wallets.WalletState) { + Wallets.Wallet storage wallet = self.registeredWallets[ + walletPubKeyHash + ]; + Wallets.WalletState walletState = wallet.state; + + if ( + walletState == Wallets.WalletState.Live || + walletState == Wallets.WalletState.MovingFunds || + walletState == Wallets.WalletState.Terminated + ) { + self.notifyWalletRedemptionTimeout( + walletPubKeyHash, + walletMembersIDs + ); + } else if (walletState == Wallets.WalletState.Closing) { + self.notifyClosingWalletRedemptionTimeout( + walletPubKeyHash, + walletMembersIDs + ); + } + + return walletState; + } + + /// @notice Permissionlessly reports a reservation's pending dissolution + /// generation as timed out once its authorization window has + /// elapsed: restores the reservation to `Active` so a fresh + /// dissolution may be requested, and propagates wallet + /// consequences. 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. A Live wallet enters MovingFunds on its first + /// failure and keeps the ordinary moving-funds deadline (a + /// Live wallet with no main UTXO begins closing instead, per + /// `Wallets.moveFunds`); a wallet already in MovingFunds has + /// now also refused the terminal cleanup of its residual + /// anchor, so it is terminated at the dissolution bound. + /// @param reservationKey The key of the reservation whose current + /// pending generation timed out. + /// @param walletMembersIDs Identifiers of the wallet signing group + /// members, consulted for the slashing path. + /// @dev Requirements: + /// - The reservation's current generation must be a `Dissolution` + /// action in the `Pending` state, + /// - The reservation itself must be in the `ActionPending` state, + /// - Its snapshotted `timeoutAt` must have elapsed. + /// @notice Releases the global and per-wallet capacity allocated for an /// accepted or pending-acceptance reservation position. function releaseAcceptanceCapacity( @@ -1144,6 +1569,12 @@ library Reservation { strandReservation(self, reservation, reservationKey, true); } + /// @notice Closes a reservation position after a proven in-kind + /// redemption: releases every capacity counter reserved at + /// acceptance and indexes the position as `Closed`. Mirrors + /// `strandReservation`'s release set (wallet count, wallet + /// amount, total amount, active count, wallet enumeration key + /// @notice Marks a revealed reserved deposit as stale so it stops /// counting against the pending-reserved-deposit guard and can /// no longer be authorized for acceptance. diff --git a/solidity/contracts/bridge/ReservationProofs.sol b/solidity/contracts/bridge/ReservationProofs.sol index 2b2ef83e9..f62a5562d 100644 --- a/solidity/contracts/bridge/ReservationProofs.sol +++ b/solidity/contracts/bridge/ReservationProofs.sol @@ -16,6 +16,7 @@ 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"; @@ -67,6 +68,7 @@ library ReservationProofs { using Reservation for BridgeState.Storage; using BTCUtils for bytes; + using BytesLib for bytes; event ReservationAccepted( uint256 indexed reservationKey, @@ -105,6 +107,12 @@ library ReservationProofs { Reservation.ActionType actionType ); + event ReservationRedemptionCompleted( + uint256 indexed reservationKey, + uint64 requestNonce, + bytes32 redemptionTxHash + ); + /// @notice Represents the type of a reservation lifecycle SPV proof. /// Zero-based; `ProofType(n)` corresponds to /// `Reservation.ActionType(n + 1)` for `n` in `{0..3}` (`None` @@ -155,6 +163,14 @@ library ReservationProofs { reservationKey, requestNonce ); + } else if (parsedProofType == ProofType.Redemption) { + submitReservedRedemptionProof( + self, + txInfo, + proof, + reservationKey, + requestNonce + ); } else if (parsedProofType == ProofType.Reanchor) { submitReservationReanchorProof( self, @@ -163,7 +179,8 @@ library ReservationProofs { reservationKey, requestNonce ); - // Milestone 1 accepts acceptance and re-anchor proofs. + // Milestone 1 accepts acceptance, redemption, and re-anchor + // proofs. } else { revert("Unsupported reservation proof type"); } @@ -847,6 +864,181 @@ library ReservationProofs { reservation.state = Reservation.ReservationState.Active; } + /// @notice Used by the wallet to prove an in-kind reserved redemption of + /// a reservation's anchor outpoint and to close the position: + /// the redemption transaction must spend exactly the current + /// anchor outpoint as its sole input and create exactly one + /// output paying the redeemer output script the generation + /// snapshotted at request time. The escrowed gross claim is + /// burned (the reserved redemption always burns the full + /// `mintedAmount`) and the position's capacity counters are + /// released. + /// @dev Requirements: + /// - The named generation must be a settleable redemption, + /// - For an on-time settlement, the reservation must not have a + /// newer pending generation, and the watchtower delay snapshotted + /// for the generation must have elapsed, + /// - `redemptionTx` must spend the current anchor outpoint as its + /// sole input, + /// - `redemptionTx` must have exactly one output paying the + /// generation's snapshotted redeemer output script, within the + /// snapshotted fee bound against the current anchor value, + /// - The miner fee must respect the snapshotted bound. + 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" + ); + } + } + + requireCurrentSourceAnchor(reservation, action); + + 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.actionDataHash, + "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, reservationKey); + + // slither-disable-next-line reentrancy-events + emit ReservationRedemptionCompleted( + reservationKey, + requestNonce, + redemptionTxHash + ); + + if (!late) { + // Burn the gross minted amount held by the Bridge since the + // redemption request. + self.bank.decreaseBalance(action.amount); + } + } + + /// @notice Resolves a late redemption settlement against the position's + /// current pending generation. If the pending generation is + /// also a redemption authorizing the same redeemer output + /// script within its own fee bound, the proof must settle + /// against it instead (the wallet could have signed either + /// generation's authorization for this exact spend, and the + /// refund bookkeeping must attribute to the correct + /// generation); otherwise the pending generation's anchor is + /// provably gone and it is unwound, refunding its escrow. + 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.actionDataHash == action.actionDataHash && + 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, false); + } + /// @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 diff --git a/solidity/contracts/bridge/ReservationRouter.sol b/solidity/contracts/bridge/ReservationRouter.sol index 51e45a793..1730bb09b 100644 --- a/solidity/contracts/bridge/ReservationRouter.sol +++ b/solidity/contracts/bridge/ReservationRouter.sol @@ -233,6 +233,37 @@ contract ReservationRouter is Governable, Initializable { ); } + /// @notice Requests an in-kind reserved redemption of a reservation's + /// anchor outpoint. Must be called by the reservation vault, + /// which collects the redemption fee, unmints the gross TBTC + /// claim and escrows it into the Bridge via the Bank before + /// this call. See `Reservation.requestReservedRedemption`. + /// @param reservationKey The key of the reservation to redeem. + /// @param redeemer The address able to claim the escrowed balance back + /// should the redemption time out or be vetoed. + /// @param redeemerOutputScript The redeemer's length-prefixed output + /// script (P2PKH, P2WPKH, P2SH or P2WSH). + /// @param feePaid True when the redemption fee was collected by the + /// vault; a fee-paid generation that times out through the + /// wallet's fault mints the retry entitlement. + /// @param useRetryCredit True when this request consumes the + /// reservation's single-use, fee-free retry entitlement. + function requestReservedRedemption( + uint256 reservationKey, + address redeemer, + bytes calldata redeemerOutputScript, + bool feePaid, + bool useRetryCredit + ) external { + self.requestReservedRedemption( + reservationKey, + redeemer, + redeemerOutputScript, + feePaid, + useRetryCredit + ); + } + /// @notice Single entry point for reservation lifecycle SPV proofs /// retained in milestone 1: anchor acceptance and re-anchoring. /// Settles the named action generation. See @@ -285,6 +316,53 @@ contract ReservationRouter is Governable, Initializable { self.notifyReservationActionTimeout(reservationKey, walletMembersIDs); } + /// @notice Permissionlessly reports a reservation's pending redemption + /// generation as timed out once its authorization window has + /// elapsed: restores the reservation to Active, mints the + /// fee-free retry entitlement when the generation had paid the + /// fee or had itself consumed one, propagates the wallet + /// consequences (slashing follows the regular redemption + /// timeout rules) and returns the escrowed claim to the + /// redeemer as Bank balance. See + /// `Reservation.notifyReservationRedemptionTimedOut`. + /// @param reservationKey The key of the reservation. + /// @param walletMembersIDs Identifiers of the wallet signing group + /// members, consulted for the slashing path. + function notifyReservationRedemptionTimedOut( + uint256 reservationKey, + uint32[] calldata walletMembersIDs + ) external { + self.notifyReservationRedemptionTimedOut( + reservationKey, + walletMembersIDs + ); + } + + /// @notice Finalizes a reserved-redemption veto: marks the generation + /// `Vetoed` and returns the escrowed gross amount to the + /// watchtower, which holds the withdrawable remainder for the + /// redeemer after the freeze period and burns the penalty fee. + /// Must be called by the redemption watchtower. See + /// `Reservation.notifyReservedRedemptionVeto`. + /// @param reservationKey The key of the reservation. + /// @param requestNonce The vetoed redemption generation. + function notifyReservedRedemptionVeto( + uint256 reservationKey, + uint64 requestNonce + ) external { + self.notifyReservedRedemptionVeto(reservationKey, requestNonce); + } + + /// @notice Extends the custody term of a reservation by the current + /// term parameter, re-granting the dissolution eligibility + /// window from the new expiry. Must be called by the + /// reservation vault, which collects the extension fee. See + /// `Reservation.extendReservation`. + /// @param reservationKey The key of the reservation to extend. + function extendReservation(uint256 reservationKey) external { + self.extendReservation(reservationKey); + } + /// @notice Permissionlessly reports a pending acceptance authorization /// as timed out once its authorization window has elapsed, /// releasing the capacity it reserved so a fresh generation can diff --git a/solidity/contracts/bridge/Wallets.sol b/solidity/contracts/bridge/Wallets.sol index 9725649eb..d88ef6037 100644 --- a/solidity/contracts/bridge/Wallets.sol +++ b/solidity/contracts/bridge/Wallets.sol @@ -275,6 +275,38 @@ library Wallets { } } + /// @notice Slashes a wallet in the Closing state that failed its + /// redemption duty, mirroring `notifyWalletRedemptionTimeout`'s + /// slashing branch (Closing wallets cannot pass that function's + /// state gate, but reaching Closing must not let the wallet + /// dodge the slashing consequence). No `moveFunds` is performed: + /// a Closing wallet is already past moving. + /// @param walletPubKeyHash 20-byte public key hash of the wallet. + /// @param walletMembersIDs Identifiers of the wallet signing group + /// members, consulted for the slashing path. + /// @dev Requirements: + /// - The wallet must be in the `Closing` state. + function notifyClosingWalletRedemptionTimeout( + BridgeState.Storage storage self, + bytes20 walletPubKeyHash, + uint32[] calldata walletMembersIDs + ) internal { + Wallet storage wallet = self.registeredWallets[walletPubKeyHash]; + require( + wallet.state == WalletState.Closing, + "Wallet must be in Closing state" + ); + + // slither-disable-next-line reentrancy-no-eth + self.ecdsaWalletRegistry.seize( + self.redemptionTimeoutSlashingAmount, + self.redemptionTimeoutNotifierRewardMultiplier, + msg.sender, + wallet.ecdsaWalletID, + walletMembersIDs + ); + } + /// @notice Handles a notification about a wallet heartbeat failure and /// triggers the wallet moving funds process. /// @param publicKeyX Wallet's public key's X coordinate. diff --git a/solidity/contracts/test/BridgeStub.sol b/solidity/contracts/test/BridgeStub.sol index 11d6042e9..6d4f6a368 100644 --- a/solidity/contracts/test/BridgeStub.sol +++ b/solidity/contracts/test/BridgeStub.sol @@ -6,6 +6,7 @@ import "../bridge/BitcoinTx.sol"; import "../bridge/Bridge.sol"; import "../bridge/MovingFunds.sol"; import "../bridge/RebateStaking.sol"; +import "../bridge/Reservation.sol"; import "../bridge/Wallets.sol"; contract BridgeStub is Bridge { @@ -73,6 +74,41 @@ contract BridgeStub is Bridge { } } + /// @notice Injects a reservation record into the Bridge's + /// `reservations` mapping and mirrors every reserved-capacity + /// counter and enumeration the acceptance path maintains, so a + /// later close/strand/redemption of the injected reservation + /// decrements exactly what this setter added (see + /// `Reservation.closeReservation`). + function setReservation( + uint256 reservationKey, + Reservation.ReservationRequest calldata reservation + ) external { + self.reservations[reservationKey] = reservation; + self.walletReservationInfo[reservation.walletPubKeyHash].count += 1; + self + .walletReservationInfo[reservation.walletPubKeyHash] + .amount += reservation.anchorAmount; + self.reservationTotalAmount += reservation.anchorAmount; + self.activeReservationsCount += 1; + self.walletReservationKeys[reservation.walletPubKeyHash].push( + reservationKey + ); + self.walletReservationKeyIndex[reservationKey] = self + .walletReservationKeys[reservation.walletPubKeyHash] + .length; + self.reservationsByAnchorUtxo[ + uint256( + keccak256( + abi.encodePacked( + reservation.anchorTxHash, + reservation.anchorTxOutputIndex + ) + ) + ) + ] = reservationKey; + } + function setDepositDustThreshold(uint64 _depositDustThreshold) external { self.depositDustThreshold = _depositDustThreshold; } diff --git a/solidity/contracts/vault/ReservationVault.sol b/solidity/contracts/vault/ReservationVault.sol index 919738e92..0e0ee4584 100644 --- a/solidity/contracts/vault/ReservationVault.sol +++ b/solidity/contracts/vault/ReservationVault.sol @@ -65,14 +65,14 @@ contract ReservationVault is IVault, IReservationFeeFinancer, Ownable { /// mint leg and the first custody term. uint16 public initiationFeeBps; /// @notice Extension fee in basis points of the gross amount, charged - /// per custody term extension. - /// @dev Unused in milestone 1; reserved for the sibling wiring PR. + /// per custody term extension by `extendCustody`. uint16 public extensionFeeBps; /// @notice Redemption fee in basis points of the gross amount, charged /// when the in-kind redemption is requested. Priced at parity /// with the pooled redemption fee; not re-charged on retries /// after wallet-fault timeouts. - /// @dev Unused in milestone 1; reserved for the sibling wiring PR. + /// @dev Charged by `redeemReservation` when the in-kind redemption is + /// requested; not re-charged on retries after wallet-fault timeouts. uint16 public redemptionFeeBps; /// @notice True while redemptions are paused. A fresh vault starts @@ -131,6 +131,19 @@ contract ReservationVault is IVault, IReservationFeeFinancer, Ownable { uint256 feeTbtc ); + event CustodyExtended( + uint256 indexed reservationKey, + address indexed owner, + uint256 feeTbtc + ); + + event ReservedRedemptionInitiated( + uint256 indexed reservationKey, + address indexed owner, + uint256 grossTbtc, + uint256 feeTbtc + ); + event FeesUpdated( uint16 initiationFeeBps, uint16 extensionFeeBps, @@ -430,63 +443,192 @@ contract ReservationVault is IVault, IReservationFeeFinancer, Ownable { ); } - /// @notice Initiates an in-kind redemption of `amountSat` of the - /// reservation's `mintedAmount`. Caller must be the - /// reservation owner. + /// @notice Requests an in-kind redemption of the caller's reservation. + /// The caller surrenders the gross minted TBTC amount plus the + /// redemption fee; the vault unmints the gross amount and asks + /// the Bridge to have the wallet spend exactly the reservation's + /// anchor outpoint to the given redeemer script. /// @param reservationKey The key of the reservation to redeem. - /// @param amountSat The redemption amount in satoshi. - /// @dev Reserved-redemption entry point. Milestone 1 unconditionally - /// reverts; milestone 2 will add the body that drives the - /// Bridge's `requestReservedRedemption` path. The amountSat parameter - /// is accepted for milestone 2 ABI stability and ignored in milestone 1. - // solhint-disable-next-line no-unused-vars - function redeemReservation(uint256 reservationKey, uint256 amountSat) - external - whenRedemptionsNotPaused - { + /// @param redeemerOutputScript The redeemer's length-prefixed output + /// script (P2PKH, P2WPKH, P2SH or P2WSH). + /// @param maxFeeTbtc Upper bound on the TBTC fee the caller accepts; + /// an unexpected fee update reverts instead of overcharging. + /// @dev Requirements: + /// - The caller must be the reservation owner, + /// - The fee must not exceed `maxFeeTbtc`, + /// - The caller must have approved this vault for + /// `mintedAmount * SATOSHI_MULTIPLIER * (1 + redemptionFeeBps/10000)` + /// TBTC. + /// + /// Should the redemption time out, the Bridge returns the + /// surrendered gross amount to the caller as Bank balance; the + /// caller can re-mint TBTC via the TBTC vault and request the + /// redemption again. + /// + /// ABI note: this entry point ships the whole-reservation + /// redemption surface in milestone 1, replacing the + /// milestone-1-disabled stub whose `amountSat` parameter reserved + /// ABI space for milestone 2's partial-redemption design + /// (`feat/utxo-reservation-partial-redemption`). The + /// settlement-style script-based ABI used here is a deliberate + /// divergence, flagged in the PR; milestone 2 must reconcile the + /// partial-redemption ABI with the deployed whole-reservation + /// interface. + function redeemReservation( + uint256 reservationKey, + bytes calldata redeemerOutputScript, + uint256 maxFeeTbtc + ) external whenRedemptionsNotPaused { + Reservation.ReservationRequest memory reservation = bridge.reservations( + reservationKey + ); require( - msg.sender == bridge.reservations(reservationKey).owner, + reservation.owner == msg.sender, "Caller is not the reservation owner" ); - revert("Reserved redemption not enabled in milestone 1"); + + uint256 grossTbtc = uint256(reservation.mintedAmount) * + SATOSHI_MULTIPLIER; + uint256 fee = (grossTbtc * redemptionFeeBps) / BASIS_POINTS; + require(fee <= maxFeeTbtc, "Fee exceeds the caller's bound"); + + IERC20(tbtcToken).safeTransferFrom( + msg.sender, + address(this), + grossTbtc + fee + ); + if (fee > 0) { + IERC20(tbtcToken).safeTransfer(bridge.treasury(), fee); + } + + // Unmint the gross TBTC back into Bank balance and let the Bridge + // take it when registering the reserved redemption request. + IERC20(tbtcToken).safeIncreaseAllowance(address(tbtcVault), grossTbtc); + tbtcVault.unmint(grossTbtc); + bank.approveBalance(address(bridge), reservation.mintedAmount); + + // slither-disable-next-line reentrancy-events + emit ReservedRedemptionInitiated( + reservationKey, + msg.sender, + grossTbtc, + fee + ); + + bridge.requestReservedRedemption( + reservationKey, + msg.sender, + redeemerOutputScript, + true, // The redemption fee was collected above. + false + ); } - /// @notice Re-runs a redemption using the reservation's single-use - /// fee-free retry entitlement, granted when a prior - /// fee-paid redemption generation timed out through wallet - /// fault. Caller must be the reservation owner. - /// @param reservationKey The key of the reservation to retry. - /// @param amountSat The redemption amount in satoshi. - /// @dev See `redeemReservation` for the same M1/M2 rationale. The - /// amountSat parameter is accepted for milestone 2 ABI stability - /// and ignored in milestone 1. - // solhint-disable-next-line no-unused-vars - function retryRedeemReservation(uint256 reservationKey, uint64 amountSat) - external - whenRedemptionsNotPaused - { + /// @notice Re-runs a redemption using the caller's Bank balance -- + /// the state a timed-out reserved redemption leaves the owner + /// in (the Bridge refunds the surrendered amount as Bank + /// balance). The caller surrenders the gross minted amount as + /// Bank balance; the redemption fee was already collected by + /// the original request and is not re-charged. + /// @param reservationKey The key of the reservation to redeem. + /// @param redeemerOutputScript The redeemer's length-prefixed output + /// script (P2PKH, P2WPKH, P2SH or P2WSH). + /// @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`), + /// - 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 + /// because the previous request timed out through the wallet's + /// fault. + /// + /// ABI note: same divergence as `redeemReservation` -- the stub's + /// `amountSat` parameter (reserved for milestone 2 partial + /// redemption) is replaced by the settlement-style script-based + /// ABI. + function retryRedeemReservation( + uint256 reservationKey, + bytes calldata redeemerOutputScript + ) external whenRedemptionsNotPaused { + Reservation.ReservationRequest memory reservation = bridge.reservations( + reservationKey + ); require( - msg.sender == bridge.reservations(reservationKey).owner, + reservation.owner == msg.sender, "Caller is not the reservation owner" ); - revert("Reserved redemption retry not enabled in milestone 1"); + + uint256 grossTbtc = uint256(reservation.mintedAmount) * + SATOSHI_MULTIPLIER; + + // The redemption fee was already collected by the original + // redeemReservation call; a retry only exists because the previous + // request timed out through the wallet's fault, so it is not + // re-charged. + + bank.transferBalanceFrom( + msg.sender, + address(this), + reservation.mintedAmount + ); + bank.approveBalance(address(bridge), reservation.mintedAmount); + + // slither-disable-next-line reentrancy-events + emit ReservedRedemptionInitiated( + reservationKey, + msg.sender, + grossTbtc, + 0 + ); + + bridge.requestReservedRedemption( + reservationKey, + msg.sender, + redeemerOutputScript, + false, + true // Consume the retry entitlement instead of paying the fee. + ); } - /// @notice Renews the custody term of the caller's reservation by - /// exactly one current term. Caller must be the reservation owner. - /// @param reservationKey The key of the reservation to renew. - /// @dev Reserved-custody-extension entry point. Milestone 1 unconditionally - /// reverts; milestone 2 will wire the real renewal flow through the - /// router's `extendReservation`. + /// @notice Extends the custody term of the caller's reservation by one + /// term length, charging the extension fee in TBTC. + /// @param reservationKey The key of the reservation to extend. + /// @dev Requirements: + /// - The caller must be the reservation owner, + /// - The caller must have approved this vault for the extension + /// fee in TBTC. function extendCustody(uint256 reservationKey) external whenRenewalsNotPaused { + Reservation.ReservationRequest memory reservation = bridge.reservations( + reservationKey + ); require( - msg.sender == bridge.reservations(reservationKey).owner, + reservation.owner == msg.sender, "Caller is not the reservation owner" ); - revert("Custody extension not enabled in milestone 1"); + + uint256 fee = (uint256(reservation.mintedAmount) * + SATOSHI_MULTIPLIER * + extensionFeeBps) / BASIS_POINTS; + if (fee > 0) { + IERC20(tbtcToken).safeTransferFrom( + msg.sender, + bridge.treasury(), + fee + ); + } + + // slither-disable-next-line reentrancy-events + emit CustodyExtended(reservationKey, msg.sender, fee); + + bridge.extendReservation(reservationKey); } /// @notice Pauses all future redemptions. Restrictive and monotonic: diff --git a/solidity/test/bridge/Bridge.ReservedRedemption.test.ts b/solidity/test/bridge/Bridge.ReservedRedemption.test.ts new file mode 100644 index 000000000..656be6dc3 --- /dev/null +++ b/solidity/test/bridge/Bridge.ReservedRedemption.test.ts @@ -0,0 +1,1280 @@ +/** + * End-to-end coverage for the reserved-redemption surface ported from + * `origin/feat/utxo-reservation-settlement` (settlement branch, whole- + * reservation ABI) onto `reservations-upgrade`: + * + * - `Reservation.requestReservedRedemption` through the router fallback, + * including the fee-paid escrow, the timeout path + * (`notifyReservationRedemptionTimedOut`), retry-credit minting and the + * wallet consequences + * - `Reservation.extendReservation` (vault-gated term extension against + * the LIVE `reservationTermSeconds` / `reservationDissolutionDelay` + * parameters - the base model has no per-position `termSeconds` + * snapshot, unlike the settlement branch) + * - `Reservation.notifyReservedRedemptionVeto` (watchtower-gated + * generation veto) + * - The `RedemptionWatchtower` reserved veto surface + * (`raiseReservedObjection`, `reservedVetoKey`, + * `getReservedRedemptionDelay(Schedule)`, `isSafeReservedRedemption`) + * and the request-time delay snapshots into the action record + * - The `ReservationVault` redemption entry points + * (`redeemReservation`, `retryRedeemReservation`, `extendCustody`) + * with fee accounting against the real Bank/TBTC plumbing + * + * ABI divergence (deliberate, human-approved 2026-09-04): the base vault + * shipped M1-disabled stubs whose `amountSat` parameter reserved ABI space + * for milestone 2's partial-redemption design + * (`feat/utxo-reservation-partial-redemption`). This file exercises the + * settlement-style whole-reservation script-based ABI that replaced those + * stubs. Milestone 2 must reconcile the partial-redemption ABI with the + * deployed whole-reservation interface. + */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +import { ethers, helpers } from "hardhat" +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" +import { BigNumber, Contract } from "ethers" +import { expect } from "chai" +import type { + Bank, + BankStub, + Bridge, + BridgeStub, + IWalletRegistry, + IRelay, + ReimbursementPool, + ReservationRouter, + ReservationVault, + TBTC, + TBTCVault, +} from "../../typechain" +import bridgeFixture from "../fixtures/bridge" +import type { Mock } from "../helpers/mock" +import { walletState } from "../fixtures" + +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("10000000") +const MAX_RESERVATIONS_PER_WALLET = 10 +const RESERVATION_ACTION_TIMEOUT = 172800 // 48 hours +const RESERVATION_RENEWAL_WINDOW = 2592000 // 30 days + +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 walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" +// A valid P2WPKH redeemer output script. +const redeemerOutputScript = "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + +describe("Bridge - Reserved redemption", () => { + let governance: SignerWithAddress + let spvMaintainer: SignerWithAddress + let thirdParty: SignerWithAddress + let treasury: SignerWithAddress + let deployer: SignerWithAddress + + let bank: Bank & BankStub + let relay: Mock + let walletRegistry: Mock + let bridge: Bridge & BridgeStub + let reservationRouter: ReservationRouter + let reservationContract: Contract + let tbtc: TBTC & Contract + let tbtcVault: TBTCVault & Contract + let reservationVault: ReservationVault + let bridgeGovernanceSigner: SignerWithAddress + + before(async () => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ + deployer, + governance, + spvMaintainer, + thirdParty, + treasury, + bank, + relay, + walletRegistry, + bridge, + tbtc, + tbtcVault, + } = await bridgeFixture()) + + // Reservation router functions are declared on `ReservationRouter`, + // not `Bridge`, and only reachable through `Bridge.fallback()`'s + // delegatecall. `ethers.getContractAt` with the router's ABI but + // Bridge's address gives correctly-encoded calls that land on the + // fallback, exactly like the production caller path. + reservationRouter = await ethers.getContractAt( + "ReservationRouter", + bridge.address + ) + // The reservation-surface events (`ReservationRedemptionRequested`, + // `ReservationExtended`, etc.) are declared on the `Reservation` + // library, whose code the Bridge executes via delegatecall from the + // router. They are not part of the Bridge's own ABI, so event + // assertions use a `Reservation`-ABI instance bound to the Bridge + // address where the logs actually land. + reservationContract = await ethers.getContractAt( + "Reservation", + bridge.address + ) + reservationVault = await helpers.contracts.getContract("ReservationVault") + const vaultOwnerSigner = await impersonateContract( + await reservationVault.owner() + ) + // The vault starts with redemptions/renewals paused; unpause once for + // the whole suite the same way governance would after deploy. + await reservationVault.connect(vaultOwnerSigner).unpauseRedemptions() + await reservationVault.connect(vaultOwnerSigner).unpauseRenewals() + bridgeGovernanceSigner = await impersonateContract( + await bridge.governance() + ) + + await relay.getCurrentEpochDifficulty.returns(0) + await relay.getPrevEpochDifficulty.returns(0) + + await bridge.setDepositDustThreshold(10000) + await bridge.setDepositTxMaxFee(2000) + await bridge.setDepositRevealAheadPeriod(0) + + const tbtcOwner = await impersonateContract(await tbtc.owner()) + await tbtc.connect(tbtcOwner).transferOwnership(tbtcVault.address) + }) + + /// Wires the reservation vault and parameters into the Bridge the way + /// the acceptance path requires (`msg.sender == reservationVault`, + /// live caps/params). + async function wireReservations() { + await bridge + .connect(bridgeGovernanceSigner) + .setVaultStatus(reservationVault.address, true) + await reservationRouter + .connect(bridgeGovernanceSigner) + .updateReservationCaps(RESERVATION_MAX_TOTAL, RESERVATION_MAX_TOTAL, 100) + await reservationRouter + .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, + RESERVATION_RENEWAL_WINDOW + ) + } + + async function impersonateContract( + address: string + ): Promise { + await ethers.provider.send("hardhat_impersonateAccount", [address]) + await ethers.provider.send("hardhat_setBalance", [ + address, + "0x8AC7230489E80000", // 10 ETH + ]) + 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, + }) + // A main UTXO makes the redemption-timeout path route the Live wallet + // to MovingFunds (base `Wallets.moveFunds`), keeping it eligible for + // re-requests and retries; without one the wallet goes to Closing. + await bridge.setWalletMainUtxo(pkh, { + txHash: ethers.utils.randomBytes(32), + txOutputIndex: 0, + txOutputValue: 100000000, + }) + } + + /// Builds an active reservation position owned by `owner`, custodied by + /// the wallet identified by `walletPubKeyHash` (base struct shape). + async function activeReservation( + owner: string, + pkh: string, + amountSat: BigNumber + ) { + return { + owner, + mintedAmount: amountSat, + acceptedAt: await lastBlockTime(), + walletPubKeyHash: pkh, + anchorAmount: amountSat, + expiresAt: (await lastBlockTime()) + RESERVATION_TERM, + anchorTxHash: ethers.utils.randomBytes(32), + anchorTxOutputIndex: 0, + state: ReservationState.Active, + requestNonce: 0, + retryCredit: false, + dissolutionEligibleAt: + (await lastBlockTime()) + RESERVATION_TERM + RESERVATION_GRACE, + cumulativeReanchorFee: 0, + reanchorCooldownUntil: 0, + } + } + + /// 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, + script: 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) + // Bank allowances are non-atomic: a reverted request leaves a + // non-zero allowance behind, so reset before (re)approving. + await bank.connect(vaultSigner).approveBalance(bridge.address, 0) + await bank.connect(vaultSigner).approveBalance(bridge.address, amountSat) + return reservationRouter + .connect(vaultSigner) + .requestReservedRedemption( + reservationKey, + redeemer, + script, + options.feePaid ?? true, + options.useRetryCredit ?? false + ) + } + + describe("requestReservedRedemption", () => { + const reservationKey = 888 + const amountSat = BigNumber.from(100000000) // 1 BTC + + before(async () => { + await createSnapshot() + await wireReservations() + await liveWallet(walletPubKeyHash) + + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert when called by a third party", async () => { + await expect( + reservationRouter + .connect(thirdParty) + .requestReservedRedemption( + reservationKey, + thirdParty.address, + redeemerOutputScript, + true, + false + ) + ).to.be.revertedWith("Caller is not the reservation vault") + }) + + it("should reject a zero redeemer", async () => { + await expect( + requestRedemptionViaVault( + reservationKey, + amountSat, + ethers.constants.AddressZero, + redeemerOutputScript + ) + ).to.be.revertedWith("Redeemer must not be the zero address") + }) + + it("should reject a non-standard redeemer output script", async () => { + await expect( + requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + "0xaa" + ) + ).to.be.revertedWith("Redeemer output script must be a standard type") + }) + + 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(reservationContract, "ReservationRedemptionRequested") + .withArgs( + reservationKey, + 1, + thirdParty.address, + redeemerOutputScript, + amountSat, + RESERVATION_TX_MAX_FEE, + true + ) + + const reservation = await reservationRouter.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 reservationRouter.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( + reservationRouter + .connect(vaultSigner) + .requestReservedRedemption( + reservationKey, + thirdParty.address, + redeemerOutputScript, + true, + false + ) + ).to.be.revertedWith("Reservation is not 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. + await increaseTime(action.timeoutAt + 1 - (await lastBlockTime())) + + const timeoutTx = await reservationRouter + .connect(thirdParty) + .notifyReservationRedemptionTimedOut(reservationKey, []) + + await expect(timeoutTx) + .to.emit(reservationContract, "ReservationRedemptionTimedOut") + .withArgs(reservationKey, 1) + await expect(timeoutTx) + .to.emit(reservationContract, "ReservationRetryCreditMinted") + .withArgs(reservationKey) + + expect(await bank.balanceOf(thirdParty.address)).to.equal(amountSat) + + const reservationAfter = await reservationRouter.reservations( + reservationKey + ) + expect(reservationAfter.state).to.equal(ReservationState.Active) + expect(reservationAfter.retryCredit).to.be.true + + expect( + (await reservationRouter.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 + ) + }) + }) + + describe("requestReservedRedemption gating", () => { + const reservationKey = 889 + const amountSat = BigNumber.from(100000000) + + before(async () => { + await createSnapshot() + await wireReservations() + await liveWallet(walletPubKeyHash) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should reject a non-active reservation", async () => { + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + await bridge.setReservation(reservationKey, { + ...(await activeReservation( + thirdParty.address, + walletPubKeyHash, + amountSat + )), + state: ReservationState.Unknown, + }) + await expect( + requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + ).to.be.revertedWith("Reservation is not active") + }) + + it("should reject a request past the dissolution eligibility time", async () => { + await bridge.setReservation(reservationKey, { + ...(await activeReservation( + thirdParty.address, + walletPubKeyHash, + amountSat + )), + dissolutionEligibleAt: 1, + }) + await expect( + requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + ).to.be.revertedWith("Reservation past grace period") + }) + + it("should exempt a retry request from the grace-period gate", async () => { + await bridge.setReservation(reservationKey, { + ...(await activeReservation( + thirdParty.address, + walletPubKeyHash, + amountSat + )), + dissolutionEligibleAt: 1, + retryCredit: true, + }) + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript, + { feePaid: false, useRetryCredit: true } + ) + const reservation = await reservationRouter.reservations(reservationKey) + expect(reservation.state).to.equal(ReservationState.ActionPending) + expect(reservation.requestNonce).to.equal(1) + // The retry entitlement was consumed by the exempted request. + expect(reservation.retryCredit).to.be.false + }) + }) + + describe("extendReservation", () => { + const reservationKey = 777 + const amountSat = BigNumber.from(100000000) + + before(async () => { + await createSnapshot() + await wireReservations() + await liveWallet(walletPubKeyHash) + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert when called by a third party", async () => { + await expect( + reservationRouter.connect(thirdParty).extendReservation(reservationKey) + ).to.be.revertedWith("Caller is not the reservation vault") + }) + + it("should extend the term against the live term and dissolution delay parameters", async () => { + const before = await reservationRouter.reservations(reservationKey) + + // Change the live parameters: this port extends against the live + // `reservationTermSeconds` / `reservationDissolutionDelay`, not a + // per-position snapshot (the base model removed `termSeconds` / + // `gracePeriod` from the request record). + await reservationRouter + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM * 2, + RESERVATION_GRACE * 2, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET, + RESERVATION_ACTION_TIMEOUT, + RESERVATION_RENEWAL_WINDOW + ) + + const vaultSigner = await impersonateContract(reservationVault.address) + const tx = await reservationRouter + .connect(vaultSigner) + .extendReservation(reservationKey) + + const after = await reservationRouter.reservations(reservationKey) + expect(after.expiresAt).to.equal(before.expiresAt + RESERVATION_TERM * 2) + expect(after.dissolutionEligibleAt).to.equal( + after.expiresAt + RESERVATION_GRACE * 2 + ) + await expect(tx) + .to.emit(reservationContract, "ReservationExtended") + .withArgs(reservationKey, after.expiresAt) + }) + }) + + describe("notifyReservedRedemptionVeto", () => { + const reservationKey = 555 + const amountSat = BigNumber.from(100000000) + + before(async () => { + await createSnapshot() + await wireReservations() + await liveWallet(walletPubKeyHash) + + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + + // Wire the watchtower only after the request so the safety gate + // does not call an EOA. + await bridge + .connect(bridgeGovernanceSigner) + .setRedemptionWatchtower(deployer.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert when called by a third party", async () => { + await expect( + reservationRouter + .connect(thirdParty) + .notifyReservedRedemptionVeto(reservationKey, 1) + ).to.be.revertedWith("Caller is not the redemption watchtower") + }) + + it("should revert for a non-current generation", async () => { + await expect( + reservationRouter + .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 reservationRouter + .connect(deployer) + .notifyReservedRedemptionVeto(reservationKey, 1) + + await expect(tx) + .to.emit(reservationContract, "ReservationRedemptionVetoed") + .withArgs(reservationKey, 1) + + expect(await bank.balanceOf(deployer.address)).to.equal(amountSat) + + const reservation = await reservationRouter.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 reservationRouter.reservationActions(reservationKey, 1)).state + ).to.equal(ActionState.Vetoed) + }) + }) + + describe("RedemptionWatchtower reserved veto flow", () => { + const reservationKey = 666 + const amountSat = BigNumber.from(100000000) + 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() + + redemptionWatchtower = await helpers.contracts.getContract( + "RedemptionWatchtower" + ) + + guardianSigners = (await ethers.getSigners()).slice(10, 13) + if ((await redemptionWatchtower.watchtowerEnabledAt()) === 0) { + const watchtowerOwner = await impersonateContract( + await redemptionWatchtower.owner() + ) + await redemptionWatchtower.connect(watchtowerOwner).enableWatchtower( + governance.address, + guardianSigners.map((g) => g.address) + ) + } else { + const manager = await impersonateContract( + await redemptionWatchtower.manager() + ) + // eslint-disable-next-line no-restricted-syntax + for (const guardian of guardianSigners) { + // eslint-disable-next-line no-await-in-loop + const alreadyGuardian = await redemptionWatchtower.isGuardian( + guardian.address + ) + if (!alreadyGuardian) { + // eslint-disable-next-line no-await-in-loop + await redemptionWatchtower + .connect(manager) + .addGuardian(guardian.address) + } + } + } + await bridge + .connect(bridgeGovernanceSigner) + .setRedemptionWatchtower(redemptionWatchtower.address) + + await liveWallet(walletPubKeyHash) + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("vetoes a reserved redemption generation after three guardian objections", async () => { + await redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey, 1) + await redemptionWatchtower + .connect(guardianSigners[1]) + .raiseReservedObjection(reservationKey, 1) + + const tx = await redemptionWatchtower + .connect(guardianSigners[2]) + .raiseReservedObjection(reservationKey, 1) + + const vetoKey = vetoKeyOf(reservationKey, 1) + await expect(tx) + .to.emit(redemptionWatchtower, "VetoFinalized") + .withArgs(vetoKey) + await expect(tx) + .to.emit(reservationContract, "ReservationRedemptionVetoed") + .withArgs(reservationKey, 1) + await expect(tx) + .to.emit(redemptionWatchtower, "Banned") + .withArgs(thirdParty.address) + + // The reservation survives the veto as Active; the generation is + // terminally vetoed. + expect( + (await reservationRouter.reservations(reservationKey)).state + ).to.equal(ReservationState.Active) + expect( + (await reservationRouter.reservationActions(reservationKey, 1)).state + ).to.equal(ActionState.Vetoed) + + // Default penalty is 100%: the whole detained amount is burned. + 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 + // reservation-scoped safety gate now rejects them. + const vaultSigner = await impersonateContract(reservationVault.address) + await expect( + reservationRouter + .connect(vaultSigner) + .requestReservedRedemption( + reservationKey, + thirdParty.address, + 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 () => { + await redemptionWatchtower.connect(governance).unban(thirdParty.address) + + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + + const reservation = await reservationRouter.reservations(reservationKey) + expect(reservation.state).to.equal(ReservationState.ActionPending) + expect(reservation.requestNonce).to.equal(2) + + await redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey, 2) + const veto = await redemptionWatchtower.vetoProposals( + vetoKeyOf(reservationKey, 2) + ) + expect(veto.objectionsCount).to.equal(1) + expect( + (await reservationRouter.reservations(reservationKey)).state + ).to.equal(ReservationState.ActionPending) + }) + }) + + describe("RedemptionWatchtower reserved delay snapshots", () => { + const reservationKey = 667 + const amountSat = BigNumber.from(100000000) + let redemptionWatchtower: Contract + let guardianSigners: SignerWithAddress[] + let watchtowerManager: SignerWithAddress + + async function updateDelayPolicy( + defaultDelay: number, + levelOneDelay: number, + levelTwoDelay: number, + waivedAmountLimit: BigNumber | number + ) { + return redemptionWatchtower + .connect(watchtowerManager) + .updateWatchtowerParameters( + await redemptionWatchtower.watchtowerLifetime(), + 20, + await redemptionWatchtower.vetoFreezePeriod(), + defaultDelay, + levelOneDelay, + levelTwoDelay, + waivedAmountLimit + ) + } + + async function requestRedemption() { + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + } + + const vetoKeyOf = (key: BigNumber | number, nonce: number): string => + ethers.utils.solidityKeccak256(["uint256", "uint64"], [key, nonce]) + + before(async () => { + await createSnapshot() + await wireReservations() + + redemptionWatchtower = await helpers.contracts.getContract( + "RedemptionWatchtower" + ) + guardianSigners = (await ethers.getSigners()).slice(10, 13) + const watchtowerOwner = await impersonateContract( + await redemptionWatchtower.owner() + ) + await redemptionWatchtower.connect(watchtowerOwner).enableWatchtower( + governance.address, + guardianSigners.map((guardian) => guardian.address) + ) + watchtowerManager = await impersonateContract( + await redemptionWatchtower.manager() + ) + await bridge + .connect(bridgeGovernanceSigner) + .setRedemptionWatchtower(redemptionWatchtower.address) + + await liveWallet(walletPubKeyHash) + }) + + after(async () => { + await restoreSnapshot() + }) + + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("keeps every objection-level delay on the old generation and applies updates to the next one", async () => { + const defaultDelay = await redemptionWatchtower.defaultDelay() + const levelOneDelay = await redemptionWatchtower.levelOneDelay() + const levelTwoDelay = await redemptionWatchtower.levelTwoDelay() + await requestRedemption() + + const oldAction = await reservationRouter.reservationActions( + reservationKey, + 1 + ) + expect(oldAction.watchtowerDefaultDelay).to.equal(defaultDelay) + expect(oldAction.watchtowerLevelOneDelay).to.equal(levelOneDelay) + expect(oldAction.watchtowerLevelTwoDelay).to.equal(levelTwoDelay) + + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(defaultDelay) + + const newDefaultDelay = defaultDelay + 60 + const newLevelOneDelay = levelOneDelay + 120 + const newLevelTwoDelay = levelTwoDelay + 180 + await updateDelayPolicy( + newDefaultDelay, + newLevelOneDelay, + newLevelTwoDelay, + 0 + ) + + // Policy changes never rewrite the authorization window of an already + // requested generation. + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(defaultDelay) + + await redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey, 1) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(levelOneDelay) + + await redemptionWatchtower + .connect(guardianSigners[1]) + .raiseReservedObjection(reservationKey, 1) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(levelTwoDelay) + + await redemptionWatchtower + .connect(guardianSigners[2]) + .raiseReservedObjection(reservationKey, 1) + await redemptionWatchtower + .connect(watchtowerManager) + .unban(thirdParty.address) + + await requestRedemptionViaVault( + reservationKey, + amountSat, + thirdParty.address, + redeemerOutputScript + ) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 2) + ).to.equal(newDefaultDelay) + }) + + it("keeps a waived generation objection-free after the waiver policy changes", async () => { + const defaultDelay = await redemptionWatchtower.defaultDelay() + const levelOneDelay = await redemptionWatchtower.levelOneDelay() + const levelTwoDelay = await redemptionWatchtower.levelTwoDelay() + await updateDelayPolicy( + defaultDelay, + levelOneDelay, + levelTwoDelay, + amountSat.add(1) + ) + await requestRedemption() + + const action = await reservationRouter.reservationActions( + reservationKey, + 1 + ) + expect(action.watchtowerDefaultDelay).to.equal(0) + expect(action.watchtowerLevelOneDelay).to.equal(0) + expect(action.watchtowerLevelTwoDelay).to.equal(0) + + // Removing the waiver affects future generations only. This generation + // retains the zero schedule captured when it was requested. + await updateDelayPolicy(defaultDelay, levelOneDelay, levelTwoDelay, 0) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(0) + await expect( + redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey, 1) + ).to.be.revertedWith("Redemption veto delay period expired") + }) + + it("captures a zero schedule after the watchtower is disabled", async () => { + const lifetimeExpiresAt = + (await redemptionWatchtower.watchtowerEnabledAt()) + + (await redemptionWatchtower.watchtowerLifetime()) + // The lifetime bound is strict (`> watchtowerEnabledAt + lifetime`). + await increaseTime(lifetimeExpiresAt - (await lastBlockTime()) + 1) + await redemptionWatchtower.connect(thirdParty).disableWatchtower() + + await requestRedemption() + + const action = await reservationRouter.reservationActions( + reservationKey, + 1 + ) + expect(action.watchtowerDefaultDelay).to.equal(0) + expect(action.watchtowerLevelOneDelay).to.equal(0) + expect(action.watchtowerLevelTwoDelay).to.equal(0) + expect( + await redemptionWatchtower.getReservedRedemptionDelay(reservationKey, 1) + ).to.equal(0) + await expect( + redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey, 1) + ).to.be.revertedWith("Redemption veto delay period expired") + }) + + it("pays the withdrawable remainder to the redeemer after the freeze period", async () => { + // `updateDelayPolicy` sets the penalty divisor to 20: 5% of the + // escrowed amount is burned as the penalty, the remainder stays + // withdrawable by the redeemer through the shared + // `withdrawVetoedFunds` path once the freeze period elapses. + await updateDelayPolicy( + await redemptionWatchtower.defaultDelay(), + await redemptionWatchtower.levelOneDelay(), + await redemptionWatchtower.levelTwoDelay(), + 0 + ) + await requestRedemption() + + await redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey, 1) + await redemptionWatchtower + .connect(guardianSigners[1]) + .raiseReservedObjection(reservationKey, 1) + await redemptionWatchtower + .connect(guardianSigners[2]) + .raiseReservedObjection(reservationKey, 1) + + const vetoKey = vetoKeyOf(reservationKey, 1) + const veto = await redemptionWatchtower.vetoProposals(vetoKey) + const expectedWithdrawable = amountSat.sub(amountSat.div(20)) + expect(veto.withdrawableAmount).to.equal(expectedWithdrawable) + + // The veto freeze period is strict (`> finalizedAt + vetoFreezePeriod`). + await increaseTime((await redemptionWatchtower.vetoFreezePeriod()) + 1) + + const redeemerBalanceBefore = await bank.balanceOf(thirdParty.address) + await redemptionWatchtower + .connect(thirdParty) + .withdrawVetoedFunds(vetoKey) + + expect(await bank.balanceOf(thirdParty.address)).to.equal( + redeemerBalanceBefore.add(expectedWithdrawable) + ) + // The withdrawable amount is consumed by the withdrawal. + expect( + (await redemptionWatchtower.vetoProposals(vetoKey)).withdrawableAmount + ).to.equal(0) + }) + }) + + describe("ReservationVault.redeemReservation", () => { + const reservationKey = 999 + const amountSat = BigNumber.from(100000000) // 1 BTC + const grossTbtc = amountSat.mul(SATOSHI_MULTIPLIER) + + before(async () => { + await createSnapshot() + await wireReservations() + await liveWallet(walletPubKeyHash) + + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + + // Fund the owner with enough TBTC for the gross surrender plus the + // redemption fee by processing another acceptance-sized credit. + const bridgeSigner = await impersonateContract(bridge.address) + await bank + .connect(bridgeSigner) + .increaseBalanceAndCall( + reservationVault.address, + [thirdParty.address], + [amountSat.mul(2)] + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert when called by a non-owner", async () => { + await expect( + reservationVault + .connect(governance) + .redeemReservation(reservationKey, redeemerOutputScript, 0) + ).to.be.revertedWith("Caller is not the reservation owner") + }) + + it("should surrender gross TBTC, charge the redemption fee, and register the redemption", async () => { + const fee = grossTbtc.mul(20).div(10000) + const treasuryBalanceBefore = await tbtc.balanceOf(treasury.address) + + await tbtc + .connect(thirdParty) + .approve(reservationVault.address, grossTbtc.add(fee)) + + const tx = await reservationVault + .connect(thirdParty) + .redeemReservation(reservationKey, redeemerOutputScript, fee) + + await expect(tx) + .to.emit(reservationVault, "ReservedRedemptionInitiated") + .withArgs(reservationKey, thirdParty.address, grossTbtc, fee) + await expect(tx).to.emit( + reservationContract, + "ReservationRedemptionRequested" + ) + + expect(await tbtc.balanceOf(treasury.address)).to.equal( + treasuryBalanceBefore.add(fee) + ) + expect( + (await reservationRouter.reservations(reservationKey)).state + ).to.equal(ReservationState.ActionPending) + expect( + (await reservationRouter.reservationActions(reservationKey, 1)).feePaid + ).to.be.true + expect(await bank.balanceOf(bridge.address)).to.equal(amountSat) + }) + + it("should reject a stale fee quote after governance update and accept the exact bound", async () => { + const exposedReservationKey = 998 + const quotedFee = grossTbtc.mul(20).div(10000) + const updatedFee = grossTbtc.mul(500).div(10000) + + await bridge.setReservation( + exposedReservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + + const bridgeSigner = await impersonateContract(bridge.address) + await bank + .connect(bridgeSigner) + .increaseBalanceAndCall( + reservationVault.address, + [thirdParty.address], + [amountSat.mul(2)] + ) + await tbtc + .connect(thirdParty) + .approve(reservationVault.address, ethers.constants.MaxUint256) + + const ownerBalanceBefore = await tbtc.balanceOf(thirdParty.address) + const treasuryBalanceBefore = await tbtc.balanceOf(treasury.address) + + const vaultOwner = await impersonateContract( + await reservationVault.owner() + ) + await reservationVault.connect(vaultOwner).updateFees(40, 20, 500) + + await expect( + reservationVault + .connect(thirdParty) + .redeemReservation( + exposedReservationKey, + redeemerOutputScript, + quotedFee + ) + ).to.be.revertedWith("Fee exceeds the caller's bound") + + expect(await tbtc.balanceOf(thirdParty.address)).to.equal( + ownerBalanceBefore + ) + expect(await tbtc.balanceOf(treasury.address)).to.equal( + treasuryBalanceBefore + ) + expect( + (await reservationRouter.reservations(exposedReservationKey)).state + ).to.equal(ReservationState.Active) + + const tx = await reservationVault + .connect(thirdParty) + .redeemReservation( + exposedReservationKey, + redeemerOutputScript, + updatedFee + ) + + await expect(tx) + .to.emit(reservationVault, "ReservedRedemptionInitiated") + .withArgs( + exposedReservationKey, + thirdParty.address, + grossTbtc, + updatedFee + ) + expect(await tbtc.balanceOf(treasury.address)).to.equal( + treasuryBalanceBefore.add(updatedFee) + ) + expect( + (await reservationRouter.reservations(exposedReservationKey)).state + ).to.equal(ReservationState.ActionPending) + + await reservationVault.connect(vaultOwner).updateFees(40, 20, 20) + }) + }) + + describe("ReservationVault.retryRedeemReservation", () => { + const reservationKey = 444 + const amountSat = BigNumber.from(100000000) + const grossTbtc = amountSat.mul(SATOSHI_MULTIPLIER) + + before(async () => { + await createSnapshot() + await wireReservations() + await liveWallet(walletPubKeyHash) + + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, 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 timedOutAction = await reservationRouter.reservationActions( + reservationKey, + 1 + ) + await increaseTime(timedOutAction.timeoutAt + 1 - (await lastBlockTime())) + await reservationRouter + .connect(thirdParty) + .notifyReservationRedemptionTimedOut(reservationKey, []) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("retries the redemption from Bank balance without re-charging the fee", async () => { + await bank + .connect(thirdParty) + .approveBalance(reservationVault.address, amountSat) + const treasuryBalanceBefore = await tbtc.balanceOf(treasury.address) + + const tx = await reservationVault + .connect(thirdParty) + .retryRedeemReservation(reservationKey, redeemerOutputScript) + + await expect(tx) + .to.emit(reservationContract, "ReservationRedemptionRequested") + .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 reservationRouter.reservations(reservationKey)).retryCredit) + .to.be.false + }) + + it("rejects a retry without a retry entitlement", async () => { + // A fresh reservation that never minted the entitlement: the retry + // request reaches the entitlement gate (the wallet is Live, holding + // a main UTXO) and fails on `retryCredit == false`. + const freshReservationKey = 445 + await bridge.setReservation( + freshReservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + + const bridgeSigner = await impersonateContract(bridge.address) + await bank + .connect(bridgeSigner) + .increaseBalance(thirdParty.address, amountSat) + await bank + .connect(thirdParty) + .approveBalance(reservationVault.address, amountSat) + await expect( + reservationVault + .connect(thirdParty) + .retryRedeemReservation(freshReservationKey, redeemerOutputScript) + ).to.be.revertedWith("No retry entitlement") + }) + }) +}) diff --git a/solidity/test/bridge/ReservationProofs.test.ts b/solidity/test/bridge/ReservationProofs.test.ts index 721a133e1..05dced5ea 100644 --- a/solidity/test/bridge/ReservationProofs.test.ts +++ b/solidity/test/bridge/ReservationProofs.test.ts @@ -225,8 +225,11 @@ describe("ReservationProofs", () => { ).to.be.revertedWith("Unsupported reservation proof type") }) - it("should revert if proof type is not supported (Redemption, Dissolution)", async () => { - // ProofType.Redemption = 1 + it("should route Redemption proofs to the reserved-redemption path", async () => { + // ProofType.Redemption = 1 is now supported: the dispatcher routes + // it to `submitReservedRedemptionProof`, which fails deeper on the + // fixture's missing action record ("Action type mismatch") rather + // than on the unsupported-type gate. await expect( testReservationProofs.submitReservationProof( 1, @@ -236,9 +239,11 @@ describe("ReservationProofs", () => { sampleReservationKey, 1 ) - ).to.be.revertedWith("Unsupported reservation proof type") + ).to.be.revertedWith("Action type mismatch") + }) - // ProofType.Dissolution = 3 + it("should revert if proof type is not supported (Dissolution)", async () => { + // ProofType.Dissolution = 3 remains unsupported in milestone 1. await expect( testReservationProofs.submitReservationProof( 3, diff --git a/solidity/test/vault/ReservationVault.test.ts b/solidity/test/vault/ReservationVault.test.ts index 4ec770578..5962749c5 100644 --- a/solidity/test/vault/ReservationVault.test.ts +++ b/solidity/test/vault/ReservationVault.test.ts @@ -3,7 +3,7 @@ import { ethers, helpers } from "hardhat" import { expect } from "chai" import { BigNumber, ContractTransaction } from "ethers" import { loadFixture } from "../helpers/fixture" -import { createMock } from "../helpers/mock" +import { createMock, expectCalledOnceWith } from "../helpers/mock" import type { Mock } from "../helpers/mock" import type { @@ -926,131 +926,192 @@ describe("ReservationVault", () => { describe("redeemReservation", () => { const reservationKey = 42 + const amountSat = 100000 + const grossTbtc = satsToTbtc(amountSat) + // Default schedule: 20 bps redemption fee. + const fee = grossTbtc.mul(20).div(10000) + const redeemerOutputScript = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" before(async () => { await createSnapshot() + await vault.unpauseRedemptions() }) after(async () => { await restoreSnapshot() }) - context("when called by a non-owner of the reservation", () => { + context("while redemptions are paused", () => { before(async () => { await createSnapshot() + await vault.pauseRedemptions() }) after(async () => { await restoreSnapshot() }) - it("should revert with pause message while redemptions are paused", async () => { - expect(await vault.redemptionsPaused()).to.equal(true) + it("should revert before checking ownership", async () => { await expect( - vault.connect(account1).redeemReservation(reservationKey, 100_000) + vault + .connect(account1) + .redeemReservation(reservationKey, redeemerOutputScript, fee) ).to.be.revertedWith("Redemptions are paused") }) - - it("should revert with ownership message while redemptions are unpaused", async () => { - await vault.unpauseRedemptions() - expect(await vault.redemptionsPaused()).to.equal(false) - await expect( - vault.connect(account1).redeemReservation(reservationKey, 100_000) - ).to.be.revertedWith("Caller is not the reservation owner") - }) }) - context("when called by the reservation owner", () => { + context("when called by a non-owner of the reservation", () => { before(async () => { await createSnapshot() - await bridge.reservations - .whenCalledWith(reservationKey) - .returns(reservationWithOwner(account1.address)) }) after(async () => { - await bridge.reservations.reset() await restoreSnapshot() }) - it("should revert with pause message while redemptions are paused", async () => { - expect(await vault.redemptionsPaused()).to.equal(true) - await expect( - vault.connect(account1).redeemReservation(reservationKey, 100_000) - ).to.be.revertedWith("Redemptions are paused") - }) - - it("should revert with the milestone-1 message while redemptions are unpaused", async () => { + it("should revert with ownership message while redemptions are unpaused", async () => { await vault.unpauseRedemptions() expect(await vault.redemptionsPaused()).to.equal(false) await expect( - vault.connect(account1).redeemReservation(reservationKey, 100_000) - ).to.be.revertedWith("Reserved redemption not enabled in milestone 1") + vault + .connect(account1) + .redeemReservation(reservationKey, redeemerOutputScript, fee) + ).to.be.revertedWith("Caller is not the reservation owner") }) }) - context("pause state distinguishability", () => { + context("when called by the reservation owner", () => { before(async () => { await createSnapshot() - await bridge.reservations - .whenCalledWith(reservationKey) - .returns(reservationWithOwner(account1.address)) + await bridge.reservations.whenCalledWith(reservationKey).returns({ + ...reservationWithOwner(account1.address), + mintedAmount: amountSat, + }) + await bridge.treasury.returns(account2.address) + + // Two acceptance-sized credits: the owner receives the gross TBTC + // (minus the initiation fee) twice and the TBTCVault receives the + // corresponding Bank balance, so the unmint leg of the redemption + // has backing to return to this vault. + await bank + .connect(bridge.wallet) + .increaseBalanceAndCall( + vault.address, + [account1.address], + [amountSat] + ) + await bank + .connect(bridge.wallet) + .increaseBalanceAndCall( + vault.address, + [account1.address], + [amountSat] + ) + + await tbtc.connect(account1).approve(vault.address, grossTbtc.add(fee)) }) after(async () => { await bridge.reservations.reset() + await bridge.treasury.reset() await restoreSnapshot() }) - it("should distinguish between paused and unpaused states", async () => { - // While paused, reverts with "Redemptions are paused" - expect(await vault.redemptionsPaused()).to.equal(true) - await expect( - vault.connect(account1).redeemReservation(reservationKey, 100_000) - ).to.be.revertedWith("Redemptions are paused") + it("should surrender gross TBTC, charge the redemption fee, and leave the escrowed Bank balance at the vault", async () => { + const ownerBalanceBefore = await tbtc.balanceOf(account1.address) + const bankBalanceBefore = await bank.balanceOf(vault.address) + const treasuryBalanceBefore = await tbtc.balanceOf(account2.address) - // While unpaused, reverts with milestone-1 disabled string - await vault.unpauseRedemptions() - expect(await vault.redemptionsPaused()).to.equal(false) + const tx = await vault + .connect(account1) + .redeemReservation(reservationKey, redeemerOutputScript, fee) + + await expect(tx) + .to.emit(vault, "ReservedRedemptionInitiated") + .withArgs(reservationKey, account1.address, grossTbtc, fee) + + // The owner surrendered the gross amount plus the fee... + expect(await tbtc.balanceOf(account1.address)).to.equal( + ownerBalanceBefore.sub(grossTbtc.add(fee)) + ) + // ...the fee was forwarded to the treasury... + expect(await tbtc.balanceOf(account2.address)).to.equal( + treasuryBalanceBefore.add(fee) + ) + // ...and the unmint leg moved the gross Bank balance out of the + // TBTCVault into this vault, ready for the Bridge to escrow on + // `requestReservedRedemption`. + expect(await bank.balanceOf(vault.address)).to.equal( + bankBalanceBefore.add(amountSat) + ) + }) + + it("should reject a redemption whose fee exceeds the caller's bound", async () => { + const ownerBalanceBefore = await tbtc.balanceOf(account1.address) await expect( - vault.connect(account1).redeemReservation(reservationKey, 100_000) - ).to.be.revertedWith("Reserved redemption not enabled in milestone 1") + vault + .connect(account1) + .redeemReservation(reservationKey, redeemerOutputScript, fee.sub(1)) + ).to.be.revertedWith("Fee exceeds the caller's bound") + + expect(await tbtc.balanceOf(account1.address)).to.equal( + ownerBalanceBefore + ) }) }) }) describe("retryRedeemReservation", () => { const reservationKey = 43 + const amountSat = 100000 + const redeemerOutputScript = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" before(async () => { await createSnapshot() + await vault.unpauseRedemptions() }) after(async () => { await restoreSnapshot() }) - context("when called by a non-owner of the reservation", () => { + context("while redemptions are paused", () => { before(async () => { await createSnapshot() + await vault.pauseRedemptions() }) after(async () => { await restoreSnapshot() }) - it("should revert with pause message while redemptions are paused", async () => { - expect(await vault.redemptionsPaused()).to.equal(true) + it("should revert before checking ownership", async () => { await expect( - vault.connect(account1).retryRedeemReservation(reservationKey, 0) + vault + .connect(account1) + .retryRedeemReservation(reservationKey, redeemerOutputScript) ).to.be.revertedWith("Redemptions are paused") }) + }) + + context("when called by a non-owner of the reservation", () => { + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) it("should revert with ownership message while redemptions are unpaused", async () => { await vault.unpauseRedemptions() expect(await vault.redemptionsPaused()).to.equal(false) await expect( - vault.connect(account1).retryRedeemReservation(reservationKey, 0) + vault + .connect(account1) + .retryRedeemReservation(reservationKey, redeemerOutputScript) ).to.be.revertedWith("Caller is not the reservation owner") }) }) @@ -1058,35 +1119,84 @@ describe("ReservationVault", () => { context("when called by the reservation owner", () => { before(async () => { await createSnapshot() - await bridge.reservations - .whenCalledWith(reservationKey) - .returns(reservationWithOwner(account1.address)) + await bridge.reservations.whenCalledWith(reservationKey).returns({ + ...reservationWithOwner(account1.address), + mintedAmount: amountSat, + }) + await bridge.treasury.returns(account2.address) + + // The retry draws from the caller's Bank balance: the state a + // timed-out redemption leaves the owner in (the Bridge refunds + // the escrow as Bank balance). + await bank + .connect(bridge.wallet) + .increaseBalance(account1.address, amountSat) + await bank.connect(account1).approveBalance(vault.address, amountSat) }) after(async () => { await bridge.reservations.reset() + await bridge.treasury.reset() await restoreSnapshot() }) - it("should revert with pause message while redemptions are paused", async () => { - expect(await vault.redemptionsPaused()).to.equal(true) - await expect( - vault.connect(account1).retryRedeemReservation(reservationKey, 0) - ).to.be.revertedWith("Redemptions are paused") + it("should surrender the gross amount as Bank balance without charging the fee", async () => { + const treasuryBalanceBefore = await tbtc.balanceOf(account2.address) + + const tx = await vault + .connect(account1) + .retryRedeemReservation(reservationKey, redeemerOutputScript) + + await expect(tx) + .to.emit(vault, "ReservedRedemptionInitiated") + .withArgs(reservationKey, account1.address, satsToTbtc(amountSat), 0) + + // The gross amount moved from the owner's Bank balance to this + // vault, ready for the Bridge to escrow. + expect(await bank.balanceOf(vault.address)).to.equal(amountSat) + expect(await bank.balanceOf(account1.address)).to.equal(0) + // No fee is re-charged on retry. + expect(await tbtc.balanceOf(account2.address)).to.equal( + treasuryBalanceBefore + ) }) + }) + }) - it("should revert with the milestone-1 message while redemptions are unpaused", async () => { - await vault.unpauseRedemptions() - expect(await vault.redemptionsPaused()).to.equal(false) + describe("extendCustody", () => { + const reservationKey = 44 + const amountSat = 100000 + const grossTbtc = satsToTbtc(amountSat) + // Default schedule: 20 bps extension fee. + const fee = grossTbtc.mul(20).div(10000) + + before(async () => { + await createSnapshot() + await vault.unpauseRenewals() + }) + + after(async () => { + await restoreSnapshot() + }) + + context("while renewals are paused", () => { + before(async () => { + await createSnapshot() + await vault.pauseRenewals() + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert before checking ownership", async () => { await expect( - vault.connect(account1).retryRedeemReservation(reservationKey, 0) - ).to.be.revertedWith( - "Reserved redemption retry not enabled in milestone 1" - ) + vault.connect(account1).extendCustody(reservationKey) + ).to.be.revertedWith("Renewals are paused") }) }) - context("pause state distinguishability", () => { + context("when called by a non-owner of the reservation", () => { before(async () => { await createSnapshot() await bridge.reservations @@ -1099,21 +1209,58 @@ describe("ReservationVault", () => { await restoreSnapshot() }) - it("should distinguish between paused and unpaused states", async () => { - // While paused, reverts with "Redemptions are paused" - expect(await vault.redemptionsPaused()).to.equal(true) + it("should revert with ownership message", async () => { await expect( - vault.connect(account1).retryRedeemReservation(reservationKey, 0) - ).to.be.revertedWith("Redemptions are paused") + vault.connect(account2).extendCustody(reservationKey) + ).to.be.revertedWith("Caller is not the reservation owner") + }) + }) - // While unpaused, reverts with milestone-1 disabled string - await vault.unpauseRedemptions() - expect(await vault.redemptionsPaused()).to.equal(false) - await expect( - vault.connect(account1).retryRedeemReservation(reservationKey, 0) - ).to.be.revertedWith( - "Reserved redemption retry not enabled in milestone 1" + context("when called by the reservation owner", () => { + before(async () => { + await createSnapshot() + await bridge.reservations.whenCalledWith(reservationKey).returns({ + ...reservationWithOwner(account1.address), + mintedAmount: amountSat, + }) + await bridge.treasury.returns(account2.address) + + // One acceptance-sized credit is far more TBTC than the small + // extension fee requires; it just needs to fund the approval. + await bank + .connect(bridge.wallet) + .increaseBalanceAndCall( + vault.address, + [account1.address], + [amountSat] + ) + + await tbtc.connect(account1).approve(vault.address, fee) + }) + + after(async () => { + await bridge.reservations.reset() + await bridge.treasury.reset() + await restoreSnapshot() + }) + + it("should charge the extension fee to the treasury and call through to the Bridge", async () => { + const ownerBalanceBefore = await tbtc.balanceOf(account1.address) + const treasuryBalanceBefore = await tbtc.balanceOf(account2.address) + + const tx = await vault.connect(account1).extendCustody(reservationKey) + + await expect(tx) + .to.emit(vault, "CustodyExtended") + .withArgs(reservationKey, account1.address, fee) + + expect(await tbtc.balanceOf(account1.address)).to.equal( + ownerBalanceBefore.sub(fee) + ) + expect(await tbtc.balanceOf(account2.address)).to.equal( + treasuryBalanceBefore.add(fee) ) + await expectCalledOnceWith(bridge.extendReservation, [reservationKey]) }) }) }) From d826c1ecfbbb6e9185f35cc1269cf3e971b684ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Mon, 7 Sep 2026 10:23:24 +0000 Subject: [PATCH 2/2] fix(bridge): close reserved-redemption escrow leak and double-claim Two P0 escrow bugs found in the #1116 multi-agent review of #1121 (reverted via #1122 pending a fix), fixed before re-landing: 1. unwindPendingAction had no branch for ActionType.Redemption: a pending redemption superseded by a late acceptance/reanchor/ redemption settlement on the same position was marked Superseded with its escrowed mintedAmount never refunded, permanently stranding it as unbacked Bank balance on the Bridge. Added the missing refund branch, matching notifyReservationRedemptionTimedOut. 2. loadSettleableAction allowed late (post-TimedOut) settlement of a Redemption action. notifyReservationRedemptionTimedOut already refunds the full escrowed claim to the redeemer and returns the reservation to Active on timeout; permitting a late proof afterward let a wallet that confirms the redemption transaction after the timeout deliver the BTC while the redeemer keeps the refunded TBTC, burning nothing. Reachable via ordinary slow confirmation, no attacker required. Restricted loadSettleableAction so a Redemption action must still be Pending to settle; a TimedOut redemption can never be proven again. Deleted the now-unreachable resolveLateRedemptionAgainstPending and the dead late-branch of submitReservedRedemptionProof. Adds the escrow-path test coverage that was previously zero for submitReservedRedemptionProof: regression tests for both fixes at the loadSettleableAction/unwindPendingAction unit level and through the real submitReservationProof dispatcher entry point. --- .../contracts/bridge/ReservationProofs.sol | 166 +++++++----------- .../contracts/test/TestReservationProofs.sol | 8 + .../test/bridge/ReservationProofs.test.ts | 116 ++++++++++++ 3 files changed, 192 insertions(+), 98 deletions(-) diff --git a/solidity/contracts/bridge/ReservationProofs.sol b/solidity/contracts/bridge/ReservationProofs.sol index f62a5562d..a8e3fb063 100644 --- a/solidity/contracts/bridge/ReservationProofs.sol +++ b/solidity/contracts/bridge/ReservationProofs.sol @@ -187,8 +187,9 @@ library ReservationProofs { } /// @notice Loads the action record of the generation being settled and - /// validates it is settleable (`Pending` or `TimedOut`) and of - /// the expected type. + /// validates it is settleable (`Pending`, or `TimedOut` for + /// action types that allow late settlement) and of the expected + /// type. /// @return action The action record. /// @return late True when settling a timed-out generation. function loadSettleableAction( @@ -211,6 +212,20 @@ library ReservationProofs { "Action is not settleable" ); late = action.state == Reservation.ActionState.TimedOut; + // Redemption timeouts are terminal: `notifyReservationRedemptionTimedOut` + // already refunds the full escrowed claim back to the redeemer as + // Bank balance and returns the reservation to `Active`. Unlike + // acceptance and re-anchor, there is no late-settlement path for a + // timed-out redemption -- allowing one would let a wallet that + // confirms the redemption transaction after the timeout deliver the + // BTC while the redeemer also keeps the refunded TBTC, burning + // nothing. A wallet that broadcasts after the timeout simply + // produces an output the protocol no longer recognizes as a + // redemption; it becomes a fresh anchor requiring a new acceptance. + require( + !late || expectedType != Reservation.ActionType.Redemption, + "Redemption action already timed out and refunded" + ); // Bound late acceptance settlement (M15 deferral mitigation): // a timed-out acceptance action may be settled late only within // the reservation term following its timeout. Reanchor late @@ -874,10 +889,13 @@ library ReservationProofs { /// `mintedAmount`) and the position's capacity counters are /// released. /// @dev Requirements: - /// - The named generation must be a settleable redemption, - /// - For an on-time settlement, the reservation must not have a - /// newer pending generation, and the watchtower delay snapshotted - /// for the generation must have elapsed, + /// - The named generation must be a settleable, still-`Pending` + /// redemption (a `TimedOut` redemption was already refunded by + /// `notifyReservationRedemptionTimedOut` and can never be + /// settled -- see `loadSettleableAction`), + /// - The reservation must not have a newer pending generation, + /// - The watchtower delay snapshotted for the generation must have + /// elapsed, /// - `redemptionTx` must spend the current anchor outpoint as its /// sole input, /// - `redemptionTx` must have exactly one output paying the @@ -891,15 +909,12 @@ library ReservationProofs { uint256 reservationKey, uint64 requestNonce ) internal { - ( - Reservation.ReservationAction storage action, - bool late - ) = loadSettleableAction( - self, - reservationKey, - requestNonce, - Reservation.ActionType.Redemption - ); + (Reservation.ReservationAction storage action, ) = loadSettleableAction( + self, + reservationKey, + requestNonce, + Reservation.ActionType.Redemption + ); Reservation.ReservationRequest storage reservation = self.reservations[ reservationKey @@ -910,30 +925,28 @@ library ReservationProofs { "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( - reservation.requestNonce == requestNonce, - "Not the current generation" + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp >= + uint256(action.requestedAt) + + IRedemptionWatchtower(self.redemptionWatchtower) + .getReservedRedemptionDelay( + reservationKey, + requestNonce + ), + "Watchtower delay has not elapsed" ); - - // 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" - ); - } } requireCurrentSourceAnchor(reservation, action); @@ -966,25 +979,6 @@ library ReservationProofs { "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, reservationKey); @@ -995,48 +989,9 @@ library ReservationProofs { redemptionTxHash ); - if (!late) { - // Burn the gross minted amount held by the Bridge since the - // redemption request. - self.bank.decreaseBalance(action.amount); - } - } - - /// @notice Resolves a late redemption settlement against the position's - /// current pending generation. If the pending generation is - /// also a redemption authorizing the same redeemer output - /// script within its own fee bound, the proof must settle - /// against it instead (the wallet could have signed either - /// generation's authorization for this exact spend, and the - /// refund bookkeeping must attribute to the correct - /// generation); otherwise the pending generation's anchor is - /// provably gone and it is unwound, refunding its escrow. - 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.actionDataHash == action.actionDataHash && - 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, false); + // Burn the gross minted amount held by the Bridge since the + // redemption request. + self.bank.decreaseBalance(action.amount); } /// @notice Unwinds the position's current pending generation during a @@ -1082,6 +1037,21 @@ library ReservationProofs { pendingAction.targetWalletPubKeyHash, pendingAction.amount ); + } else if ( + pendingAction.actionType == Reservation.ActionType.Redemption + ) { + // A superseded redemption escrowed the caller's full gross + // claim at request time (see `requestReservedRedemption`); the + // anchor it was authorized to spend is now provably consumed + // by the settling generation, so this redemption can never + // complete. Refund the escrow to the redeemer exactly as + // `notifyReservationRedemptionTimedOut` does on an ordinary + // timeout, otherwise the Bridge would keep the redeemer's + // full mintedAmount as unbacked Bank balance forever. + self.bank.transferBalance( + pendingAction.redeemer, + pendingAction.amount + ); } // The Bank is a trusted protocol contract; the refund above cannot // reenter in a way that makes this event misleading. diff --git a/solidity/contracts/test/TestReservationProofs.sol b/solidity/contracts/test/TestReservationProofs.sol index 00685e77b..66e0304e0 100644 --- a/solidity/contracts/test/TestReservationProofs.sol +++ b/solidity/contracts/test/TestReservationProofs.sol @@ -285,6 +285,14 @@ contract TestReservationProofs { state.bank = Bank(_bank); } + /// @notice Test-only helper: credits `account` with `amount` Bank + /// balance. Used to seed the escrow this contract (acting as + /// the Bridge) is expected to hold before exercising a refund + /// path such as `unwindPendingAction`. + function fundBankBalance(address account, uint256 amount) external { + state.bank.increaseBalance(account, amount); + } + function setRelay(address _relay) external { state.relay = IRelay(_relay); } diff --git a/solidity/test/bridge/ReservationProofs.test.ts b/solidity/test/bridge/ReservationProofs.test.ts index 05dced5ea..99138a772 100644 --- a/solidity/test/bridge/ReservationProofs.test.ts +++ b/solidity/test/bridge/ReservationProofs.test.ts @@ -242,6 +242,33 @@ describe("ReservationProofs", () => { ).to.be.revertedWith("Action type mismatch") }) + it("should reject a redemption proof through the real entry point when the generation already timed out", async () => { + // Regression test for the late-redemption double-claim: reaches the + // dispatcher exactly as a wallet would, proving the guard added to + // `loadSettleableAction` is wired through `submitReservationProof` + // and fires before any Bitcoin SPV proof is even inspected. + await testReservationProofs.setReservationAction( + sampleReservationKey, + 1, + buildReservationAction({ + actionType: 2, // Redemption + state: 3, // TimedOut + redeemer: redeemer.address, + }) + ) + + await expect( + testReservationProofs.submitReservationProof( + 1, // ProofType.Redemption + dummyTxInfo, + dummyProof, + dummyUtxo, + sampleReservationKey, + 1 + ) + ).to.be.revertedWith("Redemption action already timed out and refunded") + }) + it("should revert if proof type is not supported (Dissolution)", async () => { // ProofType.Dissolution = 3 remains unsupported in milestone 1. await expect( @@ -411,6 +438,31 @@ describe("ReservationProofs", () => { expect(action.state).to.equal(3) // TimedOut expect(late).to.be.true }) + + it("should revert if a Redemption action is TimedOut (late settlement is disallowed)", async () => { + // `notifyReservationRedemptionTimedOut` already refunds the full + // escrowed claim back to the redeemer on an ordinary timeout, so a + // TimedOut redemption can never be settled late: doing so would let + // the redeemer keep the refund and the wallet's delivered BTC both, + // burning nothing. + await testReservationProofs.setReservationAction( + sampleReservationKey, + requestNonce, + buildReservationAction({ + actionType: 2, // Redemption + state: 3, // TimedOut + redeemer: redeemer.address, + }) + ) + + await expect( + testReservationProofs.loadSettleableAction( + sampleReservationKey, + requestNonce, + 2 // Redemption + ) + ).to.be.revertedWith("Redemption action already timed out and refunded") + }) }) describe("4. Pending-reserved and existence guards", () => { const requestNonce = 1 @@ -1138,6 +1190,70 @@ describe("ReservationProofs", () => { ) ).to.equal(0) }) + + it("should unwind pending redemption action and refund its escrow to the redeemer", async () => { + // Regression test: `unwindPendingAction` previously had no branch for + // ActionType.Redemption, so a superseded pending redemption's + // escrowed claim was silently stranded as unbacked Bank balance on + // this contract forever instead of being refunded to its redeemer. + const redemptionNonce = 1 + + await testReservationProofs.setReservationAction( + sampleReservationKey, + redemptionNonce, + buildReservationAction({ + actionType: 2, // Redemption + state: 1, // Pending + amount: depositAmount, + redeemer: redeemer.address, + }) + ) + + await testReservationProofs.setReservation( + sampleReservationKey, + buildReservationRequest({ + owner: depositor.address, + mintedAmount: depositAmount, + acceptedAt: 1000, + walletPubKeyHash, + anchorAmount: depositAmount, + expiresAt: 1000 + termSeconds, + anchorTxHash: sampleAnchorTxHash, + anchorTxOutputIndex: 0, + state: 2, // ActionPending + requestNonce: redemptionNonce, + retryCredit: false, + dissolutionEligibleAt: 1000 + termSeconds + dissolutionDelay, + cumulativeReanchorFee: 0, + }) + ) + + // Seed the escrow this contract (acting as the Bridge) is holding + // for the redemption since its request, matching what + // `requestReservedRedemption` would have collected. + await testReservationProofs.fundBankBalance( + testReservationProofs.address, + depositAmount + ) + + const tx = await testReservationProofs.unwindPendingAction( + sampleReservationKey, + false + ) + + expect(await bank.balanceOf(redeemer.address)).to.equal(depositAmount) + expect(await bank.balanceOf(testReservationProofs.address)).to.equal(0) + + const action = await testReservationProofs.getReservationAction( + sampleReservationKey, + redemptionNonce + ) + expect(action.state).to.equal(5) // Superseded + + await expect(tx) + .to.emit(testReservationProofs, "ReservationActionSuperseded") + .withArgs(sampleReservationKey, redemptionNonce) + }) }) describe("9. Strand flow on late settlement against closed/closing wallet", () => {