diff --git a/solidity/contracts/bridge/Bridge.sol b/solidity/contracts/bridge/Bridge.sol index 028642ae34..31a7a4d901 100644 --- a/solidity/contracts/bridge/Bridge.sol +++ b/solidity/contracts/bridge/Bridge.sol @@ -27,6 +27,7 @@ import "./BridgeState.sol"; import "./Deposit.sol"; import "./DepositSweep.sol"; import "./Redemption.sol"; +import "./Reservation.sol"; import "./BitcoinTx.sol"; import "./EcdsaLib.sol"; import "./Wallets.sol"; @@ -67,6 +68,7 @@ contract Bridge is using Deposit for BridgeState.Storage; using DepositSweep for BridgeState.Storage; using Redemption for BridgeState.Storage; + using Reservation for BridgeState.Storage; using MovingFunds for BridgeState.Storage; using Wallets for BridgeState.Storage; using Fraud for BridgeState.Storage; @@ -106,6 +108,64 @@ contract Bridge is bytes redeemerOutputScript ); + event ReservationAccepted( + uint256 indexed reservationKey, + bytes20 indexed walletPubKeyHash, + address indexed owner, + bytes32 anchorTxHash, + uint64 anchorAmount, + uint32 expiresAt + ); + + event ReservationExtended( + uint256 indexed reservationKey, + uint32 newExpiresAt + ); + + event ReservedRedemptionRequested( + uint256 indexed reservationKey, + address indexed redeemer, + bytes redeemerOutputScript, + uint64 mintedAmount, + uint64 txMaxFee + ); + + event ReservedRedemptionCompleted( + uint256 indexed reservationKey, + bytes32 redemptionTxHash + ); + + event ReservedRedemptionTimedOut( + uint256 indexed reservationKey, + bytes20 indexed walletPubKeyHash + ); + + event ReservedRedemptionVetoed(uint256 indexed reservationKey); + + event ReservationReanchored( + uint256 indexed reservationKey, + bytes20 indexed newWalletPubKeyHash, + bytes32 newAnchorTxHash, + uint64 newAnchorAmount + ); + + event ReservationDissolved( + uint256 indexed reservationKey, + bytes20 indexed walletPubKeyHash, + bytes32 dissolutionTxHash + ); + + event ReservationParametersUpdated( + uint64 reservationMinAmount, + uint64 reservationTxMaxFee, + uint32 reservationTermSeconds, + uint32 reservationGracePeriod, + uint64 reservationMaxTotalAmount, + uint32 maxReservationsPerWallet + ); + + event ReservationVaultUpdated(address reservationVault); + event WalletMovingFunds( bytes32 indexed ecdsaWalletID, bytes20 indexed walletPubKeyHash @@ -766,6 +826,99 @@ contract Bridge is ); } + /// @notice Single entry point for all reservation lifecycle SPV proofs: + /// anchor acceptance, in-kind reserved redemption, re-anchoring + /// and dissolution. Consolidated into one external function to + /// preserve the Bridge's EIP-170 deployment size margin. See + /// `Reservation.submitReservationProof` and the individual + /// handlers in the `Reservation` library for detailed + /// requirements. + /// @param proofType The type of the submitted proof, see + /// `Reservation.ProofType`. + /// @param txInfo Bitcoin transaction data. + /// @param proof Bitcoin proof data. + /// @param mainUtxo Data of the wallet's main UTXO; only used for + /// `Dissolution` proofs and ignored otherwise. + /// @param reservationKey The key of the target reservation; ignored for + /// `Acceptance` proofs where the key is derived from the spent + /// deposit outpoint. + function submitReservationProof( + uint8 proofType, + BitcoinTx.Info calldata txInfo, + BitcoinTx.Proof calldata proof, + BitcoinTx.UTXO calldata mainUtxo, + uint256 reservationKey + ) external onlySpvMaintainer { + self.submitReservationProof( + proofType, + txInfo, + proof, + mainUtxo, + reservationKey + ); + } + + /// @notice Extends the custody term of a reservation by the current + /// reservation term length. Can only be called by the + /// reservation vault, which collects the custody fee for the + /// extension. See `Reservation.extendReservation`. + /// @param reservationKey The key of the reservation to extend. + function extendReservation(uint256 reservationKey) external { + // The caller is checked in the library function. + self.extendReservation(reservationKey); + } + + /// @notice Requests an in-kind redemption of a reservation: the wallet + /// is expected to spend exactly the reservation's current anchor + /// outpoint to the redeemer output script in a 1-input-1-output + /// transaction. Can only be called by the reservation vault, + /// which must have approved the Bridge in the Bank for the gross + /// minted amount. See `Reservation.requestReservedRedemption`. + /// @param reservationKey The key of the reservation to redeem. + /// @param redeemer The address able to claim the surrendered balance + /// back if the redemption times out. + /// @param redeemerOutputScript The redeemer's length-prefixed output + /// script (P2PKH, P2WPKH, P2SH or P2WSH). + function requestReservedRedemption( + uint256 reservationKey, + address redeemer, + bytes calldata redeemerOutputScript + ) external { + // The caller is checked in the library function. + self.requestReservedRedemption( + reservationKey, + redeemer, + redeemerOutputScript + ); + } + + /// @notice Notifies that a pending reserved redemption has timed out. + /// Returns the surrendered balance to the redeemer, slashes the + /// wallet operators like a regular redemption timeout, and + /// returns the reservation to the Active state. See + /// `Reservation.notifyReservedRedemptionTimeout`. + /// @param reservationKey The key of the reservation with the timed out + /// redemption. + /// @param walletMembersIDs Identifiers of the wallet signing group + /// members. + function notifyReservedRedemptionTimeout( + uint256 reservationKey, + uint32[] calldata walletMembersIDs + ) external { + self.notifyReservedRedemptionTimeout(reservationKey, walletMembersIDs); + } + + /// @notice Notifies that a pending reserved redemption was vetoed in the + /// redemption watchtower. Detains the surrendered balance to the + /// watchtower and returns the reservation to the Active state. + /// See `Reservation.notifyReservedRedemptionVeto`. + /// @param reservationKey The key of the reservation with the vetoed + /// redemption. + function notifyReservedRedemptionVeto(uint256 reservationKey) external { + // The caller is checked in the library function. + self.notifyReservedRedemptionVeto(reservationKey); + } + /// @notice Submits the moving funds target wallets commitment. /// Once all requirements are met, that function registers the /// target wallets commitment and opens the way for moving funds @@ -2080,4 +2233,85 @@ contract Bridge is // The caller is checked in the internal function. self.notifyRedemptionVeto(walletPubKeyHash, redeemerOutputScript); } + + /// @notice Updates parameters of reservations, including the + /// reservation vault address. Deposits revealed with the + /// reservation vault address are treated as UTXO reservations. + /// @param reservationVault Address of the reservation vault. Can only be + /// changed while there are no active reservations. + /// @param reservationMinAmount New value of the reservation minimum + /// amount in satoshis. It is the minimal anchor output amount + /// accepted for a reservation. + /// @param reservationTxMaxFee New value of the reservation transaction + /// max fee in satoshis. It is the maximum amount of BTC + /// transaction fee that can be incurred by a single reservation + /// lifecycle transaction. + /// @param reservationTermSeconds New value of the reservation custody + /// term length in seconds. + /// @param reservationGracePeriod New value of the reservation grace + /// period in seconds. + /// @param reservationMaxTotalAmount New cap on the total amount in + /// satoshi locked under active reservations. + /// @param maxReservationsPerWallet New cap on the number of active + /// reservations a single wallet can custody. + /// @dev Requirements: + /// - The caller must be the governance, + /// - See `Reservation.updateReservationParameters` for parameter + /// requirements. + function updateReservationParameters( + address reservationVault, + uint64 reservationMinAmount, + uint64 reservationTxMaxFee, + uint32 reservationTermSeconds, + uint32 reservationGracePeriod, + uint64 reservationMaxTotalAmount, + uint32 maxReservationsPerWallet + ) external onlyGovernance { + self.updateReservationParameters( + reservationVault, + reservationMinAmount, + reservationTxMaxFee, + reservationTermSeconds, + reservationGracePeriod, + reservationMaxTotalAmount, + maxReservationsPerWallet + ); + } + + /// @notice Collection of all reservations indexed by the deposit key of + /// the underlying reserved deposit, i.e. + /// `keccak256(fundingTxHash | fundingOutputIndex)`. + /// @param reservationKey The key of the reservation. + function reservations(uint256 reservationKey) + external + view + returns (Reservation.ReservationRequest memory) + { + return self.reservations[reservationKey]; + } + + /// @notice Returns the current values of Bridge reservation parameters. + function reservationParameters() + external + view + returns ( + address reservationVault, + uint64 reservationMinAmount, + uint64 reservationTxMaxFee, + uint32 reservationTermSeconds, + uint32 reservationGracePeriod, + uint64 reservationMaxTotalAmount, + uint64 reservationTotalAmount, + uint32 maxReservationsPerWallet + ) + { + reservationVault = self.reservationVault; + reservationMinAmount = self.reservationMinAmount; + reservationTxMaxFee = self.reservationTxMaxFee; + reservationTermSeconds = self.reservationTermSeconds; + reservationGracePeriod = self.reservationGracePeriod; + reservationMaxTotalAmount = self.reservationMaxTotalAmount; + reservationTotalAmount = self.reservationTotalAmount; + maxReservationsPerWallet = self.maxReservationsPerWallet; + } } diff --git a/solidity/contracts/bridge/BridgeGovernance.sol b/solidity/contracts/bridge/BridgeGovernance.sol index b1a6f206aa..d4a9593f80 100644 --- a/solidity/contracts/bridge/BridgeGovernance.sol +++ b/solidity/contracts/bridge/BridgeGovernance.sol @@ -32,6 +32,7 @@ contract BridgeGovernance is Ownable { using BridgeGovernanceParameters for BridgeGovernanceParameters.WalletData; using BridgeGovernanceParameters for BridgeGovernanceParameters.FraudData; using BridgeGovernanceParameters for BridgeGovernanceParameters.TreasuryData; + using BridgeGovernanceParameters for BridgeGovernanceParameters.ReservationData; BridgeGovernanceParameters.DepositData internal depositData; BridgeGovernanceParameters.RedemptionData internal redemptionData; @@ -39,6 +40,7 @@ contract BridgeGovernance is Ownable { BridgeGovernanceParameters.WalletData internal walletData; BridgeGovernanceParameters.FraudData internal fraudData; BridgeGovernanceParameters.TreasuryData internal treasuryData; + BridgeGovernanceParameters.ReservationData internal reservationData; Bridge internal bridge; @@ -288,6 +290,17 @@ contract BridgeGovernance is Ownable { event TreasuryUpdateStarted(address newTreasury, uint256 timestamp); event TreasuryUpdated(address treasury); + event ReservationParametersUpdateStarted( + address newReservationVault, + uint64 newReservationMinAmount, + uint64 newReservationTxMaxFee, + uint32 newReservationTermSeconds, + uint32 newReservationGracePeriod, + uint64 newReservationMaxTotalAmount, + uint32 newMaxReservationsPerWallet, + uint256 timestamp + ); + constructor(Bridge _bridge, uint256 _governanceDelay) { bridge = _bridge; governanceDelays[0] = _governanceDelay; @@ -1807,4 +1820,47 @@ contract BridgeGovernance is Ownable { function setRebateStaking(address rebateStaking) external onlyOwner { bridge.setRebateStaking(rebateStaking); } + + /// @notice Begins the reservation parameters update process. All + /// reservation parameters (including the reservation vault) are + /// staged together since they are applied atomically via a + /// single Bridge call. + /// @dev Can be called only by the contract owner. + function beginReservationParametersUpdate( + address _newReservationVault, + uint64 _newReservationMinAmount, + uint64 _newReservationTxMaxFee, + uint32 _newReservationTermSeconds, + uint32 _newReservationGracePeriod, + uint64 _newReservationMaxTotalAmount, + uint32 _newMaxReservationsPerWallet + ) external onlyOwner { + reservationData.beginReservationParametersUpdate( + _newReservationVault, + _newReservationMinAmount, + _newReservationTxMaxFee, + _newReservationTermSeconds, + _newReservationGracePeriod, + _newReservationMaxTotalAmount, + _newMaxReservationsPerWallet + ); + } + + /// @notice Finalizes the reservation parameters update process. + /// @dev Can be called only by the contract owner, after the governance + /// delay elapses. + function finalizeReservationParametersUpdate() external onlyOwner { + BridgeGovernanceParameters.ReservationData + memory staged = reservationData; + reservationData.finalizeReservationParametersUpdate(governanceDelay()); + bridge.updateReservationParameters( + staged.newReservationVault, + staged.newReservationMinAmount, + staged.newReservationTxMaxFee, + staged.newReservationTermSeconds, + staged.newReservationGracePeriod, + staged.newReservationMaxTotalAmount, + staged.newMaxReservationsPerWallet + ); + } } diff --git a/solidity/contracts/bridge/BridgeGovernanceParameters.sol b/solidity/contracts/bridge/BridgeGovernanceParameters.sol index dfbcf8c0f9..14589bafbf 100644 --- a/solidity/contracts/bridge/BridgeGovernanceParameters.sol +++ b/solidity/contracts/bridge/BridgeGovernanceParameters.sol @@ -1571,4 +1571,78 @@ library BridgeGovernanceParameters { self.newTreasury = address(0); self.treasuryChangeInitiated = 0; } + + struct ReservationData { + address newReservationVault; + uint64 newReservationMinAmount; + uint64 newReservationTxMaxFee; + uint32 newReservationTermSeconds; + uint32 newReservationGracePeriod; + uint64 newReservationMaxTotalAmount; + uint32 newMaxReservationsPerWallet; + uint256 reservationParametersChangeInitiated; + } + + event ReservationParametersUpdateStarted( + address newReservationVault, + uint64 newReservationMinAmount, + uint64 newReservationTxMaxFee, + uint32 newReservationTermSeconds, + uint32 newReservationGracePeriod, + uint64 newReservationMaxTotalAmount, + uint32 newMaxReservationsPerWallet, + uint256 timestamp + ); + + /// @notice Begins the reservation parameters update process. All + /// reservation parameters are staged together since they are + /// applied atomically via a single Bridge call. + function beginReservationParametersUpdate( + ReservationData storage self, + address _newReservationVault, + uint64 _newReservationMinAmount, + uint64 _newReservationTxMaxFee, + uint32 _newReservationTermSeconds, + uint32 _newReservationGracePeriod, + uint64 _newReservationMaxTotalAmount, + uint32 _newMaxReservationsPerWallet + ) external { + /* solhint-disable not-rely-on-time */ + self.newReservationVault = _newReservationVault; + self.newReservationMinAmount = _newReservationMinAmount; + self.newReservationTxMaxFee = _newReservationTxMaxFee; + self.newReservationTermSeconds = _newReservationTermSeconds; + self.newReservationGracePeriod = _newReservationGracePeriod; + self.newReservationMaxTotalAmount = _newReservationMaxTotalAmount; + self.newMaxReservationsPerWallet = _newMaxReservationsPerWallet; + self.reservationParametersChangeInitiated = block.timestamp; + emit ReservationParametersUpdateStarted( + _newReservationVault, + _newReservationMinAmount, + _newReservationTxMaxFee, + _newReservationTermSeconds, + _newReservationGracePeriod, + _newReservationMaxTotalAmount, + _newMaxReservationsPerWallet, + block.timestamp + ); + /* solhint-enable not-rely-on-time */ + } + + /// @notice Finalizes the reservation parameters update process. + /// @dev The staged values are read by the caller before this call; this + /// function only enforces the governance delay and clears the + /// staged change. + function finalizeReservationParametersUpdate( + ReservationData storage self, + uint256 governanceDelay + ) + external + onlyAfterGovernanceDelay( + self.reservationParametersChangeInitiated, + governanceDelay + ) + { + self.reservationParametersChangeInitiated = 0; + } } diff --git a/solidity/contracts/bridge/BridgeState.sol b/solidity/contracts/bridge/BridgeState.sol index 648e11069c..e9ecbf5217 100644 --- a/solidity/contracts/bridge/BridgeState.sol +++ b/solidity/contracts/bridge/BridgeState.sol @@ -21,6 +21,7 @@ import "@keep-network/random-beacon/contracts/ReimbursementPool.sol"; import "./IRelay.sol"; import "./Deposit.sol"; import "./Redemption.sol"; +import "./Reservation.sol"; import "./Fraud.sol"; import "./Wallets.sol"; import "./MovingFunds.sol"; @@ -325,6 +326,46 @@ library BridgeState { // governance wiring; changing it afterwards requires a dedicated // upgrade path of the Bridge implementation. address rebateStaking; + // The minimal anchor output amount in satoshi accepted for a + // reservation. Deposits revealed with `reservationVault` as their + // vault are treated as UTXO reservations: they are anchored by the + // wallet in a 1-input-1-output spend instead of being swept, and + // are redeemable in-kind. See the `Reservation` library for details. + uint64 reservationMinAmount; + // The custody term length in seconds applied to new and extended + // reservations. The term is a contract-layer fact only; anchor + // outputs carry no timelock. + uint32 reservationTermSeconds; + // Address of the reservation vault. Deposits revealed with this + // vault address are treated as UTXO reservations. + address reservationVault; + // Maximum amount of BTC transaction fee in satoshi that can be + // incurred by a single reservation lifecycle transaction (anchor, + // re-anchor, reserved redemption, dissolution). + uint64 reservationTxMaxFee; + // The grace period in seconds after a reservation's custody term + // expires during which the reservation can still be extended or + // redeemed but not yet dissolved. + uint32 reservationGracePeriod; + // Maximum total amount in satoshi that can be locked under active + // reservations at the same time. + uint64 reservationMaxTotalAmount; + // Current total amount in satoshi locked under active reservations + // (sum of current anchor output values). + uint64 reservationTotalAmount; + // Maximum number of active reservations a single wallet can custody. + uint32 maxReservationsPerWallet; + // Collection of all reservations indexed by the deposit key of the + // underlying reserved deposit, i.e. + // `keccak256(fundingTxHash | fundingOutputIndex)`. + mapping(uint256 => Reservation.ReservationRequest) reservations; + // Maps the UTXO key of a reservation's current anchor outpoint, + // built as `keccak256(anchorTxHash | anchorTxOutputIndex)`, to the + // reservation key. + mapping(uint256 => uint256) reservationsByAnchorUtxo; + // The number of active reservations custodied by the given wallet, + // identified by its 20-byte wallet public key hash. + mapping(bytes20 => uint32) walletReservationsCount; // Reserved storage space in case we need to add more variables. // The convention from OpenZeppelin suggests the storage space should // add up to 50 slots. Here we want to have more slots as there are @@ -332,7 +373,7 @@ library BridgeState { // the struct in the upcoming versions we need to reduce the array size. // See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps // slither-disable-next-line unused-state - uint256[48] __gap; + uint256[43] __gap; } event DepositParametersUpdated( diff --git a/solidity/contracts/bridge/DepositSweep.sol b/solidity/contracts/bridge/DepositSweep.sol index e51bd9ddd5..1ae6e72f3e 100644 --- a/solidity/contracts/bridge/DepositSweep.sol +++ b/solidity/contracts/bridge/DepositSweep.sol @@ -158,6 +158,17 @@ library DepositSweep { // `txProofDifficultyFactor` constant. bytes32 sweepTxHash = self.validateProof(sweepTx, sweepProof); + // Reserved deposits are anchored via the `Reservation` library + // instead of being swept. Since every deposit swept by a single + // transaction must share the `vault` parameter (enforced per input + // below), rejecting the reservation vault here rejects every + // reserved deposit. + require( + self.reservationVault == address(0) || + vault != self.reservationVault, + "Reserved deposits must not be swept" + ); + // Process sweep transaction output and extract its target wallet // public key hash and value. ( diff --git a/solidity/contracts/bridge/Redemption.sol b/solidity/contracts/bridge/Redemption.sol index b3111893ec..73960bc861 100644 --- a/solidity/contracts/bridge/Redemption.sol +++ b/solidity/contracts/bridge/Redemption.sol @@ -62,6 +62,15 @@ interface IRedemptionWatchtower { external view returns (uint32); + + /// @notice Returns the applicable veto delay for a pending reserved + /// redemption identified by the given reservation key. + /// @param reservationKey The key of the reservation. + /// @return Reserved redemption veto delay. + function getReservedRedemptionDelay(uint256 reservationKey) + external + view + returns (uint32); } /// @notice Aggregates functions common to the redemption transaction proof diff --git a/solidity/contracts/bridge/RedemptionWatchtower.sol b/solidity/contracts/bridge/RedemptionWatchtower.sol index dd42fbddb0..87ca7ebbd3 100644 --- a/solidity/contracts/bridge/RedemptionWatchtower.sol +++ b/solidity/contracts/bridge/RedemptionWatchtower.sol @@ -19,6 +19,7 @@ import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "./Bridge.sol"; import "./Redemption.sol"; +import "./Reservation.sol"; /// @title Redemption watchtower /// @notice This contract encapsulates the logic behind the redemption veto @@ -402,6 +403,125 @@ contract RedemptionWatchtower is OwnableUpgradeable { } } + /// @notice Raises an objection to a pending reserved redemption + /// identified by its reservation key. Works the same way as + /// `raiseObjection` does for regular redemption requests: each + /// pending reserved redemption has a delay period during which + /// the wallet is not allowed to process it and the guardians can + /// raise objections to; the third objection vetoes it. A veto + /// detains the surrendered gross amount from the Bridge, freezes + /// it for the freeze period, burns the penalty fee, and bans the + /// redeemer. The reservation itself returns to the Active state + /// on the Bridge -- the anchor outpoint was not spent, so the + /// in-kind claim survives (though a banned owner cannot + /// re-request until unbanned). + /// @param reservationKey The key of the reservation with the pending + /// reserved redemption. + /// @dev Requirements: + /// - The caller must be a redemption guardian, + /// - The reserved redemption must not have been vetoed already, + /// - The guardian must not have already objected to it, + /// - The reserved redemption must be pending, + /// - The reserved redemption must be within its veto delay period, + /// unless it was requested before the watchtower was enabled. + function raiseReservedObjection(uint256 reservationKey) + external + onlyGuardian + { + VetoProposal storage veto = vetoProposals[reservationKey]; + + require( + veto.objectionsCount < REQUIRED_OBJECTIONS_COUNT, + "Reserved redemption already vetoed" + ); + + uint256 objectionKey = uint256( + keccak256(abi.encodePacked(reservationKey, msg.sender)) + ); + require(!objections[objectionKey], "Guardian already objected"); + + Reservation.ReservationRequest memory reservation = bridge.reservations( + reservationKey + ); + + require( + reservation.state == + Reservation.ReservationState.RedemptionRequested, + "Reserved redemption does not exist" + ); + + if (reservation.redemptionRequestedAt >= watchtowerEnabledAt) { + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp < + reservation.redemptionRequestedAt + + _redemptionDelay( + veto.objectionsCount, + reservation.mintedAmount + ), + "Redemption veto delay period expired" + ); + } else { + emit VetoPeriodCheckOmitted(reservationKey); + } + + objections[objectionKey] = true; + veto.redeemer = reservation.redeemer; + veto.objectionsCount++; + + emit ObjectionRaised(reservationKey, msg.sender); + + if (veto.objectionsCount == REQUIRED_OBJECTIONS_COUNT) { + uint64 penaltyFee = vetoPenaltyFeeDivisor > 0 + ? reservation.mintedAmount / vetoPenaltyFeeDivisor + : 0; + + veto.withdrawableAmount = reservation.mintedAmount - penaltyFee; + /* solhint-disable-next-line not-rely-on-time */ + veto.finalizedAt = uint32(block.timestamp); + isBanned[reservation.redeemer] = true; + + emit Banned(reservation.redeemer); + + emit VetoFinalized(reservationKey); + + // Notify the Bridge about the veto. As result of this call, + // this contract receives the surrendered gross amount + // (as Bank's balance) from the Bridge. + bridge.notifyReservedRedemptionVeto(reservationKey); + // 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 a pending reserved + /// redemption identified by the given reservation key. + /// @param reservationKey The key of the reservation. + /// @return Reserved redemption veto delay. + /// @dev If the watchtower has been disabled, the delay is always zero. + function getReservedRedemptionDelay(uint256 reservationKey) + external + view + returns (uint32) + { + Reservation.ReservationRequest memory reservation = bridge.reservations( + reservationKey + ); + + require( + reservation.state == + Reservation.ReservationState.RedemptionRequested, + "Reserved redemption does not exist" + ); + + return + _redemptionDelay( + vetoProposals[reservationKey].objectionsCount, + reservation.mintedAmount + ); + } + /// @notice Returns the redemption delay for a given number of objections /// and requested amount. /// @param objectionsCount Number of objections. diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol new file mode 100644 index 0000000000..14d52142c6 --- /dev/null +++ b/solidity/contracts/bridge/Reservation.sol @@ -0,0 +1,1201 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ + +pragma solidity 0.8.17; + +import {BTCUtils} from "@keep-network/bitcoin-spv-sol/contracts/BTCUtils.sol"; +import {BytesLib} from "@keep-network/bitcoin-spv-sol/contracts/BytesLib.sol"; + +import "./BitcoinTx.sol"; +import "./BridgeState.sol"; +import "./Deposit.sol"; +import "./Redemption.sol"; +import "./Wallets.sol"; + +import "../bank/Bank.sol"; + +/// @title Bridge UTXO reservations +/// @notice The library handles the logic for reserving deposited UTXOs so +/// that a depositor's coins are custodied without ever being +/// commingled with the pooled supply and are returned in-kind -- +/// with an unbroken 1-input-1-output lineage -- upon redemption. +/// @dev A reserved deposit is a regular revealed deposit routed to the +/// designated reservation vault (`reveal.vault == reservationVault`). +/// Instead of being swept into the wallet's main UTXO, a reserved +/// deposit is *anchored*: the wallet performs a 1-input-1-output spend +/// of the deposit into a fresh wallet-controlled P2(W)PKH output that +/// carries no refund path. The Bank balance is credited only when the +/// SPV proof of that anchor transaction is submitted. The anchor thus +/// mirrors the sweep's refund-disabling role while dropping its +/// consolidating role -- balances are never credited against an output +/// the depositor could still claw back, and the coins never merge with +/// the pooled supply. +/// +/// The registry tracks the reservation's current anchor outpoint +/// through optional re-anchoring hops (wallet migration) until the +/// reservation is closed by an in-kind redemption, or -- once the +/// custody term and grace period elapse -- dissolved into the wallet's +/// main UTXO, at which point the owner's balance simply remains an +/// ordinary pooled claim. +/// +/// Claim accounting: the amount credited (`mintedAmount`) is the gross +/// anchor output value; the per-deposit treasury fee computed at reveal +/// time is deliberately not netted. All protocol fees are charged by +/// the reservation vault as explicit transfers. Bitcoin miner fees are +/// the only in-kind deductions: the anchor and any re-anchor fees +/// reduce the on-chain `anchorAmount`, while the reserved redemption +/// always burns the full `mintedAmount`, so supply and backing +/// reconcile exactly when the reservation closes via redemption. +/// +/// Fraud-defense integration: accepting a reservation marks the +/// underlying deposit as swept (making the deposit outpoint recognized +/// by `Fraud.defeatFraudChallenge`), and every proven consumption of an +/// anchor outpoint (redemption, re-anchor, dissolution) records it in +/// `spentMainUTXOs` -- the existing registry of honestly-spent, +/// wallet-controlled outpoints the fraud defeat path consults. +library Reservation { + using BridgeState for BridgeState.Storage; + using Wallets for BridgeState.Storage; + using BitcoinTx for BridgeState.Storage; + + using BTCUtils for bytes; + using BytesLib for bytes; + + /// @notice Represents the state of a reservation. + enum ReservationState { + /// @dev The reservation is unknown to the Bridge. + Unknown, + /// @dev The reservation was accepted (anchor proven, balance + /// credited) and its anchor outpoint is under wallet custody. + Active, + /// @dev The owner requested an in-kind redemption of the reserved + /// outpoint and surrendered the gross balance. The wallet is + /// expected to spend the anchor to the redeemer script. + RedemptionRequested, + /// @dev The reservation was closed, either by a proven in-kind + /// redemption or by dissolution into the wallet's main UTXO. + Closed + } + + /// @notice Represents a UTXO reservation. + struct ReservationRequest { + // The reservation owner holding the in-kind redemption right. Set to + // the deposit's depositor at acceptance time. + address owner; + // Gross amount in satoshi credited to the owner via the reservation + // vault at acceptance time. This is the amount that must be + // surrendered and burned when the reservation is redeemed in-kind. + uint64 mintedAmount; + // UNIX timestamp the reservation was accepted at. + // XXX: Unsigned 32-bit int unix seconds, will break February 7th 2106. + uint32 acceptedAt; + // 20-byte public key hash of the wallet custodying the current + // anchor outpoint. + bytes20 walletPubKeyHash; + // Value in satoshi of the current anchor outpoint. Starts equal to + // `mintedAmount` and decreases by the Bitcoin miner fee on each + // re-anchor hop. + uint64 anchorAmount; + // UNIX timestamp the custody term expires at. Purely a contract + // layer fact -- the anchor output carries no timelock. After + // `expiresAt + reservationGracePeriod` the wallet may dissolve the + // reservation into its main UTXO. + // XXX: Unsigned 32-bit int unix seconds, will break February 7th 2106. + uint32 expiresAt; + // Hash of the Bitcoin transaction holding the current anchor output. + bytes32 anchorTxHash; + // Output index of the current anchor output. Always 0 given anchor + // transactions have a single output; kept for auditability. + uint32 anchorTxOutputIndex; + // Current state of the reservation. + ReservationState state; + // UNIX timestamp the pending reserved redemption was requested at. + // Zero when no redemption is pending. + // XXX: Unsigned 32-bit int unix seconds, will break February 7th 2106. + uint32 redemptionRequestedAt; + // Transaction maximum BTC fee in satoshi snapshotted at redemption + // request time. + uint64 redemptionTxMaxFee; + // The address able to claim the surrendered balance back should the + // pending reserved redemption time out. + address redeemer; + // keccak256 hash of the length-prefixed redeemer output script the + // pending reserved redemption must pay to. + bytes32 redeemerOutputScriptHash; + // This struct doesn't contain `__gap` property as the structure is + // stored in a mapping, mappings store values in different slots and + // they are not contiguous with other values. + } + + event ReservationAccepted( + uint256 indexed reservationKey, + bytes20 indexed walletPubKeyHash, + address indexed owner, + bytes32 anchorTxHash, + uint64 anchorAmount, + uint32 expiresAt + ); + + event ReservationExtended( + uint256 indexed reservationKey, + uint32 newExpiresAt + ); + + event ReservedRedemptionRequested( + uint256 indexed reservationKey, + address indexed redeemer, + bytes redeemerOutputScript, + uint64 mintedAmount, + uint64 txMaxFee + ); + + event ReservedRedemptionCompleted( + uint256 indexed reservationKey, + bytes32 redemptionTxHash + ); + + event ReservedRedemptionTimedOut( + uint256 indexed reservationKey, + bytes20 indexed walletPubKeyHash + ); + + event ReservedRedemptionVetoed(uint256 indexed reservationKey); + + event ReservationReanchored( + uint256 indexed reservationKey, + bytes20 indexed newWalletPubKeyHash, + bytes32 newAnchorTxHash, + uint64 newAnchorAmount + ); + + event ReservationDissolved( + uint256 indexed reservationKey, + bytes20 indexed walletPubKeyHash, + bytes32 dissolutionTxHash + ); + + event ReservationParametersUpdated( + uint64 reservationMinAmount, + uint64 reservationTxMaxFee, + uint32 reservationTermSeconds, + uint32 reservationGracePeriod, + uint64 reservationMaxTotalAmount, + uint32 maxReservationsPerWallet + ); + + event ReservationVaultUpdated(address reservationVault); + + /// @notice Represents the type of a reservation lifecycle SPV proof. + enum ProofType { + /// @dev Proof of the anchor transaction accepting a reserved + /// deposit. `mainUtxo` and `reservationKey` parameters are + /// ignored; the reservation key is derived from the spent + /// deposit outpoint. + Acceptance, + /// @dev Proof of an in-kind reserved redemption transaction. + /// `mainUtxo` parameter is ignored. + Redemption, + /// @dev Proof of a re-anchor transaction moving the anchor outpoint + /// to another wallet. `mainUtxo` parameter is ignored. + Reanchor, + /// @dev Proof of a dissolution transaction merging an expired + /// reservation's anchor into the wallet's main UTXO. + Dissolution + } + + /// @notice Single entry point for all reservation lifecycle SPV proofs. + /// Dispatches to the appropriate handler based on `proofType`. + /// Consolidated into one external function to preserve the + /// Bridge contract's EIP-170 deployment size margin. + /// @param proofType The type of the submitted proof, see `ProofType`. + /// @param txInfo Bitcoin transaction data. + /// @param proof Bitcoin proof data. + /// @param mainUtxo Data of the wallet's main UTXO; only used for + /// `Dissolution` proofs and ignored otherwise. + /// @param reservationKey The key of the target reservation; ignored for + /// `Acceptance` proofs where the key is derived from the spent + /// deposit outpoint. + function submitReservationProof( + BridgeState.Storage storage self, + uint8 proofType, + BitcoinTx.Info calldata txInfo, + BitcoinTx.Proof calldata proof, + BitcoinTx.UTXO calldata mainUtxo, + uint256 reservationKey + ) external { + ProofType parsedProofType = ProofType(proofType); + + if (parsedProofType == ProofType.Acceptance) { + submitReservationAcceptanceProof(self, txInfo, proof); + } else if (parsedProofType == ProofType.Redemption) { + submitReservedRedemptionProof(self, txInfo, proof, reservationKey); + } else if (parsedProofType == ProofType.Reanchor) { + submitReservationReanchorProof(self, txInfo, proof, reservationKey); + } else { + submitReservationDissolutionProof( + self, + txInfo, + proof, + mainUtxo, + reservationKey + ); + } + } + + /// @notice Used by the wallet to prove the BTC anchor transaction of a + /// reserved deposit and to credit the owner's balance + /// accordingly. The anchor is only accepted if it satisfies SPV + /// proof. + /// + /// The anchor transaction must spend exactly the revealed + /// reserved deposit as its sole input and create exactly one + /// P2(W)PKH output controlled by a registered wallet. Proving it + /// marks the deposit as swept (blocking any regular sweep and + /// enabling fraud challenge defeats for the deposit outpoint), + /// registers the reservation and credits the gross anchor value + /// to the depositor through the reservation vault. + /// @param anchorTx Bitcoin anchor transaction data. + /// @param anchorProof Bitcoin anchor proof data. + /// @dev Requirements: + /// - The reservation vault must be set, + /// - `anchorTx` must have exactly one input pointing to a revealed, + /// unswept deposit routed to the reservation vault, + /// - `anchorTx` must have exactly one P2(W)PKH output locking funds + /// on a 20-byte public key hash of a Live or MovingFunds wallet, + /// - The anchor output value must respect the reservation minimum + /// amount, the per-transaction max fee, and the reservation caps. + function submitReservationAcceptanceProof( + BridgeState.Storage storage self, + BitcoinTx.Info calldata anchorTx, + BitcoinTx.Proof calldata anchorProof + ) internal { + require( + self.reservationVault != address(0), + "Reservations are disabled" + ); + + bytes32 anchorTxHash = self.validateProof(anchorTx, anchorProof); + + uint256 reservationKey = resolveAcceptedDeposit( + self, + anchorTx.inputVector + ); + + (bytes20 walletPubKeyHash, uint64 anchorAmount) = processAnchorOutput( + self, + anchorTx.outputVector + ); + + Deposit.DepositRequest storage deposit = self.deposits[reservationKey]; + + require( + anchorAmount >= self.reservationMinAmount, + "Reservation amount too small" + ); + // The difference between the deposit value and the anchor output + // value is the Bitcoin miner fee of the anchor transaction -- the + // only in-kind deduction the reservation lifecycle allows. + require( + deposit.amount - anchorAmount <= self.reservationTxMaxFee, + "Transaction fee is too high" + ); + + registerReservation( + self, + reservationKey, + deposit.depositor, + walletPubKeyHash, + anchorAmount, + anchorTxHash + ); + + // Credit the gross anchored amount through the reservation vault. + // The per-deposit treasury fee computed at reveal time is + // deliberately ignored: reservation claims are minted gross and all + // protocol fees are charged as explicit transfers by the vault. + address[] memory depositors = new address[](1); + depositors[0] = deposit.depositor; + uint256[] memory amounts = new uint256[](1); + amounts[0] = anchorAmount; + self.bank.increaseBalanceAndCall( + self.reservationVault, + depositors, + amounts + ); + } + + /// @notice Resolves the reserved deposit spent by the anchor transaction + /// input vector, validates it and marks it as swept. Marking the + /// deposit as swept blocks any future regular sweep, prevents + /// double acceptance, and makes the deposit outpoint recognized + /// as correctly spent by the fraud challenge defeat path. + /// @return reservationKey The deposit key of the reserved deposit, used + /// as the reservation key. + function resolveAcceptedDeposit( + BridgeState.Storage storage self, + bytes memory inputVector + ) internal returns (uint256 reservationKey) { + (bytes32 outpointTxHash, uint32 outpointIndex) = OutboundTx + .parseWalletOutboundTxInput(inputVector); + + reservationKey = uint256( + keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) + ); + + Deposit.DepositRequest storage deposit = self.deposits[reservationKey]; + require(deposit.revealedAt != 0, "Deposit not revealed"); + require(deposit.sweptAt == 0, "Deposit already swept"); + require( + deposit.vault == self.reservationVault, + "Deposit not routed to the reservation vault" + ); + + /* solhint-disable-next-line not-rely-on-time */ + deposit.sweptAt = uint32(block.timestamp); + } + + /// @notice Parses the anchor transaction single output and validates it + /// is controlled by a Live or MovingFunds wallet. + function processAnchorOutput( + BridgeState.Storage storage self, + bytes memory outputVector + ) internal view returns (bytes20 walletPubKeyHash, uint64 anchorAmount) { + bytes memory anchorOutput = parseSingleOutput(outputVector); + walletPubKeyHash = self.extractPubKeyHash(anchorOutput); + anchorAmount = anchorOutput.extractValue(); + + Wallets.WalletState walletState = self + .registeredWallets[walletPubKeyHash] + .state; + require( + walletState == Wallets.WalletState.Live || + walletState == Wallets.WalletState.MovingFunds, + "Anchor wallet must be in Live or MovingFunds state" + ); + } + + /// @notice Registers a new reservation: checks and adjusts the caps, + /// stores the reservation record and indexes the anchor + /// outpoint. + function registerReservation( + BridgeState.Storage storage self, + uint256 reservationKey, + address owner, + bytes20 walletPubKeyHash, + uint64 anchorAmount, + bytes32 anchorTxHash + ) internal { + uint64 newTotal = self.reservationTotalAmount + anchorAmount; + require( + newTotal <= self.reservationMaxTotalAmount, + "Total reserved amount cap exceeded" + ); + self.reservationTotalAmount = newTotal; + + uint32 walletCount = self.walletReservationsCount[walletPubKeyHash] + 1; + require( + walletCount <= self.maxReservationsPerWallet, + "Wallet reservations cap exceeded" + ); + self.walletReservationsCount[walletPubKeyHash] = walletCount; + + /* solhint-disable-next-line not-rely-on-time */ + uint32 expiresAt = uint32(block.timestamp) + + self.reservationTermSeconds; + + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + reservation.owner = owner; + reservation.mintedAmount = anchorAmount; + /* solhint-disable-next-line not-rely-on-time */ + reservation.acceptedAt = uint32(block.timestamp); + reservation.walletPubKeyHash = walletPubKeyHash; + reservation.anchorAmount = anchorAmount; + reservation.expiresAt = expiresAt; + reservation.anchorTxHash = anchorTxHash; + reservation.anchorTxOutputIndex = 0; + reservation.state = ReservationState.Active; + + self.reservationsByAnchorUtxo[ + uint256(keccak256(abi.encodePacked(anchorTxHash, uint32(0)))) + ] = reservationKey; + + // slither-disable-next-line reentrancy-events + emit ReservationAccepted( + reservationKey, + walletPubKeyHash, + owner, + anchorTxHash, + anchorAmount, + expiresAt + ); + } + + /// @notice Extends the custody term of a reservation by the current + /// reservation term length. The custody fee for the extension is + /// collected by the reservation vault before this call. + /// @param reservationKey The key of the reservation to extend. + /// @dev Requirements: + /// - The caller must be the reservation vault, + /// - The reservation must be in the Active or RedemptionRequested + /// state, + /// - The reservation must not be past its grace period. + function extendReservation( + BridgeState.Storage storage self, + uint256 reservationKey + ) external { + require( + msg.sender == self.reservationVault, + "Caller is not the reservation vault" + ); + + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == ReservationState.Active || + reservation.state == ReservationState.RedemptionRequested, + "Reservation is not active" + ); + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp <= + uint256(reservation.expiresAt) + self.reservationGracePeriod, + "Reservation past grace period" + ); + + uint32 base = reservation.expiresAt; + /* solhint-disable-next-line not-rely-on-time */ + if (base < block.timestamp) { + /* solhint-disable-next-line not-rely-on-time */ + base = uint32(block.timestamp); + } + reservation.expiresAt = base + self.reservationTermSeconds; + + emit ReservationExtended(reservationKey, reservation.expiresAt); + } + + /// @notice Requests an in-kind redemption of a reservation: the wallet + /// is expected to spend exactly the reservation's anchor + /// outpoint to the redeemer output script in a 1-input-1-output + /// transaction. The gross minted amount is taken from the + /// reservation vault's Bank balance and held by the Bridge until + /// the redemption is proven (burned) or times out (returned to + /// the redeemer). + /// @param reservationKey The key of the reservation to redeem. + /// @param redeemer The address able to claim the surrendered balance + /// back if the redemption times out. + /// @param redeemerOutputScript The redeemer's length-prefixed output + /// script (P2PKH, P2WPKH, P2SH or P2WSH) that will be used to + /// lock the redeemed BTC. + /// @dev Requirements: + /// - The caller must be the reservation vault, which must have + /// approved the Bridge in the Bank for the gross minted amount, + /// - The reservation must be in the Active state, + /// - If the redemption watchtower is set, the request must be + /// considered safe by the watchtower, + /// - `redeemerOutputScript` must be a standard type and must not + /// pay to the custodying wallet's public key hash. + function requestReservedRedemption( + BridgeState.Storage storage self, + uint256 reservationKey, + address redeemer, + bytes calldata redeemerOutputScript + ) 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" + ); + + if (self.redemptionWatchtower != address(0)) { + require( + IRedemptionWatchtower(self.redemptionWatchtower) + .isSafeRedemption( + reservation.walletPubKeyHash, + redeemerOutputScript, + self.reservationVault, + 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.RedemptionRequested; + reservation.redeemer = redeemer; + reservation.redeemerOutputScriptHash = keccak256( + redeemerOutputScriptMem + ); + /* solhint-disable-next-line not-rely-on-time */ + reservation.redemptionRequestedAt = uint32(block.timestamp); + reservation.redemptionTxMaxFee = self.reservationTxMaxFee; + + // slither-disable-next-line reentrancy-events + emit ReservedRedemptionRequested( + reservationKey, + redeemer, + redeemerOutputScript, + reservation.mintedAmount, + reservation.redemptionTxMaxFee + ); + + self.bank.transferBalanceFrom( + msg.sender, + address(this), + reservation.mintedAmount + ); + } + + /// @notice Used by the wallet to prove the BTC reserved redemption + /// transaction and close the reservation. The transaction must + /// spend exactly the reservation's anchor outpoint as its sole + /// input and pay the requested redeemer output script as its + /// sole output. The full gross minted amount is burned: the + /// difference between the burned amount and the BTC paid out is + /// the Bitcoin miner fee (plus any re-anchor fees accrued + /// in-kind during custody), so supply and backing reconcile + /// exactly. + /// @param redemptionTx Bitcoin reserved redemption transaction data. + /// @param redemptionProof Bitcoin reserved redemption proof data. + /// @param reservationKey The key of the reservation being redeemed. + /// @dev Requirements: + /// - The reservation must have a pending reserved redemption, + /// - `redemptionTx` must spend the reservation's current anchor + /// outpoint as its sole input, + /// - `redemptionTx` must have a single output paying the requested + /// redeemer output script with a value within the acceptable + /// range. + function submitReservedRedemptionProof( + BridgeState.Storage storage self, + BitcoinTx.Info calldata redemptionTx, + BitcoinTx.Proof calldata redemptionProof, + uint256 reservationKey + ) internal { + bytes32 redemptionTxHash = self.validateProof( + redemptionTx, + redemptionProof + ); + + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == ReservationState.RedemptionRequested, + "No pending reserved redemption" + ); + + consumeAnchor(self, reservation, redemptionTx.inputVector); + + bytes memory output = parseSingleOutput(redemptionTx.outputVector); + uint64 outputValue = output.extractValue(); + + { + bytes memory outputScript = output.slice(8, output.length - 8); + require( + keccak256(outputScript) == reservation.redeemerOutputScriptHash, + "Output does not pay the requested redeemer script" + ); + } + + // Underflow-safe range check. `redemptionTxMaxFee` is a governable + // parameter, not a value derived from the proven transaction, so it + // may exceed `anchorAmount` after re-anchor hops shrink the anchor + // or after a governance fee increase. The equivalent formulation + // below avoids the `anchorAmount - redemptionTxMaxFee` subtraction + // that would otherwise revert and permanently strand the + // reservation. `outputValue <= anchorAmount` is guaranteed by + // Bitcoin consensus (an output cannot exceed its input) but is + // asserted defensively. + require( + outputValue <= reservation.anchorAmount && + reservation.anchorAmount - outputValue <= + reservation.redemptionTxMaxFee, + "Output value is not within the acceptable range" + ); + + closeReservation(self, reservation); + + // slither-disable-next-line reentrancy-events + emit ReservedRedemptionCompleted(reservationKey, redemptionTxHash); + + // Burn the gross minted amount held by the Bridge since the + // redemption request. + self.bank.decreaseBalance(reservation.mintedAmount); + } + + /// @notice Notifies that a pending reserved redemption has timed out. + /// The surrendered balance is returned to the redeemer, the + /// wallet operators are slashed the same way as for a regular + /// redemption timeout, and the reservation returns to the + /// Active state -- the anchor outpoint was not spent, so the + /// in-kind claim survives and the redemption can be re-requested. + /// @param reservationKey The key of the reservation with the timed out + /// redemption. + /// @param walletMembersIDs Identifiers of the wallet signing group + /// members. + /// @dev Requirements: + /// - The reservation must have a pending reserved redemption, + /// - The amount of time defined by `redemptionTimeout` must have + /// passed since the reserved redemption was requested. + function notifyReservedRedemptionTimeout( + BridgeState.Storage storage self, + uint256 reservationKey, + uint32[] calldata walletMembersIDs + ) external { + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == ReservationState.RedemptionRequested, + "No pending reserved redemption" + ); + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp > + reservation.redemptionRequestedAt + self.redemptionTimeout, + "Redemption request has not timed out" + ); + + address redeemer = reservation.redeemer; + uint64 refundAmount = reservation.mintedAmount; + bytes20 walletPubKeyHash = reservation.walletPubKeyHash; + + reservation.state = ReservationState.Active; + reservation.redeemer = address(0); + reservation.redeemerOutputScriptHash = bytes32(0); + reservation.redemptionRequestedAt = 0; + reservation.redemptionTxMaxFee = 0; + + // Propagate timeout consequences to the wallet: slashing and state + // transition follow exactly the regular redemption timeout rules. + self.notifyWalletRedemptionTimeout(walletPubKeyHash, walletMembersIDs); + + // slither-disable-next-line reentrancy-events + emit ReservedRedemptionTimedOut(reservationKey, walletPubKeyHash); + + // Return the surrendered balance to the redeemer as Bank balance. + self.bank.transferBalance(redeemer, refundAmount); + } + + /// @notice Notifies that a pending reserved redemption was vetoed in the + /// redemption watchtower. Mirrors + /// `Redemption.notifyRedemptionVeto`: the surrendered balance is + /// detained and passed to the watchtower (as Bank balance) for + /// further processing, the pending request is cleared, and the + /// reservation returns to the Active state -- the anchor + /// outpoint was not spent, so the in-kind claim survives. + /// @param reservationKey The key of the reservation with the vetoed + /// redemption. + /// @dev Requirements: + /// - The caller must be the redemption watchtower, + /// - The reservation must have a pending reserved redemption. + function notifyReservedRedemptionVeto( + BridgeState.Storage storage self, + uint256 reservationKey + ) external { + require( + msg.sender == self.redemptionWatchtower, + "Caller is not the redemption watchtower" + ); + + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == ReservationState.RedemptionRequested, + "No pending reserved redemption" + ); + + uint64 detainedAmount = reservation.mintedAmount; + + reservation.state = ReservationState.Active; + reservation.redeemer = address(0); + reservation.redeemerOutputScriptHash = bytes32(0); + reservation.redemptionRequestedAt = 0; + reservation.redemptionTxMaxFee = 0; + + // slither-disable-next-line reentrancy-events + emit ReservedRedemptionVetoed(reservationKey); + + self.bank.transferBalance(self.redemptionWatchtower, detainedAmount); + } + + /// @notice Used by the wallet to prove a re-anchor transaction moving a + /// reservation's anchor outpoint to another (or a fresh output + /// of the same) wallet in a 1-input-1-output spend. Used during + /// wallet migration so reservations never pin retiring wallets. + /// @param reanchorTx Bitcoin re-anchor transaction data. + /// @param reanchorProof Bitcoin re-anchor proof data. + /// @param reservationKey The key of the reservation being re-anchored. + /// @dev Requirements: + /// - The reservation must be in the Active state (a pending + /// reserved redemption blocks re-anchoring), + /// - `reanchorTx` must spend the current anchor outpoint as its + /// sole input and create a single P2(W)PKH output controlled by + /// a Live wallet, + /// - The value difference (Bitcoin miner fee) must not exceed the + /// reservation transaction max fee. + /// + /// The source wallet state is deliberately not restricted: the + /// ability to produce the transaction is enforced by Bitcoin (only + /// the custodying wallet can sign the anchor spend) and moving + /// reservations out must remain possible for wallets in any + /// lifecycle state. + function submitReservationReanchorProof( + BridgeState.Storage storage self, + BitcoinTx.Info calldata reanchorTx, + BitcoinTx.Proof calldata reanchorProof, + uint256 reservationKey + ) internal { + bytes32 reanchorTxHash = self.validateProof(reanchorTx, reanchorProof); + + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == ReservationState.Active, + "Reservation is not active" + ); + + consumeAnchor(self, reservation, reanchorTx.inputVector); + + bytes memory output = parseSingleOutput(reanchorTx.outputVector); + bytes20 newWalletPubKeyHash = self.extractPubKeyHash(output); + uint64 newAnchorAmount = output.extractValue(); + + require( + self.registeredWallets[newWalletPubKeyHash].state == + Wallets.WalletState.Live, + "Target wallet must be in Live state" + ); + + // `newAnchorAmount <= anchorAmount` is guaranteed by Bitcoin + // consensus (the re-anchor output cannot exceed its input). + require( + reservation.anchorAmount - newAnchorAmount <= + self.reservationTxMaxFee, + "Transaction fee is too high" + ); + + // Dust floor: the re-anchored amount must stay strictly above the + // per-transaction fee bound, keeping the anchor clear of dust and + // preserving positive redemption value. This is deliberately a dust + // floor rather than `reservationMinAmount`: a minimum-sized + // reservation must remain migratable (a `reservationMinAmount` floor + // combined with the proposal validator's positive-fee requirement + // would leave no compliant re-anchor for an exactly-minimum anchor, + // pinning a retiring wallet). Bounding cumulative Byzantine + // re-anchor grinding is deferred to the authorized-action model (an + // explicit migration request with nonce, owner/target authorization, + // and a cumulative fee budget), tracked as a follow-up. + require( + newAnchorAmount > self.reservationTxMaxFee, + "Re-anchor amount below the dust floor" + ); + + bytes20 oldWalletPubKeyHash = reservation.walletPubKeyHash; + if (oldWalletPubKeyHash != newWalletPubKeyHash) { + self.walletReservationsCount[oldWalletPubKeyHash] -= 1; + uint32 newCount = self.walletReservationsCount[ + newWalletPubKeyHash + ] + 1; + require( + newCount <= self.maxReservationsPerWallet, + "Wallet reservations cap exceeded" + ); + self.walletReservationsCount[newWalletPubKeyHash] = newCount; + } + + // The miner fee reduces the on-chain earmarked amount. The gross + // claim (`mintedAmount`) is unchanged; the accumulated in-kind fees + // are settled when the reservation is redeemed (full gross burn). + self.reservationTotalAmount -= (reservation.anchorAmount - + newAnchorAmount); + + reservation.walletPubKeyHash = newWalletPubKeyHash; + reservation.anchorAmount = newAnchorAmount; + reservation.anchorTxHash = reanchorTxHash; + reservation.anchorTxOutputIndex = 0; + + self.reservationsByAnchorUtxo[ + uint256(keccak256(abi.encodePacked(reanchorTxHash, uint32(0)))) + ] = reservationKey; + + emit ReservationReanchored( + reservationKey, + newWalletPubKeyHash, + reanchorTxHash, + newAnchorAmount + ); + } + + /// @notice Used by the wallet to prove a dissolution transaction merging + /// an expired reservation's anchor outpoint into the wallet's + /// main UTXO. After dissolution the owner's minted balance + /// simply remains an ordinary pooled claim; no balances are + /// moved or burned. + /// @param dissolutionTx Bitcoin dissolution transaction data. + /// @param dissolutionProof Bitcoin dissolution proof data. + /// @param mainUtxo Data of the wallet's main UTXO, as currently known on + /// the Ethereum chain. Ignored if the wallet has no main UTXO. + /// @param reservationKey The key of the reservation being dissolved. + /// @dev Requirements: + /// - The reservation must be in the Active state (a pending + /// reserved redemption always wins over dissolution), + /// - The custody term plus grace period must have elapsed, + /// - The custodying wallet must be in Live or MovingFunds state, + /// - If the wallet has a main UTXO, `dissolutionTx` must spend + /// exactly the anchor outpoint (first input) and that main UTXO + /// (second input); otherwise it must spend exactly the anchor + /// outpoint, + /// - `dissolutionTx` must have a single P2(W)PKH output locking + /// funds back on the custodying wallet's public key hash; that + /// output becomes the wallet's new main UTXO. + /// + /// Note the Bridge cannot verify *when* the dissolution transaction + /// was signed. A wallet signing it before the grace period elapses + /// cannot prove it here (this function reverts), so such a spend is + /// an undefeatable fraud challenge target until the grace period + /// passes -- premature dissolution is deterred economically by the + /// fraud slashing machinery. + function submitReservationDissolutionProof( + BridgeState.Storage storage self, + BitcoinTx.Info calldata dissolutionTx, + BitcoinTx.Proof calldata dissolutionProof, + BitcoinTx.UTXO calldata mainUtxo, + uint256 reservationKey + ) internal { + bytes32 dissolutionTxHash = self.validateProof( + dissolutionTx, + dissolutionProof + ); + + ReservationRequest storage reservation = self.reservations[ + reservationKey + ]; + require( + reservation.state == ReservationState.Active, + "Reservation is not active" + ); + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp > + uint256(reservation.expiresAt) + self.reservationGracePeriod, + "Reservation term or grace period not elapsed" + ); + + Wallets.Wallet storage wallet = self.registeredWallets[ + reservation.walletPubKeyHash + ]; + { + Wallets.WalletState walletState = wallet.state; + require( + walletState == Wallets.WalletState.Live || + walletState == Wallets.WalletState.MovingFunds, + "Wallet must be in Live or MovingFunds state" + ); + } + + uint64 inputsTotalValue = processDissolutionInputs( + self, + reservation, + dissolutionTx.inputVector, + wallet.mainUtxoHash, + mainUtxo + ); + + bytes memory output = parseSingleOutput(dissolutionTx.outputVector); + uint64 outputValue = output.extractValue(); + require( + self.extractPubKeyHash(output) == reservation.walletPubKeyHash, + "Dissolution output must pay to the custodying wallet" + ); + require( + inputsTotalValue - outputValue <= self.reservationTxMaxFee, + "Transaction fee is too high" + ); + + // The dissolution output becomes the wallet's new main UTXO: the + // reserved backing rejoins the pooled supply. + wallet.mainUtxoHash = keccak256( + abi.encodePacked(dissolutionTxHash, uint32(0), outputValue) + ); + + closeReservation(self, reservation); + + emit ReservationDissolved( + reservationKey, + reservation.walletPubKeyHash, + dissolutionTxHash + ); + } + + /// @notice Updates the reservation parameters, including the + /// reservation vault address. Deposits revealed with the + /// reservation vault address are treated as reserved deposits. + /// @dev Requirements: + /// - Reservation transaction max fee must be greater than zero, + /// - Reservation minimum amount must be greater than the + /// reservation transaction max fee, + /// - Reservation term must be greater than zero, + /// - The reservation vault can only be changed while there are no + /// active reservations (total reserved amount is zero). + function updateReservationParameters( + BridgeState.Storage storage self, + address reservationVault, + uint64 reservationMinAmount, + uint64 reservationTxMaxFee, + uint32 reservationTermSeconds, + uint32 reservationGracePeriod, + uint64 reservationMaxTotalAmount, + uint32 maxReservationsPerWallet + ) external { + require( + reservationTxMaxFee > 0, + "Reservation transaction max fee must be greater than zero" + ); + require( + reservationMinAmount > reservationTxMaxFee, + "Reservation minimum amount must be greater than the reservation TX max fee" + ); + require( + reservationTermSeconds > 0, + "Reservation term must be greater than zero" + ); + + if (reservationVault != self.reservationVault) { + require( + self.reservationTotalAmount == 0, + "Active reservations exist" + ); + self.reservationVault = reservationVault; + emit ReservationVaultUpdated(reservationVault); + } + + self.reservationMinAmount = reservationMinAmount; + self.reservationTxMaxFee = reservationTxMaxFee; + self.reservationTermSeconds = reservationTermSeconds; + self.reservationGracePeriod = reservationGracePeriod; + self.reservationMaxTotalAmount = reservationMaxTotalAmount; + self.maxReservationsPerWallet = maxReservationsPerWallet; + + emit ReservationParametersUpdated( + reservationMinAmount, + reservationTxMaxFee, + reservationTermSeconds, + reservationGracePeriod, + reservationMaxTotalAmount, + maxReservationsPerWallet + ); + } + + /// @notice Parses the given output vector and returns its single output. + /// Reverts if the vector does not contain exactly one output. + function parseSingleOutput(bytes memory outputVector) + internal + pure + returns (bytes memory output) + { + (, uint256 outputsCount) = outputVector.parseVarInt(); + require( + outputsCount == 1, + "Reservation transaction must have a single output" + ); + + output = outputVector.extractOutputAtIndex(0); + } + + /// @notice Asserts the given input vector contains exactly one input + /// pointing to the reservation's current anchor outpoint, marks + /// that outpoint as correctly spent (making it recognized by the + /// fraud challenge defeat path) and clears its anchor index + /// entry. + function consumeAnchor( + BridgeState.Storage storage self, + ReservationRequest storage reservation, + bytes memory inputVector + ) internal { + (bytes32 outpointTxHash, uint32 outpointIndex) = OutboundTx + .parseWalletOutboundTxInput(inputVector); + + require( + reservation.anchorTxHash == outpointTxHash && + reservation.anchorTxOutputIndex == outpointIndex, + "Transaction input must point to the reservation anchor" + ); + + uint256 anchorUtxoKey = uint256( + keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) + ); + + // Anchor outpoints are wallet-controlled UTXOs. Marking a consumed + // anchor in `spentMainUTXOs` -- the existing registry of honestly + // spent wallet UTXOs -- makes the spend recognized by + // `Fraud.defeatFraudChallenge` without modifying the fraud library. + self.spentMainUTXOs[anchorUtxoKey] = true; + delete self.reservationsByAnchorUtxo[anchorUtxoKey]; + } + + /// @notice Processes the dissolution transaction inputs: the first + /// input must spend the reservation's anchor outpoint and, if + /// the wallet has a main UTXO, the second input must spend + /// exactly that main UTXO. Marks both consumed outpoints as + /// correctly spent. + /// @return inputsTotalValue Sum of all inputs values. + function processDissolutionInputs( + BridgeState.Storage storage self, + ReservationRequest storage reservation, + bytes memory inputVector, + bytes32 mainUtxoHash, + BitcoinTx.UTXO calldata mainUtxo + ) internal returns (uint64 inputsTotalValue) { + bool mainUtxoExpected = mainUtxoHash != bytes32(0); + + (uint256 varIntLength, uint256 inputsCount) = inputVector.parseVarInt(); + require( + inputsCount == (mainUtxoExpected ? 2 : 1), + "Wrong number of dissolution transaction inputs" + ); + + // The first input must spend the reservation's anchor outpoint. + uint256 nextInputIndex = consumeAnchorInputAt( + self, + reservation, + inputVector, + 1 + varIntLength + ); + inputsTotalValue = reservation.anchorAmount; + + if (mainUtxoExpected) { + require( + keccak256( + abi.encodePacked( + mainUtxo.txHash, + mainUtxo.txOutputIndex, + mainUtxo.txOutputValue + ) + ) == mainUtxoHash, + "Invalid main UTXO data" + ); + + consumeMainUtxoInputAt(self, inputVector, nextInputIndex, mainUtxo); + inputsTotalValue += mainUtxo.txOutputValue; + } + + return inputsTotalValue; + } + + /// @notice Asserts the input at the given starting index spends the + /// reservation's current anchor outpoint, marks that outpoint as + /// correctly spent and clears its anchor index entry. + /// @return nextInputIndex Starting index of the next input. + function consumeAnchorInputAt( + BridgeState.Storage storage self, + ReservationRequest storage reservation, + bytes memory inputVector, + uint256 inputStartingIndex + ) internal returns (uint256 nextInputIndex) { + bytes32 outpointTxHash = inputVector.extractInputTxIdLeAt( + inputStartingIndex + ); + uint32 outpointIndex = BTCUtils.reverseUint32( + uint32(inputVector.extractTxIndexLeAt(inputStartingIndex)) + ); + + require( + reservation.anchorTxHash == outpointTxHash && + reservation.anchorTxOutputIndex == outpointIndex, + "Transaction input must point to the reservation anchor" + ); + + uint256 anchorUtxoKey = uint256( + keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) + ); + // See `consumeAnchor` for the `spentMainUTXOs` rationale. + self.spentMainUTXOs[anchorUtxoKey] = true; + delete self.reservationsByAnchorUtxo[anchorUtxoKey]; + + nextInputIndex = + inputStartingIndex + + inputVector.determineInputLengthAt(inputStartingIndex); + } + + /// @notice Asserts the input at the given starting index spends the + /// wallet's main UTXO and marks it as correctly spent. + function consumeMainUtxoInputAt( + BridgeState.Storage storage self, + bytes memory inputVector, + uint256 inputStartingIndex, + BitcoinTx.UTXO calldata mainUtxo + ) internal { + bytes32 outpointTxHash = inputVector.extractInputTxIdLeAt( + inputStartingIndex + ); + uint32 outpointIndex = BTCUtils.reverseUint32( + uint32(inputVector.extractTxIndexLeAt(inputStartingIndex)) + ); + + require( + mainUtxo.txHash == outpointTxHash && + mainUtxo.txOutputIndex == outpointIndex, + "Transaction input must point to the wallet's main UTXO" + ); + + self.spentMainUTXOs[ + uint256(keccak256(abi.encodePacked(outpointTxHash, outpointIndex))) + ] = true; + } + + /// @notice Closes a reservation: adjusts the wallet reservation count + /// and the total reserved amount, and marks the reservation as + /// Closed. + function closeReservation( + BridgeState.Storage storage self, + ReservationRequest storage reservation + ) internal { + self.walletReservationsCount[reservation.walletPubKeyHash] -= 1; + self.reservationTotalAmount -= reservation.anchorAmount; + reservation.state = ReservationState.Closed; + } +} diff --git a/solidity/contracts/bridge/WalletProposalValidator.sol b/solidity/contracts/bridge/WalletProposalValidator.sol index 0cd2713bce..d2da5818fe 100644 --- a/solidity/contracts/bridge/WalletProposalValidator.sol +++ b/solidity/contracts/bridge/WalletProposalValidator.sol @@ -22,6 +22,7 @@ import "./BitcoinTx.sol"; import "./Bridge.sol"; import "./Deposit.sol"; import "./Redemption.sol"; +import "./Reservation.sol"; import "./MovingFunds.sol"; import "./Wallets.sol"; @@ -268,6 +269,9 @@ contract WalletProposalValidator { address proposalVault = address(0); + (address reservationVault, , , , , , , ) = bridge + .reservationParameters(); + uint256[] memory processedDepositKeys = new uint256[]( proposal.depositsKeys.length ); @@ -300,6 +304,14 @@ contract WalletProposalValidator { require(depositRequest.sweptAt == 0, "Deposit already swept"); + // Deposits routed to the reservation vault are anchored via the + // reservation flow and must never be swept. + require( + reservationVault == address(0) || + depositRequest.vault != reservationVault, + "Reserved deposits must not be swept" + ); + validateDepositExtraInfo( depositKey, depositRequest.depositor, @@ -897,4 +909,346 @@ contract WalletProposalValidator { return true; } + + /// @notice Helper structure representing a reservation anchor proposal. + struct ReservationAnchorProposal { + // 20-byte public key hash of the wallet performing the anchor. + bytes20 walletPubKeyHash; + // Key of the reserved deposit to anchor. + DepositKey depositKey; + // Proposed BTC fee for the anchor transaction. + uint256 anchorTxFee; + } + + /// @notice Helper structure representing a reserved redemption proposal. + struct ReservedRedemptionProposal { + // 20-byte public key hash of the wallet custodying the reservation. + bytes20 walletPubKeyHash; + // Key of the reservation with the pending reserved redemption. + uint256 reservationKey; + // Proposed BTC fee for the reserved redemption transaction. + uint256 redemptionTxFee; + } + + /// @notice Helper structure representing a reservation re-anchor + /// proposal. + struct ReservationReanchorProposal { + // 20-byte public key hash of the wallet custodying the reservation. + bytes20 sourceWalletPubKeyHash; + // Key of the reservation to re-anchor. + uint256 reservationKey; + // 20-byte public key hash of the wallet receiving the anchor. + bytes20 targetWalletPubKeyHash; + // Proposed BTC fee for the re-anchor transaction. + uint256 reanchorTxFee; + } + + /// @notice Helper structure representing a reservation dissolution + /// proposal. + struct ReservationDissolutionProposal { + // 20-byte public key hash of the wallet custodying the reservation. + bytes20 walletPubKeyHash; + // Key of the reservation to dissolve. + uint256 reservationKey; + // Proposed BTC fee for the dissolution transaction. + uint256 dissolutionTxFee; + } + + /// @notice View function encapsulating the main rules of a valid + /// reservation anchor proposal. + /// @param proposal The anchor proposal to validate. + /// @param depositExtraInfo Deposit extra info required to perform the + /// validation. + /// @return True if the proposal is valid. Reverts otherwise. + /// @dev Requirements: + /// - Reservations must be enabled (reservation vault set), + /// - The wallet must be in the Live or MovingFunds state, + /// - The deposit must be revealed, old enough, not swept, and + /// routed to the reservation vault, + /// - The proposed fee must be positive, within the reservation + /// transaction max fee, and must leave an anchor amount above + /// the reservation minimum, + /// - The deposit extra info must be valid and preserve the refund + /// safety margin, + /// - The deposit must be controlled by the proposal wallet. + function validateReservationAnchorProposal( + ReservationAnchorProposal calldata proposal, + DepositExtraInfo calldata depositExtraInfo + ) external view returns (bool) { + ( + address reservationVault, + uint64 reservationMinAmount, + uint64 reservationTxMaxFee, + , + , + , + , + + ) = bridge.reservationParameters(); + + require(reservationVault != address(0), "Reservations are disabled"); + + requireWalletLiveOrMovingFunds(proposal.walletPubKeyHash); + + uint256 depositKeyUint = uint256( + keccak256( + abi.encodePacked( + proposal.depositKey.fundingTxHash, + proposal.depositKey.fundingOutputIndex + ) + ) + ); + + Deposit.DepositRequest memory depositRequest = bridge.deposits( + depositKeyUint + ); + + require(depositRequest.revealedAt != 0, "Deposit not revealed"); + + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp > depositRequest.revealedAt + DEPOSIT_MIN_AGE, + "Deposit min age not achieved yet" + ); + + require(depositRequest.sweptAt == 0, "Deposit already swept"); + + require( + depositRequest.vault == reservationVault, + "Deposit not routed to the reservation vault" + ); + + require( + proposal.anchorTxFee > 0, + "Proposed transaction fee cannot be zero" + ); + require( + proposal.anchorTxFee <= reservationTxMaxFee, + "Proposed transaction fee is too high" + ); + require( + depositRequest.amount - proposal.anchorTxFee >= + reservationMinAmount, + "Anchor amount below the reservation minimum" + ); + + validateDepositExtraInfo( + proposal.depositKey, + depositRequest.depositor, + depositRequest.extraData, + depositExtraInfo + ); + + uint32 depositRefundableTimestamp = BTCUtils.reverseUint32( + uint32(depositExtraInfo.refundLocktime) + ); + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp < + depositRefundableTimestamp - DEPOSIT_REFUND_SAFETY_MARGIN, + "Deposit refund safety margin is not preserved" + ); + + require( + depositExtraInfo.walletPubKeyHash == proposal.walletPubKeyHash, + "Deposit controlled by different wallet" + ); + + return true; + } + + /// @notice View function encapsulating the main rules of a valid + /// reserved redemption proposal. + /// @param proposal The reserved redemption proposal to validate. + /// @return True if the proposal is valid. Reverts otherwise. + /// @dev Requirements: + /// - The wallet must be in the Live or MovingFunds state, + /// - The reservation must have a pending reserved redemption and + /// must be custodied by the proposal wallet, + /// - The request must be old enough, i.e. at least + /// `REDEMPTION_REQUEST_MIN_AGE` or the watchtower veto delay + /// (whichever is greater) elapsed since request time, + /// - The request must have the timeout safety margin preserved, + /// - The proposed fee must be positive and within the max fee + /// snapshotted by the request. + function validateReservedRedemptionProposal( + ReservedRedemptionProposal calldata proposal + ) external view returns (bool) { + requireWalletLiveOrMovingFunds(proposal.walletPubKeyHash); + + Reservation.ReservationRequest memory reservation = bridge.reservations( + proposal.reservationKey + ); + + require( + reservation.state == + Reservation.ReservationState.RedemptionRequested, + "No pending reserved redemption" + ); + require( + reservation.walletPubKeyHash == proposal.walletPubKeyHash, + "Reservation custodied by different wallet" + ); + + uint32 requestMinAge = REDEMPTION_REQUEST_MIN_AGE; + address watchtower = bridge.getRedemptionWatchtower(); + if (watchtower != address(0)) { + uint32 delay = IRedemptionWatchtower(watchtower) + .getReservedRedemptionDelay(proposal.reservationKey); + if (delay > requestMinAge) { + requestMinAge = delay; + } + } + + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp > reservation.redemptionRequestedAt + requestMinAge, + "Reserved redemption min age not achieved yet" + ); + + (, , , , uint32 redemptionTimeout, , ) = bridge.redemptionParameters(); + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp < + reservation.redemptionRequestedAt + + redemptionTimeout - + REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN, + "Redemption request timeout safety margin is not preserved" + ); + + require( + proposal.redemptionTxFee > 0, + "Proposed transaction fee cannot be zero" + ); + require( + proposal.redemptionTxFee <= reservation.redemptionTxMaxFee, + "Proposed transaction fee is too high" + ); + + return true; + } + + /// @notice View function encapsulating the main rules of a valid + /// reservation re-anchor proposal. + /// @param proposal The re-anchor proposal to validate. + /// @return True if the proposal is valid. Reverts otherwise. + /// @dev Requirements: + /// - The reservation must be Active and custodied by the source + /// wallet, + /// - The target wallet must be in the Live state, + /// - The proposed fee must be positive and within the reservation + /// transaction max fee. + /// + /// The source wallet state is deliberately not restricted, mirroring + /// `Reservation.submitReservationReanchorProof`: moving reservations + /// out must remain possible for wallets in any lifecycle state. + function validateReservationReanchorProposal( + ReservationReanchorProposal calldata proposal + ) external view returns (bool) { + Reservation.ReservationRequest memory reservation = bridge.reservations( + proposal.reservationKey + ); + + require( + reservation.state == Reservation.ReservationState.Active, + "Reservation is not active" + ); + require( + reservation.walletPubKeyHash == proposal.sourceWalletPubKeyHash, + "Reservation custodied by different wallet" + ); + + require( + bridge.wallets(proposal.targetWalletPubKeyHash).state == + Wallets.WalletState.Live, + "Target wallet must be in Live state" + ); + + (, , uint64 reservationTxMaxFee, , , , , ) = bridge + .reservationParameters(); + require( + proposal.reanchorTxFee > 0, + "Proposed transaction fee cannot be zero" + ); + require( + proposal.reanchorTxFee <= reservationTxMaxFee, + "Proposed transaction fee is too high" + ); + + return true; + } + + /// @notice View function encapsulating the main rules of a valid + /// reservation dissolution proposal. + /// @param proposal The dissolution proposal to validate. + /// @return True if the proposal is valid. Reverts otherwise. + /// @dev Requirements: + /// - The wallet must be in the Live or MovingFunds state, + /// - The reservation must be Active, custodied by the proposal + /// wallet, and past its custody term plus grace period, + /// - The proposed fee must be positive and within the reservation + /// transaction max fee. + function validateReservationDissolutionProposal( + ReservationDissolutionProposal calldata proposal + ) external view returns (bool) { + requireWalletLiveOrMovingFunds(proposal.walletPubKeyHash); + + Reservation.ReservationRequest memory reservation = bridge.reservations( + proposal.reservationKey + ); + + require( + reservation.state == Reservation.ReservationState.Active, + "Reservation is not active" + ); + require( + reservation.walletPubKeyHash == proposal.walletPubKeyHash, + "Reservation custodied by different wallet" + ); + + ( + , + , + uint64 reservationTxMaxFee, + , + uint32 reservationGracePeriod, + , + , + + ) = bridge.reservationParameters(); + + require( + /* solhint-disable-next-line not-rely-on-time */ + block.timestamp > + uint256(reservation.expiresAt) + reservationGracePeriod, + "Reservation term or grace period not elapsed" + ); + + require( + proposal.dissolutionTxFee > 0, + "Proposed transaction fee cannot be zero" + ); + require( + proposal.dissolutionTxFee <= reservationTxMaxFee, + "Proposed transaction fee is too high" + ); + + return true; + } + + /// @notice Reverts unless the given wallet is in the Live or MovingFunds + /// state. + function requireWalletLiveOrMovingFunds(bytes20 walletPubKeyHash) + internal + view + { + Wallets.WalletState walletState = bridge + .wallets(walletPubKeyHash) + .state; + require( + walletState == Wallets.WalletState.Live || + walletState == Wallets.WalletState.MovingFunds, + "Wallet is not in Live or MovingFunds state" + ); + } } diff --git a/solidity/contracts/bridge/Wallets.sol b/solidity/contracts/bridge/Wallets.sol index a9d03987db..bd054550b8 100644 --- a/solidity/contracts/bridge/Wallets.sol +++ b/solidity/contracts/bridge/Wallets.sol @@ -375,6 +375,14 @@ library Wallets { "Closing period has not elapsed yet" ); + // A wallet still custodying reservation anchors must not close + // ultimately; its reservations must first be re-anchored to other + // wallets or dissolved into the main UTXO. + require( + self.walletReservationsCount[walletPubKeyHash] == 0, + "Wallet still custodies reservations" + ); + finalizeWalletClosing(self, walletPubKeyHash); } diff --git a/solidity/contracts/test/BridgeStub.sol b/solidity/contracts/test/BridgeStub.sol index 11d6042e95..eac26f40e0 100644 --- a/solidity/contracts/test/BridgeStub.sol +++ b/solidity/contracts/test/BridgeStub.sol @@ -6,9 +6,29 @@ 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 { + function setReservation( + uint256 reservationKey, + Reservation.ReservationRequest calldata reservation + ) external { + self.reservations[reservationKey] = reservation; + self.walletReservationsCount[reservation.walletPubKeyHash] += 1; + self.reservationTotalAmount += reservation.anchorAmount; + self.reservationsByAnchorUtxo[ + uint256( + keccak256( + abi.encodePacked( + reservation.anchorTxHash, + reservation.anchorTxOutputIndex + ) + ) + ) + ] = reservationKey; + } + function setSweptDeposits(BitcoinTx.UTXO[] calldata utxos) external { for (uint256 i = 0; i < utxos.length; i++) { uint256 utxoKey = uint256( diff --git a/solidity/contracts/vault/ReservationVault.sol b/solidity/contracts/vault/ReservationVault.sol new file mode 100644 index 0000000000..2836ec2041 --- /dev/null +++ b/solidity/contracts/vault/ReservationVault.sol @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ + +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "./IVault.sol"; +import "./TBTCVault.sol"; +import "../bank/Bank.sol"; +import "../bridge/Reservation.sol"; +import "../token/TBTC.sol"; + +/// @notice Minimal interface of the Bridge functions the reservation vault +/// interacts with. +interface IReservationBridge { + function reservations(uint256 reservationKey) + external + view + returns (Reservation.ReservationRequest memory); + + function requestReservedRedemption( + uint256 reservationKey, + address redeemer, + bytes calldata redeemerOutputScript + ) external; + + function extendReservation(uint256 reservationKey) external; + + function treasury() external view returns (address); +} + +/// @title Reservation vault +/// @notice The reservation vault is the liability-side companion of the +/// Bridge's `Reservation` library. Deposits revealed with this vault +/// address are treated as UTXO reservations: instead of being swept +/// into the pooled supply, they are anchored by the wallet and +/// redeemable in-kind. When the Bridge proves a reservation's anchor +/// transaction, it credits the gross anchored amount to this vault, +/// which mints TBTC gross to the reservation owner and collects all +/// protocol fees as explicit TBTC transfers -- reservation claims +/// are never netted, so the claim surrendered at redemption always +/// equals the sats earmarked on-chain. +/// +/// Fee schedule (basis points of the gross amount, all governable): +/// - initiation: charged when the acceptance credit is processed; +/// covers the mint leg and the first custody term, +/// - extension: charged per custody term extension, +/// - redemption: 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. +/// +/// Wiring requirements: governance must mark this vault as trusted +/// in the Bridge (`setVaultStatus`) so deposits can be revealed with +/// it, and must set it as the Bridge's reservation vault via +/// `updateReservationParameters`. +/// @dev The vault deliberately keeps no claim registry of its own -- the +/// Bridge's reservation records are the single source of truth and are +/// consulted for ownership checks. +contract ReservationVault is IVault, Ownable { + using SafeERC20 for IERC20; + + /// @notice Multiplier to convert satoshi to TBTC token units. + uint256 public constant SATOSHI_MULTIPLIER = 10**10; + + /// @notice Basis points divisor for fee computations. + uint256 public constant BASIS_POINTS = 10000; + + /// @notice Upper sanity bound for each fee parameter, in basis points. + uint256 public constant MAX_FEE_BASIS_POINTS = 500; + + Bank public immutable bank; + TBTCVault public immutable tbtcVault; + TBTC public immutable tbtcToken; + IReservationBridge public immutable bridge; + + /// @notice Initiation fee in basis points of the gross anchored amount, + /// charged when the acceptance credit is processed. Covers the + /// 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. + 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. + uint16 public redemptionFeeBps; + + event ReservationCreditProcessed( + address indexed owner, + uint256 satAmount, + 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, + uint16 redemptionFeeBps + ); + + modifier onlyBank() { + require(msg.sender == address(bank), "Caller is not the Bank"); + _; + } + + constructor( + Bank _bank, + TBTCVault _tbtcVault, + IReservationBridge _bridge + ) { + require( + address(_bank) != address(0), + "Bank can not be the zero address" + ); + require( + address(_tbtcVault) != address(0), + "TBTCVault can not be the zero address" + ); + require( + address(_bridge) != address(0), + "Bridge can not be the zero address" + ); + + bank = _bank; + tbtcVault = _tbtcVault; + tbtcToken = _tbtcVault.tbtcToken(); + bridge = _bridge; + + // Fee schedule (see the UTXO reservation design): the endpoints + // are priced at parity with the pooled path -- a 20 bps mint leg + // inside the 40 bps initiation fee and a 20 bps redemption fee -- + // so the only premium being purchased is the 20 bps/yr custody fee + // (the remainder of the initiation fee prepays the first year). An + // N-year holding pays 40 + 20N bps against the pooled 40 bps round + // trip: strictly premium at every horizon. The minimum reservation + // size, not this schedule, is the governance dial that keeps the + // carry fee covering per-position lifecycle costs. + initiationFeeBps = 40; + extensionFeeBps = 20; + redemptionFeeBps = 20; + } + + /// @notice Called by the Bank when the Bridge proves a reservation's + /// anchor transaction and credits the gross anchored amount to + /// this vault. Mints TBTC gross and forwards it to the + /// reservation owner minus the initiation fee, which is + /// transferred to the Bridge treasury. + /// @dev The gross amount is always minted so the total TBTC supply + /// created against the reservation equals the sats earmarked + /// on-chain; the fee is an explicit transfer, never a netted + /// credit. + function receiveBalanceIncrease( + address[] calldata depositors, + uint256[] calldata depositedAmounts + ) external override onlyBank { + require(depositors.length != 0, "No depositors specified"); + + uint256 totalSat = 0; + for (uint256 i = 0; i < depositedAmounts.length; i++) { + totalSat += depositedAmounts[i]; + } + + // Convert the whole Bank balance credited by the Bridge into TBTC + // minted to this vault in a single mint, then distribute it. + bank.approveBalance(address(tbtcVault), totalSat); + tbtcVault.mint(totalSat * SATOSHI_MULTIPLIER); + + uint256 totalFee = 0; + + for (uint256 i = 0; i < depositors.length; i++) { + uint256 grossTbtc = depositedAmounts[i] * SATOSHI_MULTIPLIER; + uint256 fee = (grossTbtc * initiationFeeBps) / BASIS_POINTS; + totalFee += fee; + + IERC20(tbtcToken).safeTransfer(depositors[i], grossTbtc - fee); + + // slither-disable-next-line reentrancy-events + emit ReservationCreditProcessed( + depositors[i], + depositedAmounts[i], + fee + ); + } + + if (totalFee > 0) { + IERC20(tbtcToken).safeTransfer(bridge.treasury(), totalFee); + } + } + + /// @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 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. + function redeemReservation( + uint256 reservationKey, + bytes calldata redeemerOutputScript, + uint256 maxFeeTbtc + ) external { + Reservation.ReservationRequest memory reservation = bridge.reservations( + reservationKey + ); + require( + reservation.owner == msg.sender, + "Caller is not the reservation owner" + ); + + 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 + ); + } + + /// @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 { + Reservation.ReservationRequest memory reservation = bridge.reservations( + reservationKey + ); + require( + reservation.owner == msg.sender, + "Caller is not the reservation owner" + ); + + 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 Re-requests an in-kind 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 and pays the redemption fee in TBTC. + /// @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 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. + function retryRedeemReservation( + uint256 reservationKey, + bytes calldata redeemerOutputScript + ) external { + Reservation.ReservationRequest memory reservation = bridge.reservations( + reservationKey + ); + require( + reservation.owner == msg.sender, + "Caller is not the reservation owner" + ); + + 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 + ); + } + + /// @notice Updates the vault fee parameters. + /// @dev Requirements: + /// - The caller must be the vault owner (governance), + /// - Each fee must not exceed `MAX_FEE_BASIS_POINTS`. + function updateFees( + uint16 _initiationFeeBps, + uint16 _extensionFeeBps, + uint16 _redemptionFeeBps + ) external onlyOwner { + require( + _initiationFeeBps <= MAX_FEE_BASIS_POINTS && + _extensionFeeBps <= MAX_FEE_BASIS_POINTS && + _redemptionFeeBps <= MAX_FEE_BASIS_POINTS, + "Fee exceeds the maximum" + ); + + initiationFeeBps = _initiationFeeBps; + extensionFeeBps = _extensionFeeBps; + redemptionFeeBps = _redemptionFeeBps; + + emit FeesUpdated( + _initiationFeeBps, + _extensionFeeBps, + _redemptionFeeBps + ); + } + + /// @notice The reservation vault does not support the balance approval + /// flow; reserved redemptions are initiated via + /// `redeemReservation`. + function receiveBalanceApproval( + address, + uint256, + bytes calldata + ) external pure override { + revert("Balance approvals not supported"); + } +} diff --git a/solidity/deploy/06_deploy_bridge.ts b/solidity/deploy/06_deploy_bridge.ts index 194dc3f59a..68b5155e8a 100644 --- a/solidity/deploy/06_deploy_bridge.ts +++ b/solidity/deploy/06_deploy_bridge.ts @@ -39,6 +39,7 @@ const func: DeployFunction = async function deployBridge( }) const Fraud = await deploy("Fraud", deployOptions) const MovingFunds = await deploy("MovingFunds", deployOptions) + const Reservation = await deploy("Reservation", deployOptions) const [bridge, proxyDeployment] = await helpers.upgrades.deployProxy( "Bridge", @@ -62,6 +63,7 @@ const func: DeployFunction = async function deployBridge( Wallets: Wallets.address, Fraud: Fraud.address, MovingFunds: MovingFunds.address, + Reservation: Reservation.address, }, }, proxyOpts: { @@ -82,6 +84,7 @@ const func: DeployFunction = async function deployBridge( await helpers.etherscan.verify(Wallets) await helpers.etherscan.verify(Fraud) await helpers.etherscan.verify(MovingFunds) + await helpers.etherscan.verify(Reservation) // We use `verify` instead of `verify:verify` as the `verify` task is defined // in "@openzeppelin/hardhat-upgrades" to perform Etherscan verification diff --git a/solidity/deploy/80_upgrade_bridge_v2_DEPRECATED.ts b/solidity/deploy/80_upgrade_bridge_v2_DEPRECATED.ts index 2f2109dc1b..90d8e4301e 100644 --- a/solidity/deploy/80_upgrade_bridge_v2_DEPRECATED.ts +++ b/solidity/deploy/80_upgrade_bridge_v2_DEPRECATED.ts @@ -46,6 +46,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { const Wallets = await get("Wallets") const Fraud = await get("Fraud") const MovingFunds = await get("MovingFunds") + const Reservation = await get("Reservation") const Bridge = await deployments.get("Bridge") @@ -56,6 +57,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { Wallets: Wallets.address, Fraud: Fraud.address, MovingFunds: MovingFunds.address, + Reservation: Reservation.address, } const bridgeFactory = await ethers.getContractFactory("Bridge", { diff --git a/solidity/deploy/81_upgrade_bridge_v2_vault_fix.ts b/solidity/deploy/81_upgrade_bridge_v2_vault_fix.ts index 666107a512..67ff9640af 100644 --- a/solidity/deploy/81_upgrade_bridge_v2_vault_fix.ts +++ b/solidity/deploy/81_upgrade_bridge_v2_vault_fix.ts @@ -57,6 +57,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { const Wallets = await get("Wallets") const Fraud = await get("Fraud") const MovingFunds = await get("MovingFunds") + const Reservation = await get("Reservation") log("Using existing libraries:") log(` Deposit: ${Deposit.address}`) @@ -85,6 +86,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { Wallets: Wallets.address, Fraud: Fraud.address, MovingFunds: MovingFunds.address, + Reservation: Reservation.address, }, }, proxyOpts: { diff --git a/solidity/deploy/82_deploy_rebate_and_prepare_txs_DEPRECATED.ts b/solidity/deploy/82_deploy_rebate_and_prepare_txs_DEPRECATED.ts index 4c1fb4ce4e..95aaeaa638 100644 --- a/solidity/deploy/82_deploy_rebate_and_prepare_txs_DEPRECATED.ts +++ b/solidity/deploy/82_deploy_rebate_and_prepare_txs_DEPRECATED.ts @@ -141,6 +141,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { const Wallets = await get("Wallets") const Fraud = await get("Fraud") const MovingFunds = await get("MovingFunds") + const Reservation = await get("Reservation") console.log("✓ Using existing DepositSweep at:", DepositSweep.address) console.log("✓ Using existing Wallets at:", Wallets.address) @@ -167,6 +168,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { Wallets: Wallets.address, Fraud: Fraud.address, MovingFunds: MovingFunds.address, + Reservation: Reservation.address, }, }) @@ -385,6 +387,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { Wallets: Wallets.address, Fraud: Fraud.address, MovingFunds: MovingFunds.address, + Reservation: Reservation.address, }, }) } catch (error) { diff --git a/solidity/deploy/84_upgrade_bridge_repair_rebate_staking_DEPRECATED.ts b/solidity/deploy/84_upgrade_bridge_repair_rebate_staking_DEPRECATED.ts index 6743622ad9..66012c7dac 100644 --- a/solidity/deploy/84_upgrade_bridge_repair_rebate_staking_DEPRECATED.ts +++ b/solidity/deploy/84_upgrade_bridge_repair_rebate_staking_DEPRECATED.ts @@ -75,6 +75,10 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { "MovingFunds", bridgeArtifact.libraries?.MovingFunds ), + Reservation: await resolveAddress( + "Reservation", + bridgeArtifact.libraries?.Reservation + ), } const currentBridge = await ethers.getContractAt("Bridge", bridgeAddress) diff --git a/solidity/deploy/85_deploy_tip109_governance_upgrade.ts b/solidity/deploy/85_deploy_tip109_governance_upgrade.ts index d0a6a85c94..d242f9c7ae 100644 --- a/solidity/deploy/85_deploy_tip109_governance_upgrade.ts +++ b/solidity/deploy/85_deploy_tip109_governance_upgrade.ts @@ -336,6 +336,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { const Wallets = await get("Wallets") const Fraud = await get("Fraud") const MovingFunds = await get("MovingFunds") + const Reservation = await get("Reservation") console.log("Existing library addresses:") console.log(` DepositSweep: ${DepositSweep.address}`) @@ -355,6 +356,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { Wallets: Wallets.address, Fraud: Fraud.address, MovingFunds: MovingFunds.address, + Reservation: Reservation.address, } const bridgeImpl = await deploy("BridgeTIP109Implementation", { diff --git a/solidity/deploy/86_deploy_tip109_hotfix.ts b/solidity/deploy/86_deploy_tip109_hotfix.ts index a0611e422a..43c9dcb7c1 100644 --- a/solidity/deploy/86_deploy_tip109_hotfix.ts +++ b/solidity/deploy/86_deploy_tip109_hotfix.ts @@ -136,6 +136,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { const Wallets = await get("Wallets") const Fraud = await get("Fraud") const MovingFunds = await get("MovingFunds") + const Reservation = await get("Reservation") // --- Step 4: Deploy new Bridge implementation --- // Linked to existing Deposit + new Redemption + unchanged libs. @@ -147,6 +148,7 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { Wallets: Wallets.address, Fraud: Fraud.address, MovingFunds: MovingFunds.address, + Reservation: Reservation.address, } const bridgeImpl = await deploy("BridgeTIP109HotfixImplementation", { diff --git a/solidity/deploy/95_deploy_reservation_vault.ts b/solidity/deploy/95_deploy_reservation_vault.ts new file mode 100644 index 0000000000..0e4510ad81 --- /dev/null +++ b/solidity/deploy/95_deploy_reservation_vault.ts @@ -0,0 +1,34 @@ +import type { HardhatRuntimeEnvironment } from "hardhat/types" +import type { DeployFunction } from "hardhat-deploy/types" + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const { deployments, getNamedAccounts, helpers } = hre + const { deployer } = await getNamedAccounts() + + const Bank = await deployments.get("Bank") + const TBTCVault = await deployments.get("TBTCVault") + const Bridge = await deployments.get("Bridge") + + const reservationVault = await deployments.deploy("ReservationVault", { + from: deployer, + args: [Bank.address, TBTCVault.address, Bridge.address], + log: true, + waitConfirmations: 1, + }) + + // NOTE: To activate reservations, governance must additionally: + // 1. While the vault remains untrusted, stage and finalize the reservation + // vault and parameters through `BridgeGovernance`, + // 2. As the final activation step, mark the vault as trusted through + // `BridgeGovernance.setVaultStatus(vault, true)`. + // Neither action is performed by this script. + + if (hre.network.tags.etherscan) { + await helpers.etherscan.verify(reservationVault) + } +} + +export default func + +func.tags = ["ReservationVault"] +func.dependencies = ["Bank", "TBTCVault"] diff --git a/solidity/hardhat.config.ts b/solidity/hardhat.config.ts index 06b95569d9..435f93d320 100644 --- a/solidity/hardhat.config.ts +++ b/solidity/hardhat.config.ts @@ -73,6 +73,24 @@ const config: HardhatUserConfig = { "@keep-network/ecdsa/contracts/WalletRegistry.sol": ecdsaSolidityCompilerConfig, "contracts/bridge/BridgeGovernance.sol": bridgeGovernanceCompilerConfig, + // Reduce the number of optimizer runs to preserve the Bridge's + // EIP-170 deployment margin after adding the reservation entry + // points. The runtime-gas cost of this is effectively nil: the Bridge + // is a thin dispatch shell over linked libraries (Deposit, + // DepositSweep, Redemption, Reservation) that stay at runs=1000, so a + // measured runs=1000-vs-100 diff over the deposit/redemption/ + // reservation hot paths is 0-8 gas (noise). DRAFT NOTE: the durable + // alternative is a router-style refactor moving entry points out of + // the Bridge (as done on the P2TR activation track). + "contracts/bridge/Bridge.sol": { + version: "0.8.17", + settings: { + optimizer: { + enabled: true, + runs: 100, + }, + }, + }, "contracts/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.sol": { version: "0.8.17", settings: { diff --git a/solidity/test/bridge/Bridge.RebateRecovery.test.ts b/solidity/test/bridge/Bridge.RebateRecovery.test.ts index 3b6ab48538..e83eaf5386 100644 --- a/solidity/test/bridge/Bridge.RebateRecovery.test.ts +++ b/solidity/test/bridge/Bridge.RebateRecovery.test.ts @@ -46,6 +46,7 @@ describe("Bridge - Rebate staking recovery upgrade", () => { Wallets: (await helpers.contracts.getContract("Wallets")).address, Fraud: (await helpers.contracts.getContract("Fraud")).address, MovingFunds: (await helpers.contracts.getContract("MovingFunds")).address, + Reservation: (await helpers.contracts.getContract("Reservation")).address, } const bridgeFactory = await ethers.getContractFactory("BridgeStub", { diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts new file mode 100644 index 0000000000..1eb2f87df1 --- /dev/null +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -0,0 +1,1730 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +import { ethers, helpers, waffle } from "hardhat" +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" +import { BigNumber, Contract } from "ethers" +import chai, { expect } from "chai" +import { FakeContract, smock } from "@defi-wonderland/smock" +import type { + Bank, + BankStub, + Bridge, + BridgeGovernance, + BridgeStub, + IRelay, + ReservationVault, + TBTCVault, + TBTC, +} from "../../typechain" +import bridgeFixture from "../fixtures/bridge" +import { walletState } from "../fixtures" +import { DepositSweepTestData, SingleP2WSHDeposit } from "../data/deposit-sweep" + +chai.use(smock.matchers) + +const { createSnapshot, restoreSnapshot } = helpers.snapshot +const { lastBlockTime, increaseTime } = helpers.time +const { impersonateAccount } = helpers.account + +const ZERO_ADDRESS = ethers.constants.AddressZero +const ZERO_BYTES32 = ethers.constants.HashZero + +const RESERVATION_TERM = 31536000 // 365 days +const RESERVATION_GRACE = 2592000 // 30 days +const RESERVATION_MIN_AMOUNT = 10000 +const RESERVATION_TX_MAX_FEE = 2000 +const RESERVATION_MAX_TOTAL = BigNumber.from("2100000000000000") +const MAX_RESERVATIONS_PER_WALLET = 10 + +const SATOSHI_MULTIPLIER = BigNumber.from(10).pow(10) + +describe("Bridge - Reservation", () => { + let governance: SignerWithAddress + let spvMaintainer: SignerWithAddress + let thirdParty: SignerWithAddress + let treasury: SignerWithAddress + let deployer: SignerWithAddress + + let bank: Bank & BankStub + let relay: FakeContract + let bridge: Bridge & BridgeStub + let tbtc: TBTC & Contract + let tbtcVault: TBTCVault & Contract + let bridgeGovernance: BridgeGovernance + let reservationVault: ReservationVault + let bridgeGovernanceSigner: SignerWithAddress + + before(async () => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ + deployer, + governance, + spvMaintainer, + thirdParty, + treasury, + bank, + relay, + bridge, + bridgeGovernance, + tbtc, + tbtcVault, + } = await waffle.loadFixture(bridgeFixture)) + + reservationVault = await helpers.contracts.getContract("ReservationVault") + + // The Bridge is governed by the BridgeGovernance contract in the + // fixture; impersonate it to exercise onlyGovernance functions + // directly. Production wiring through BridgeGovernance is a follow-up. + bridgeGovernanceSigner = await impersonateContract( + await bridge.governance() + ) + }) + + // Impersonates a contract address, funding it via hardhat_setBalance + // (the impersonateAccount helper funds with a plain transfer, which + // reverts for contracts without a receive function). + 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) + } + + // Marks the reservation vault as trusted and wires it together with the + // reservation parameters into the Bridge. + async function wireReservations() { + await bridge + .connect(bridgeGovernanceSigner) + .setVaultStatus(reservationVault.address, true) + await bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + } + + // Builds an active reservation record owned by `owner`, custodied by the + // wallet identified by `walletPubKeyHash`. + async function activeReservation( + owner: string, + walletPubKeyHash: string, + amountSat: BigNumber + ) { + return { + owner, + mintedAmount: amountSat, + acceptedAt: await lastBlockTime(), + walletPubKeyHash, + anchorAmount: amountSat, + expiresAt: (await lastBlockTime()) + RESERVATION_TERM, + anchorTxHash: ethers.utils.randomBytes(32), + anchorTxOutputIndex: 0, + state: 1, // Active + redemptionRequestedAt: 0, + redemptionTxMaxFee: 0, + redeemer: ZERO_ADDRESS, + redeemerOutputScriptHash: ZERO_BYTES32, + } + } + + // ---- Bitcoin fixture crafting (regtest-style difficulty) ---- + // SPV proof validation checks transaction structure, merkle inclusion, + // and header work against the relay difficulty -- it does not execute + // Bitcoin scripts. Fixtures below use regtest-style compact bits + // (0x207fffff), whose integer difficulty is 0, with the relay mocked to + // report difficulty 0, so headers are minable in a couple of tries. + + const REGTEST_BITS_LE = "ffff7f20" + const REGTEST_TARGET = BigNumber.from("0x7fffff").mul( + BigNumber.from(2).pow(8 * (0x20 - 3)) + ) + + const reverseHex = (hex: string): string => + hex.replace(/^0x/, "").match(/../g)!.reverse().join("") + + const hash256 = (hexData: string): string => + ethers.utils.sha256(ethers.utils.sha256(hexData)) + + const toLE = (value: number | BigNumber, byteLength: number): string => + reverseHex( + BigNumber.from(value) + .toHexString() + .slice(2) + .padStart(byteLength * 2, "0") + ) + + const compactSize = (n: number): string => { + if (n >= 0xfd) { + throw new Error("compactSize > 252 not supported in fixtures") + } + return n.toString(16).padStart(2, "0") + } + + interface FixtureTxIn { + txHash: string + index: number + } + + interface FixtureTxOut { + valueSat: BigNumber | number + script: string // raw script hex, no 0x, no length prefix + } + + function buildTx(inputs: FixtureTxIn[], outputs: FixtureTxOut[]) { + const inputVector = `0x${compactSize(inputs.length)}${inputs + .map((i) => `${i.txHash.slice(2)}${toLE(i.index, 4)}00ffffffff`) + .join("")}` + const outputVector = `0x${compactSize(outputs.length)}${outputs + .map( + (o) => + `${toLE(BigNumber.from(o.valueSat), 8)}${compactSize( + o.script.length / 2 + )}${o.script}` + ) + .join("")}` + const info = { + version: "0x01000000", + inputVector, + outputVector, + locktime: "0x00000000", + } + const txHash = hash256( + `0x01000000${inputVector.slice(2)}${outputVector.slice(2)}00000000` + ) + return { info, txHash } + } + + function mineHeader(merkleRoot: string): string { + const prevBlock = ethers.utils + .hexlify(ethers.utils.randomBytes(32)) + .slice(2) + const base = `20000000${prevBlock}${merkleRoot.slice( + 2 + )}662a2c68${REGTEST_BITS_LE}` + for (let nonce = 0; ; nonce++) { + const header = `0x${base}${toLE(nonce, 4)}` + if ( + BigNumber.from(`0x${reverseHex(hash256(header))}`).lte(REGTEST_TARGET) + ) { + return header + } + } + } + + // Builds an SPV proof for a transaction assumed to share a two-leaf + // block with a synthetic coinbase. + function proofFor(txHash: string) { + const coinbasePreimage = ethers.utils.sha256(ethers.utils.randomBytes(32)) + const coinbaseTxId = ethers.utils.sha256(coinbasePreimage) + const merkleRoot = hash256(`0x${coinbaseTxId.slice(2)}${txHash.slice(2)}`) + return { + merkleProof: coinbaseTxId, + txIndexInBlock: 1, + bitcoinHeaders: mineHeader(merkleRoot), + coinbasePreimage, + coinbaseProof: txHash, + } + } + + const buildDepositScript = ( + depositor: string, + blinding: string, + walletPkh: string, + refundPkh: string, + locktime: string + ): string => + `14${depositor.slice(2)}7508${blinding.slice(2)}7576a914${walletPkh + .slice(2) + .toLowerCase()}8763ac6776a914${refundPkh.slice(2)}8804${locktime.slice( + 2 + )}b175ac68` + + const p2wshScript = (script: string): string => + `0020${ethers.utils.sha256(`0x${script}`).slice(2)}` + + const p2wpkhScript = (pkh: string): string => `0014${pkh.slice(2)}` + + describe("updateReservationParameters", () => { + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when called by a third party", () => { + it("should revert", async () => { + await expect( + bridge + .connect(thirdParty) + .updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + ).to.be.revertedWith("Caller is not the governance") + }) + }) + + context("when called by the governance", () => { + it("should set the parameters and the reservation vault", async () => { + const tx = await bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + + await expect(tx) + .to.emit(bridge, "ReservationVaultUpdated") + .withArgs(reservationVault.address) + await expect(tx) + .to.emit(bridge, "ReservationParametersUpdated") + .withArgs( + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + }) + + it("should revert for a zero transaction max fee", async () => { + await expect( + bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + 0, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + ).to.be.revertedWith( + "Reservation transaction max fee must be greater than zero" + ) + }) + + it("should revert when changing the vault with active reservations", async () => { + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + await bridge.setReservation( + 12345, + await activeReservation( + thirdParty.address, + walletPubKeyHash, + BigNumber.from(100000000) + ) + ) + + await expect( + bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + thirdParty.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + ).to.be.revertedWith("Active reservations exist") + }) + }) + }) + + describe("deposit sweep guard", () => { + before(async () => { + await createSnapshot() + await wireReservations() + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should refuse to sweep deposits routed to the reservation vault", async () => { + const data: DepositSweepTestData = JSON.parse( + JSON.stringify(SingleP2WSHDeposit) + ) + // Route the revealed deposit and the sweep to the reservation vault. + data.deposits[0].reveal.vault = reservationVault.address + data.vault = reservationVault.address + + relay.getCurrentEpochDifficulty.returns(data.chainDifficulty) + relay.getPrevEpochDifficulty.returns(data.chainDifficulty) + + await bridge.setDepositDustThreshold(10000) + await bridge.setDepositTxMaxFee(2000) + await bridge.setDepositRevealAheadPeriod(0) + + const { fundingTx, depositor, reveal } = data.deposits[0] + await bridge.setWallet(reveal.walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + + const depositorSigner = await impersonateAccount(depositor, { + from: governance, + value: 10, + }) + await bridge.connect(depositorSigner).revealDeposit(fundingTx, reveal) + + await expect( + bridge + .connect(spvMaintainer) + .submitDepositSweepProof( + data.sweepTx, + data.sweepProof, + data.mainUtxo, + data.vault + ) + ).to.be.revertedWith("Reserved deposits must not be swept") + }) + }) + + describe("extendReservation", () => { + const reservationKey = 777 + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + + before(async () => { + await createSnapshot() + await wireReservations() + await bridge.setReservation( + reservationKey, + await activeReservation( + thirdParty.address, + walletPubKeyHash, + BigNumber.from(100000000) + ) + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when called by a third party", () => { + it("should revert", async () => { + await expect( + bridge.connect(thirdParty).extendReservation(reservationKey) + ).to.be.revertedWith("Caller is not the reservation vault") + }) + }) + + context("when called by the reservation vault", () => { + it("should extend the reservation term", async () => { + const before_ = await bridge.reservations(reservationKey) + + const vaultSigner = await impersonateContract(reservationVault.address) + const tx = await bridge + .connect(vaultSigner) + .extendReservation(reservationKey) + + const after_ = await bridge.reservations(reservationKey) + expect(after_.expiresAt).to.equal(before_.expiresAt + RESERVATION_TERM) + await expect(tx) + .to.emit(bridge, "ReservationExtended") + .withArgs(reservationKey, after_.expiresAt) + }) + }) + }) + + describe("requestReservedRedemption", () => { + const reservationKey = 888 + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const amountSat = BigNumber.from(100000000) // 1 BTC + // A valid P2WPKH redeemer output script. + const redeemerOutputScript = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + + before(async () => { + await createSnapshot() + await wireReservations() + + // A Terminated wallet lets the timeout path skip slashing and state + // transitions, isolating the reservation bookkeeping under test. + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Terminated, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + + // Give the reservation vault the gross Bank balance it must + // surrender with the request. + const bridgeSigner = await impersonateContract(bridge.address) + await bank + .connect(bridgeSigner) + .increaseBalance(reservationVault.address, amountSat) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert when called by a third party", async () => { + await expect( + bridge + .connect(thirdParty) + .requestReservedRedemption( + reservationKey, + thirdParty.address, + redeemerOutputScript + ) + ).to.be.revertedWith("Caller is not the reservation vault") + }) + + it("should register the redemption, take the balance, and refund on timeout", async () => { + const vaultSigner = await impersonateContract(reservationVault.address) + + await bank.connect(vaultSigner).approveBalance(bridge.address, amountSat) + + const tx = await bridge + .connect(vaultSigner) + .requestReservedRedemption( + reservationKey, + thirdParty.address, + redeemerOutputScript + ) + + await expect(tx) + .to.emit(bridge, "ReservedRedemptionRequested") + .withArgs( + reservationKey, + thirdParty.address, + redeemerOutputScript, + amountSat, + RESERVATION_TX_MAX_FEE + ) + + expect((await bridge.reservations(reservationKey)).state).to.equal(2) // RedemptionRequested + expect(await bank.balanceOf(bridge.address)).to.equal(amountSat) + + // Re-requesting while one is pending must fail. + await expect( + bridge + .connect(vaultSigner) + .requestReservedRedemption( + reservationKey, + thirdParty.address, + redeemerOutputScript + ) + ).to.be.revertedWith("Reservation is not active") + + // Timeout: the redeemer gets the balance back and the reservation + // survives as Active. + const { redemptionTimeout } = await bridge.redemptionParameters() + await increaseTime(redemptionTimeout + 1) + + const timeoutTx = await bridge + .connect(thirdParty) + .notifyReservedRedemptionTimeout(reservationKey, []) + + await expect(timeoutTx) + .to.emit(bridge, "ReservedRedemptionTimedOut") + .withArgs(reservationKey, walletPubKeyHash) + + expect(await bank.balanceOf(thirdParty.address)).to.equal(amountSat) + expect((await bridge.reservations(reservationKey)).state).to.equal(1) // Active + }) + }) + + describe("ReservationVault", () => { + const amountSat = BigNumber.from(100000000) // 1 BTC + const grossTbtc = amountSat.mul(SATOSHI_MULTIPLIER) + + before(async () => { + await createSnapshot() + await wireReservations() + + // In the base fixture the TBTC token is still owned by the + // VendingMachine; hand it to the TBTC vault so minting works. + const tbtcOwner = await impersonateContract(await tbtc.owner()) + await tbtc.connect(tbtcOwner).transferOwnership(tbtcVault.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + describe("receiveBalanceIncrease", () => { + it("should revert when not called by the Bank", async () => { + await expect( + reservationVault + .connect(thirdParty) + .receiveBalanceIncrease([thirdParty.address], [amountSat]) + ).to.be.revertedWith("Caller is not the Bank") + }) + + it("should mint gross TBTC and split the initiation fee", async () => { + const bridgeSigner = await impersonateContract(bridge.address) + + // Simulates the acceptance-proof credit performed by the Bridge. + await bank + .connect(bridgeSigner) + .increaseBalanceAndCall( + reservationVault.address, + [thirdParty.address], + [amountSat] + ) + + // 40 bps initiation fee. + const expectedFee = grossTbtc.mul(40).div(10000) + expect(await tbtc.balanceOf(thirdParty.address)).to.equal( + grossTbtc.sub(expectedFee) + ) + expect(await tbtc.balanceOf(treasury.address)).to.equal(expectedFee) + }) + }) + + describe("redeemReservation", () => { + const reservationKey = 999 + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const redeemerOutputScript = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + + before(async () => { + 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)] + ) + }) + + 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(bridge, "ReservedRedemptionRequested") + + expect(await tbtc.balanceOf(treasury.address)).to.equal( + treasuryBalanceBefore.add(fee) + ) + expect((await bridge.reservations(reservationKey)).state).to.equal(2) + 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) + + await reservationVault.connect(deployer).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 bridge.reservations(exposedReservationKey)).state + ).to.equal(1) + + 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 bridge.reservations(exposedReservationKey)).state + ).to.equal(2) + + await reservationVault.connect(deployer).updateFees(40, 20, 20) + }) + }) + }) + + describe("notifyReservedRedemptionVeto", () => { + const reservationKey = 555 + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const amountSat = BigNumber.from(100000000) + const redeemerOutputScript = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + + before(async () => { + await createSnapshot() + await wireReservations() + + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Terminated, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + + const bridgeSigner = await impersonateContract(bridge.address) + await bank + .connect(bridgeSigner) + .increaseBalance(reservationVault.address, amountSat) + const vaultSigner = await impersonateContract(reservationVault.address) + await bank.connect(vaultSigner).approveBalance(bridge.address, amountSat) + await bridge + .connect(vaultSigner) + .requestReservedRedemption( + reservationKey, + thirdParty.address, + redeemerOutputScript + ) + + // Wire the watchtower only after the request so the + // isSafeRedemption 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( + bridge.connect(thirdParty).notifyReservedRedemptionVeto(reservationKey) + ).to.be.revertedWith("Caller is not the redemption watchtower") + }) + + it("should detain the balance and reactivate the reservation", async () => { + const tx = await bridge + .connect(deployer) + .notifyReservedRedemptionVeto(reservationKey) + + await expect(tx) + .to.emit(bridge, "ReservedRedemptionVetoed") + .withArgs(reservationKey) + + expect(await bank.balanceOf(deployer.address)).to.equal(amountSat) + expect((await bridge.reservations(reservationKey)).state).to.equal(1) // Active + }) + }) + + describe("ReservationVault.retryRedeemReservation", () => { + const reservationKey = 444 + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const amountSat = BigNumber.from(100000000) + const grossTbtc = amountSat.mul(SATOSHI_MULTIPLIER) + const redeemerOutputScript = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + + before(async () => { + await createSnapshot() + await wireReservations() + + const tbtcOwner = await impersonateContract(await tbtc.owner()) + await tbtc.connect(tbtcOwner).transferOwnership(tbtcVault.address) + + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Terminated, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + + // Simulate the state a redemption timeout leaves the owner in: the + // gross amount refunded as Bank balance. The fee is paid in TBTC, + // funded here through an ordinary reservation credit. + const bridgeSigner = await impersonateContract(bridge.address) + await bank + .connect(bridgeSigner) + .increaseBalance(thirdParty.address, amountSat) + await bank + .connect(bridgeSigner) + .increaseBalanceAndCall( + reservationVault.address, + [thirdParty.address], + [amountSat] + ) + }) + + 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(bridge, "ReservedRedemptionRequested") + expect((await bridge.reservations(reservationKey)).state).to.equal(2) // RedemptionRequested + 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 + ) + }) + }) + + describe("governance-delayed reservation parameters update", () => { + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) + + it("stages the parameters and applies them after the governance delay", async () => { + const beginTx = await bridgeGovernance + .connect(governance) + .beginReservationParametersUpdate( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + + const beginReceipt = await beginTx.wait() + const beginBlock = await ethers.provider.getBlock( + beginReceipt.blockNumber + ) + + await expect(beginTx) + .to.emit(bridgeGovernance, "ReservationParametersUpdateStarted") + .withArgs( + reservationVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET, + beginBlock.timestamp + ) + + await expect( + bridgeGovernance + .connect(governance) + .finalizeReservationParametersUpdate() + ).to.be.revertedWith("Governance delay has not elapsed") + + await increaseTime( + (await bridgeGovernance.governanceDelays(0)).toNumber() + ) + + const tx = await bridgeGovernance + .connect(governance) + .finalizeReservationParametersUpdate() + + await expect(tx) + .to.emit(bridge, "ReservationVaultUpdated") + .withArgs(reservationVault.address) + await expect(tx) + .to.emit(bridge, "ReservationParametersUpdated") + .withArgs( + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + }) + }) + + describe("RedemptionWatchtower reserved veto flow", () => { + const reservationKey = 666 + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const amountSat = BigNumber.from(100000000) + const redeemerOutputScript = + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + let redemptionWatchtower: Contract + let guardianSigners: SignerWithAddress[] + + before(async () => { + await createSnapshot() + await wireReservations() + + redemptionWatchtower = await helpers.contracts.getContract( + "RedemptionWatchtower" + ) + + // Enable the watchtower with three guardians and wire it into the + // Bridge. + 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() + ) + for (let i = 0; i < guardianSigners.length; i++) { + // eslint-disable-next-line no-await-in-loop + const alreadyGuardian = await redemptionWatchtower.isGuardian( + guardianSigners[i].address + ) + if (!alreadyGuardian) { + // eslint-disable-next-line no-await-in-loop + await redemptionWatchtower + .connect(manager) + .addGuardian(guardianSigners[i].address) + } + } + } + await bridge + .connect(bridgeGovernanceSigner) + .setRedemptionWatchtower(redemptionWatchtower.address) + + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Terminated, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + + const bridgeSigner = await impersonateContract(bridge.address) + await bank + .connect(bridgeSigner) + .increaseBalance(reservationVault.address, amountSat) + const vaultSigner = await impersonateContract(reservationVault.address) + await bank.connect(vaultSigner).approveBalance(bridge.address, amountSat) + await bridge + .connect(vaultSigner) + .requestReservedRedemption( + reservationKey, + thirdParty.address, + redeemerOutputScript + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("vetoes a reserved redemption after three guardian objections", async () => { + await redemptionWatchtower + .connect(guardianSigners[0]) + .raiseReservedObjection(reservationKey) + await redemptionWatchtower + .connect(guardianSigners[1]) + .raiseReservedObjection(reservationKey) + + const tx = await redemptionWatchtower + .connect(guardianSigners[2]) + .raiseReservedObjection(reservationKey) + + await expect(tx) + .to.emit(redemptionWatchtower, "VetoFinalized") + .withArgs(reservationKey) + await expect(tx) + .to.emit(bridge, "ReservedRedemptionVetoed") + .withArgs(reservationKey) + await expect(tx) + .to.emit(redemptionWatchtower, "Banned") + .withArgs(thirdParty.address) + + // The reservation survives the veto as Active. + expect((await bridge.reservations(reservationKey)).state).to.equal(1) + + // Default penalty is 100%: the whole detained amount is burned. + const veto = await redemptionWatchtower.vetoProposals(reservationKey) + expect(veto.withdrawableAmount).to.equal(0) + expect(await bank.balanceOf(redemptionWatchtower.address)).to.equal(0) + + // The banned owner cannot re-request through the vault: the + // isSafeRedemption gate now rejects them. + const vaultSigner = await impersonateContract(reservationVault.address) + await expect( + bridge + .connect(vaultSigner) + .requestReservedRedemption( + reservationKey, + thirdParty.address, + redeemerOutputScript + ) + ).to.be.revertedWith("Redemption request rejected by the watchtower") + }) + }) + + describe("WalletProposalValidator", () => { + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const secondWalletPubKeyHash = "0xafcdf88d15a0e0c2134dbbc9f6da24d0e26c8f21" + const blindingFactor = "0xf9f0c90d00039523" + const refundPubKeyHash = "0x28e081f285138ccbe389c1eb8985716230129f89" + const amountSat = BigNumber.from(100000000) + + let validator: Contract + let futureRefundLocktime: string + let fundingTx: { info: any; txHash: string } + let depositKey: { fundingTxHash: string; fundingOutputIndex: number } + let depositExtraInfo: any + + before(async () => { + await createSnapshot() + await wireReservations() + + const ValidatorFactory = await ethers.getContractFactory( + "WalletProposalValidator" + ) + validator = await ValidatorFactory.deploy(bridge.address) + + await bridge.setDepositDustThreshold(10000) + await bridge.setDepositTxMaxFee(2000) + await bridge.setDepositRevealAheadPeriod(0) + + await bridge.setWallet(walletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + + // A refund locktime comfortably in the future (400 days), as 4-byte LE. + futureRefundLocktime = `0x${toLE( + (await lastBlockTime()) + 400 * 24 * 3600, + 4 + )}` + + const depositScript = buildDepositScript( + thirdParty.address, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + futureRefundLocktime + ) + fundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: BigNumber.from(3000000), + script: p2wshScript(depositScript), + }, + ] + ) + + await bridge.connect(thirdParty).revealDeposit(fundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime: futureRefundLocktime, + vault: reservationVault.address, + }) + + depositKey = { fundingTxHash: fundingTx.txHash, fundingOutputIndex: 0 } + depositExtraInfo = { + fundingTx: fundingTx.info, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime: futureRefundLocktime, + } + + // Let the deposit exceed DEPOSIT_MIN_AGE (2 hours). + await increaseTime(7300) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("rejects sweep proposals containing reserved deposits", async () => { + await expect( + validator.validateDepositSweepProposal( + { + walletPubKeyHash, + depositsKeys: [depositKey], + sweepTxFee: 1500, + depositsRevealBlocks: [0], + }, + [depositExtraInfo] + ) + ).to.be.revertedWith("Reserved deposits must not be swept") + }) + + it("validates a reservation anchor proposal", async () => { + expect( + await validator.validateReservationAnchorProposal( + { walletPubKeyHash, depositKey, anchorTxFee: 1500 }, + depositExtraInfo + ) + ).to.be.true + + await expect( + validator.validateReservationAnchorProposal( + { walletPubKeyHash, depositKey, anchorTxFee: 0 }, + depositExtraInfo + ) + ).to.be.revertedWith("Proposed transaction fee cannot be zero") + + await expect( + validator.validateReservationAnchorProposal( + { walletPubKeyHash, depositKey, anchorTxFee: 2001 }, + depositExtraInfo + ) + ).to.be.revertedWith("Proposed transaction fee is too high") + }) + + it("validates reserved redemption, re-anchor and dissolution proposals", async () => { + const reservationKey = 12321 + await bridge.setReservation( + reservationKey, + await activeReservation(thirdParty.address, walletPubKeyHash, amountSat) + ) + + // Re-anchor: valid toward a Live target wallet. + await bridge.setWallet(secondWalletPubKeyHash, { + ecdsaWalletID: ethers.utils.randomBytes(32), + mainUtxoHash: ZERO_BYTES32, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: ZERO_BYTES32, + }) + expect( + await validator.validateReservationReanchorProposal({ + sourceWalletPubKeyHash: walletPubKeyHash, + reservationKey, + targetWalletPubKeyHash: secondWalletPubKeyHash, + reanchorTxFee: 1500, + }) + ).to.be.true + + // Dissolution: rejected before term + grace elapse. + await expect( + validator.validateReservationDissolutionProposal({ + walletPubKeyHash, + reservationKey, + dissolutionTxFee: 1500, + }) + ).to.be.revertedWith("Reservation term or grace period not elapsed") + + // Reserved redemption: request through the vault, wait past min age. + const bridgeSigner = await impersonateContract(bridge.address) + await bank + .connect(bridgeSigner) + .increaseBalance(reservationVault.address, amountSat) + const vaultSigner = await impersonateContract(reservationVault.address) + await bank.connect(vaultSigner).approveBalance(bridge.address, amountSat) + await bridge + .connect(vaultSigner) + .requestReservedRedemption( + reservationKey, + thirdParty.address, + "0x160014f4eedc8f40d4b8e30771f792b065ebec0abaddef" + ) + + await increaseTime(601) + + expect( + await validator.validateReservedRedemptionProposal({ + walletPubKeyHash, + reservationKey, + redemptionTxFee: 1500, + }) + ).to.be.true + + await expect( + validator.validateReservedRedemptionProposal({ + walletPubKeyHash: secondWalletPubKeyHash, + reservationKey, + redemptionTxFee: 1500, + }) + ).to.be.revertedWith("Reservation custodied by different wallet") + }) + }) + + describe("reservation SPV proofs", () => { + // ---- Scenario constants ---- + + const walletPubKeyHash = "0x8db50eb52063ea9d98b3eac91489a90f738986f6" + const secondWalletPubKeyHash = "0xafcdf88d15a0e0c2134dbbc9f6da24d0e26c8f21" + const blindingFactor = "0xf9f0c90d00039523" + const refundPubKeyHash = "0x28e081f285138ccbe389c1eb8985716230129f89" + const refundLocktime = "0x60bcea61" + const NO_MAIN_UTXO_PARAM = { + txHash: ZERO_BYTES32, + txOutputIndex: 0, + txOutputValue: 0, + } + + const depositAmount = BigNumber.from(3000000) + const anchorFee = 1500 + const anchorAmount = depositAmount.sub(anchorFee) + const grossTbtc = anchorAmount.mul(SATOSHI_MULTIPLIER) + + const ProofType = { + Acceptance: 0, + Redemption: 1, + Reanchor: 2, + Dissolution: 3, + } + + 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, + }) + } + + // Reveals a fresh reserved deposit and proves its anchor transaction. + async function makeAcceptedReservation(anchorValue?: BigNumber) { + const fundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: depositAmount, + script: p2wshScript( + buildDepositScript( + thirdParty.address, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime + ) + ), + }, + ] + ) + + await bridge.connect(thirdParty).revealDeposit(fundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime, + vault: reservationVault.address, + }) + + const anchorTx = buildTx( + [{ txHash: fundingTx.txHash, index: 0 }], + [ + { + valueSat: anchorValue ?? anchorAmount, + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + + const acceptTx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Acceptance, + anchorTx.info, + proofFor(anchorTx.txHash), + NO_MAIN_UTXO_PARAM, + 0 + ) + + const reservationKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) + + return { fundingTx, anchorTx, acceptTx, reservationKey } + } + + before(async () => { + await createSnapshot() + await wireReservations() + + relay.getCurrentEpochDifficulty.returns(0) + relay.getPrevEpochDifficulty.returns(0) + + await bridge.setDepositDustThreshold(10000) + await bridge.setDepositTxMaxFee(2000) + await bridge.setDepositRevealAheadPeriod(0) + await liveWallet(walletPubKeyHash) + + const tbtcOwner = await impersonateContract(await tbtc.owner()) + await tbtc.connect(tbtcOwner).transferOwnership(tbtcVault.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + beforeEach(async () => { + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() + }) + + it("accepts a proven anchor and credits the gross amount", async () => { + const { anchorTx, acceptTx, reservationKey } = + await makeAcceptedReservation() + + await expect(acceptTx) + .to.emit(bridge, "ReservationAccepted") + .withArgs( + reservationKey, + walletPubKeyHash, + thirdParty.address, + anchorTx.txHash, + anchorAmount, + ( + await bridge.reservations(reservationKey) + ).expiresAt + ) + + const reservation = await bridge.reservations(reservationKey) + expect(reservation.owner).to.equal(thirdParty.address) + expect(reservation.mintedAmount).to.equal(anchorAmount) + expect(reservation.anchorAmount).to.equal(anchorAmount) + expect(reservation.anchorTxHash).to.equal(anchorTx.txHash) + expect(reservation.state).to.equal(1) // Active + + // Gross mint minus the 40 bps initiation fee. + const fee = grossTbtc.mul(40).div(10000) + expect(await tbtc.balanceOf(thirdParty.address)).to.equal( + grossTbtc.sub(fee) + ) + + // The anchor consumed the deposit: no double acceptance. + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Acceptance, + anchorTx.info, + proofFor(anchorTx.txHash), + NO_MAIN_UTXO_PARAM, + 0 + ) + ).to.be.revertedWith("Deposit already swept") + }) + + it("rejects an anchor paying an excessive miner fee", async () => { + await expect( + makeAcceptedReservation(depositAmount.sub(RESERVATION_TX_MAX_FEE + 1)) + ).to.be.revertedWith("Transaction fee is too high") + }) + + it("completes an in-kind reserved redemption", async () => { + // Two acceptances fund the owner with enough TBTC for the gross + // surrender plus the redemption fee on the first reservation. + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() + + const redeemerScript = `0x16${p2wpkhScript( + ethers.utils.hexlify(ethers.utils.randomBytes(20)) + )}` + + // Captured before the vault unmints the gross surrender: the ERC-20 + // burn happens at request time, while the SPV proof burns the Bank + // balance held by the Bridge. + const supplyBefore = await tbtc.totalSupply() + + const redemptionFee = grossTbtc.mul(20).div(10000) + await tbtc + .connect(thirdParty) + .approve(reservationVault.address, grossTbtc.add(redemptionFee)) + await reservationVault + .connect(thirdParty) + .redeemReservation(reservationKey, redeemerScript, redemptionFee) + const redemptionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(1000), + script: redeemerScript.slice(4), // strip 0x16 length prefix + }, + ] + ) + + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey + ) + + await expect(tx) + .to.emit(bridge, "ReservedRedemptionCompleted") + .withArgs(reservationKey, redemptionTx.txHash) + + expect((await bridge.reservations(reservationKey)).state).to.equal(3) // Closed + // The gross surrendered balance was burned. + expect(await bank.balanceOf(bridge.address)).to.equal(0) + expect(await tbtc.totalSupply()).to.equal(supplyBefore.sub(grossTbtc)) + }) + + it("keeps redemption provable when txMaxFee exceeds the anchor (underflow guard)", async () => { + // Regression: redemptionTxMaxFee is a governable parameter, not a + // tx-derived value, so a governance increase can push it above an + // existing anchorAmount. The redemption range check must not underflow + // in that case. Fund the owner with two acceptances, then raise the + // fee/min parameters above the anchor and prove an in-kind redemption. + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await makeAcceptedReservation() // funds the owner with extra TBTC + + // Raise the reservation tx max fee above the anchor amount (and the + // minimum above the fee, preserving the invariant). + await bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + reservationVault.address, + anchorAmount.add(2), + anchorAmount.add(1), + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + + const redeemerScript = `0x16${p2wpkhScript( + ethers.utils.hexlify(ethers.utils.randomBytes(20)) + )}` + const redemptionFee = grossTbtc.mul(20).div(10000) + await tbtc + .connect(thirdParty) + .approve(reservationVault.address, grossTbtc.add(redemptionFee)) + await reservationVault + .connect(thirdParty) + .redeemReservation(reservationKey, redeemerScript, redemptionFee) + + // Pay the full anchor value out (zero miner fee). The old lower bound + // `anchorAmount - redemptionTxMaxFee` would revert on underflow here. + const redemptionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [{ valueSat: anchorAmount, script: redeemerScript.slice(4) }] + ) + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Redemption, + redemptionTx.info, + proofFor(redemptionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey + ) + await expect(tx) + .to.emit(bridge, "ReservedRedemptionCompleted") + .withArgs(reservationKey, redemptionTx.txHash) + }) + + it("allows a minimum-sized reservation to migrate (H-08 regression)", async () => { + // A reservation whose anchor sits at the reservation minimum must + // still be migratable with a positive fee. The earlier + // `>= reservationMinAmount` re-anchor floor, combined with the + // proposal validator's positive-fee requirement, left no compliant + // re-anchor for an exactly-minimum anchor and would have pinned a + // retiring wallet. The dust floor (`> reservationTxMaxFee`) fixes it. + const minAmount = BigNumber.from(RESERVATION_MIN_AMOUNT) + const depositAmt = minAmount.add(RESERVATION_TX_MAX_FEE) + const fundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: depositAmt, + script: p2wshScript( + buildDepositScript( + thirdParty.address, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime + ) + ), + }, + ] + ) + await bridge.connect(thirdParty).revealDeposit(fundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime, + vault: reservationVault.address, + }) + const anchorTx = buildTx( + [{ txHash: fundingTx.txHash, index: 0 }], + [{ valueSat: minAmount, script: p2wpkhScript(walletPubKeyHash) }] + ) + await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Acceptance, + anchorTx.info, + proofFor(anchorTx.txHash), + NO_MAIN_UTXO_PARAM, + 0 + ) + const reservationKey = BigNumber.from( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.txHash, 0] + ) + ) + + await liveWallet(secondWalletPubKeyHash) + + // Migrate with a 1-sat fee: the anchor stays above the dust floor. + const migrated = minAmount.sub(1) + const reanchorTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [{ valueSat: migrated, script: p2wpkhScript(secondWalletPubKeyHash) }] + ) + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Reanchor, + reanchorTx.info, + proofFor(reanchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey + ) + await expect(tx) + .to.emit(bridge, "ReservationReanchored") + .withArgs( + reservationKey, + secondWalletPubKeyHash, + reanchorTx.txHash, + migrated + ) + }) + + it("dissolves an expired reservation into the wallet main UTXO", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + + const dissolutionFee = 500 + const dissolutionTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(dissolutionFee), + script: p2wpkhScript(walletPubKeyHash), + }, + ] + ) + + // Not dissolvable before term + grace. + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Dissolution, + dissolutionTx.info, + proofFor(dissolutionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey + ) + ).to.be.revertedWith("Reservation term or grace period not elapsed") + + await increaseTime(RESERVATION_TERM + RESERVATION_GRACE + 60) + + const tx = await bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Dissolution, + dissolutionTx.info, + proofFor(dissolutionTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey + ) + + await expect(tx).to.emit(bridge, "ReservationDissolved") + + expect((await bridge.reservations(reservationKey)).state).to.equal(3) // Closed + + // The dissolution output became the wallet's new main UTXO. + const wallet = await bridge.wallets(walletPubKeyHash) + expect(wallet.mainUtxoHash).to.equal( + ethers.utils.solidityKeccak256( + ["bytes32", "uint32", "uint64"], + [dissolutionTx.txHash, 0, anchorAmount.sub(dissolutionFee)] + ) + ) + }) + }) +}) diff --git a/solidity/test/bridge/Bridge.StorageLayout.test.ts b/solidity/test/bridge/Bridge.StorageLayout.test.ts new file mode 100644 index 0000000000..b3b2e1fd64 --- /dev/null +++ b/solidity/test/bridge/Bridge.StorageLayout.test.ts @@ -0,0 +1,96 @@ +import { artifacts } from "hardhat" +import { expect } from "chai" +import { assertStorageUpgradeSafe } from "@openzeppelin/upgrades-core" + +import bridgeTIP109HotfixDeployment from "../../deployments/mainnet/BridgeTIP109HotfixImplementation.json" + +type StorageEntry = { + label: string + offset: number + slot: string + type: string +} + +type StorageLayout = { + storage: StorageEntry[] + types: Record< + string, + { + label: string + encoding: string + numberOfBytes: string + members?: StorageEntry[] + key?: string + value?: string + base?: string + } + > +} + +type UpgradeStorageLayout = Parameters[0] + +async function getBridgeStorageLayout(): Promise { + const sourceName = "contracts/bridge/Bridge.sol" + const contractName = "Bridge" + // Bridge is compiled in multiple optimizer jobs and its final artifact can + // point at a job without storageLayout output. BridgeState's artifact points + // at the validation-enabled job that also contains the compiled Bridge. + const buildInfo = await artifacts.getBuildInfo( + "contracts/bridge/BridgeState.sol:BridgeState" + ) + if (!buildInfo) { + throw new Error(`No build info for ${sourceName}:${contractName}`) + } + + const layout = ( + buildInfo.output.contracts[sourceName][contractName] as { + storageLayout?: StorageLayout + } + ).storageLayout + if (!layout) { + throw new Error(`No storage layout for ${sourceName}:${contractName}`) + } + + return layout +} + +function bridgeStateLayout(layout: StorageLayout): StorageLayout { + const bridgeState = layout.storage.find((entry) => entry.label === "self") + if (!bridgeState) { + throw new Error("BridgeState.Storage entry not found") + } + + const bridgeStateType = layout.types[bridgeState.type] + if (!bridgeStateType?.members) { + throw new Error("BridgeState.Storage members not found") + } + + // Bridge stores its state in one library struct. Flatten that struct for + // OpenZeppelin 1.x so its normal gap-consumption algorithm validates the + // real member slots; that release rejects every nested-struct append before + // applying the nested struct's own __gap semantics. + return { + storage: bridgeStateType.members, + types: layout.types, + } +} + +describe("Bridge storage layout", () => { + it("is upgrade-safe against the deployed TIP-109 Bridge layout", async () => { + const deployedLayout = bridgeStateLayout( + bridgeTIP109HotfixDeployment.storageLayout as unknown as StorageLayout + ) + const updatedLayout = bridgeStateLayout(await getBridgeStorageLayout()) + + // The checked-in deployment artifact predates solc's enum-members output. + // Allow incomplete custom-type descriptions while still validating every + // concrete slot, packing decision, mapping, struct, and storage-gap change. + expect(() => + assertStorageUpgradeSafe( + deployedLayout as unknown as UpgradeStorageLayout, + updatedLayout as unknown as UpgradeStorageLayout, + true + ) + ).not.to.throw() + }) +}) diff --git a/solidity/test/deploy/85_deploy_tip109_governance_upgrade.test.ts b/solidity/test/deploy/85_deploy_tip109_governance_upgrade.test.ts index 514c53048d..b8839bfce9 100644 --- a/solidity/test/deploy/85_deploy_tip109_governance_upgrade.test.ts +++ b/solidity/test/deploy/85_deploy_tip109_governance_upgrade.test.ts @@ -27,6 +27,7 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { const MOVING_FUNDS_ADDRESS = "0x0000000000000000000000000000000000000006" const BRIDGE_IMPL_ADDRESS = "0x0000000000000000000000000000000000000007" const REBATE_IMPL_ADDRESS = "0x0000000000000000000000000000000000000008" + const RESERVATION_ADDRESS = "0x0000000000000000000000000000000000000009" const BRIDGE_PROXY_ADDRESS = "0x5e4861a80B55f035D899f66772117F00FA0E8e7B" const REBATE_STAKING_PROXY_ADDRESS = "0x0184739c02d51bFc1cc2E3a2bF6bbBe31e265a45" @@ -45,6 +46,7 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { Wallets: WALLETS_ADDRESS, Fraud: FRAUD_ADDRESS, MovingFunds: MOVING_FUNDS_ADDRESS, + Reservation: RESERVATION_ADDRESS, Bridge: BRIDGE_PROXY_ADDRESS, RebateStaking: REBATE_STAKING_PROXY_ADDRESS, BridgeGovernance: BRIDGE_GOV_ADDRESS, @@ -229,6 +231,7 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { "Wallets", "Fraud", "MovingFunds", + "Reservation", ] const getNames = getCalls.map((c) => c.name) @@ -257,7 +260,7 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { expect(directBridgeCall).to.be.undefined }) - it("should define all 6 required libraries for Bridge implementation deployment", async () => { + it("should define all 7 required libraries for Bridge implementation deployment", async () => { await func(mockHre) const bridgeCall = deployCalls.find( @@ -275,9 +278,10 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { "Wallets", "Fraud", "MovingFunds", + "Reservation", ] const actualKeys = Object.keys(libraries) - expect(actualKeys).to.have.lengthOf(6) + expect(actualKeys).to.have.lengthOf(7) expectedLibKeys.forEach((key) => { expect(libraries).to.have.property(key) @@ -291,6 +295,7 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { expect(libraries.Wallets).to.equal(WALLETS_ADDRESS) expect(libraries.Fraud).to.equal(FRAUD_ADDRESS) expect(libraries.MovingFunds).to.equal(MOVING_FUNDS_ADDRESS) + expect(libraries.Reservation).to.equal(RESERVATION_ADDRESS) }) it("should deploy RebateStaking implementation with distinct artifact name", async () => { @@ -772,11 +777,11 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { }) }) - it("should have libraries with all 6 entries", () => { + it("should have libraries with all 7 entries", () => { expect(summary).to.not.be.null const libs = summary.libraries - expect(Object.keys(libs)).to.have.lengthOf(6) + expect(Object.keys(libs)).to.have.lengthOf(7) const requiredKeys = [ "Deposit", @@ -785,6 +790,7 @@ describe("Deploy Script 85: TIP-109 Governance Upgrade", () => { "Wallets", "Fraud", "MovingFunds", + "Reservation", ] requiredKeys.forEach((key) => { diff --git a/solidity/test/deploy/95_deploy_reservation_vault.test.ts b/solidity/test/deploy/95_deploy_reservation_vault.test.ts new file mode 100644 index 0000000000..d79e5a8028 --- /dev/null +++ b/solidity/test/deploy/95_deploy_reservation_vault.test.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { expect } from "chai" +import func from "../../deploy/95_deploy_reservation_vault" + +describe("Deploy Script 95: ReservationVault", () => { + const deployer = "0x1234567890123456789012345678901234567890" + const bank = "0x0000000000000000000000000000000000000001" + const tbtcVault = "0x0000000000000000000000000000000000000002" + const bridge = "0x0000000000000000000000000000000000000003" + const reservationVault = "0x0000000000000000000000000000000000000004" + + it("does not recursively execute the Bridge deployment", () => { + expect(func.dependencies).to.deep.equal(["Bank", "TBTCVault"]) + }) + + it("reads the existing Bridge address for the vault constructor", async () => { + const getCalls: string[] = [] + const deployCalls: Array<{ name: string; options: any }> = [] + const addresses: Record = { + Bank: bank, + TBTCVault: tbtcVault, + Bridge: bridge, + } + + const mockHre: any = { + deployments: { + get: async (name: string) => { + getCalls.push(name) + return { address: addresses[name] } + }, + deploy: async (name: string, options: any) => { + deployCalls.push({ name, options }) + return { address: reservationVault } + }, + }, + getNamedAccounts: async () => ({ deployer }), + helpers: { + etherscan: { + verify: async () => undefined, + }, + }, + network: { tags: {} }, + } + + await func(mockHre) + + expect(getCalls).to.deep.equal(["Bank", "TBTCVault", "Bridge"]) + expect(deployCalls).to.have.lengthOf(1) + expect(deployCalls[0]).to.deep.equal({ + name: "ReservationVault", + options: { + from: deployer, + args: [bank, tbtcVault, bridge], + log: true, + waitConfirmations: 1, + }, + }) + }) +}) diff --git a/solidity/test/fixtures/bridge.ts b/solidity/test/fixtures/bridge.ts index c61b000372..3a82532c42 100644 --- a/solidity/test/fixtures/bridge.ts +++ b/solidity/test/fixtures/bridge.ts @@ -135,6 +135,8 @@ export default async function bridgeFixture(): Promise<{ Fraud: (await helpers.contracts.getContract("Fraud")).address, MovingFunds: (await helpers.contracts.getContract("MovingFunds")) .address, + Reservation: (await helpers.contracts.getContract("Reservation")) + .address, }, }, proxyOpts: {