From 5e7378e728797fba33b07a8836d21fe021ac9cdf Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 15:24:05 -0400 Subject: [PATCH 01/22] feat(bridge): add UTXO reservation core Adds the Reservation library implementing segregated, in-kind-redeemable custody of deposited UTXOs. A deposit revealed with the designated reservation vault is anchored by the wallet -- a 1-input-1-output spend into a fresh wallet-controlled output with no refund path -- instead of being swept. Balance is credited gross only on the SPV proof of the anchor, mirroring the sweep's refund-disabling role while dropping its consolidating role. The registry tracks the anchor outpoint through re-anchor hops (wallet migration) until an in-kind reserved redemption burns the gross claim, or term+grace expiry lets the wallet dissolve the anchor into its main UTXO. All four lifecycle SPV proofs share one Bridge entry point (submitReservationProof) to preserve the EIP-170 margin; Bridge compiles to 23.9 KB with a runs=100 optimizer override (baseline 24.1 KB at runs=1000 with only 0.5 KB of headroom). Safety wiring: acceptance marks the deposit swept (blocking sweeps and enabling fraud-challenge defeats); consumed anchors are recorded in spentMainUTXOs so the existing defeat path recognizes them; sweeps targeting the reservation vault revert; wallets cannot finalize closing while custodying reservations; reserved redemptions pass the redemption watchtower isSafeRedemption gate and reuse the redemption-timeout slashing machinery. --- solidity/contracts/bridge/Bridge.sol | 196 +++ solidity/contracts/bridge/BridgeState.sol | 43 +- solidity/contracts/bridge/DepositSweep.sol | 11 + solidity/contracts/bridge/Reservation.sol | 1129 +++++++++++++++++ solidity/contracts/bridge/Wallets.sol | 8 + solidity/deploy/06_deploy_bridge.ts | 3 + .../deploy/80_upgrade_bridge_v2_DEPRECATED.ts | 2 + .../deploy/81_upgrade_bridge_v2_vault_fix.ts | 2 + ...eploy_rebate_and_prepare_txs_DEPRECATED.ts | 3 + ...bridge_repair_rebate_staking_DEPRECATED.ts | 4 + .../85_deploy_tip109_governance_upgrade.ts | 2 + solidity/deploy/86_deploy_tip109_hotfix.ts | 2 + solidity/hardhat.config.ts | 14 + solidity/test/fixtures/bridge.ts | 2 + 14 files changed, 1420 insertions(+), 1 deletion(-) create mode 100644 solidity/contracts/bridge/Reservation.sol diff --git a/solidity/contracts/bridge/Bridge.sol b/solidity/contracts/bridge/Bridge.sol index 028642ae34..d0ccb1c3cc 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,62 @@ 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 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 +824,88 @@ 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 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 +2220,60 @@ 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]; + } } 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/Reservation.sol b/solidity/contracts/bridge/Reservation.sol new file mode 100644 index 0000000000..1d224c21b9 --- /dev/null +++ b/solidity/contracts/bridge/Reservation.sol @@ -0,0 +1,1129 @@ +// 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 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" + ); + } + + require( + reservation.anchorAmount - reservation.redemptionTxMaxFee <= + outputValue && + outputValue <= reservation.anchorAmount, + "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 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" + ); + + require( + reservation.anchorAmount - newAnchorAmount <= + self.reservationTxMaxFee, + "Transaction fee is too high" + ); + + 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/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/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/hardhat.config.ts b/solidity/hardhat.config.ts index 06b95569d9..4aa551b5f8 100644 --- a/solidity/hardhat.config.ts +++ b/solidity/hardhat.config.ts @@ -73,6 +73,20 @@ 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. DRAFT NOTE: this trades runtime gas for code size; the + // 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/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: { From e9a63705ee1887be823505f2de4a817996ca7eb3 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 15:24:08 -0400 Subject: [PATCH 02/22] feat(vault): add ReservationVault Liability-side companion of the reservation core: receives the gross acceptance credit, mints TBTC gross to the reservation owner, and collects all protocol fees as explicit TBTC transfers (claims are never netted, so the surrendered claim always equals the sats earmarked on-chain). Draft fee schedule: 40 bps all-in at initiation (mint leg + first custody term), 20 bps per extension, 20 bps at redemption -- strictly dominating the pooled path's 20+20 bps round trip at every holding horizon. --- solidity/contracts/vault/ReservationVault.sol | 333 ++++++++++++++++++ .../deploy/95_deploy_reservation_vault.ts | 33 ++ 2 files changed, 366 insertions(+) create mode 100644 solidity/contracts/vault/ReservationVault.sol create mode 100644 solidity/deploy/95_deploy_reservation_vault.ts diff --git a/solidity/contracts/vault/ReservationVault.sol b/solidity/contracts/vault/ReservationVault.sol new file mode 100644 index 0000000000..0f08b8dc89 --- /dev/null +++ b/solidity/contracts/vault/ReservationVault.sol @@ -0,0 +1,333 @@ +// 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. +/// +/// 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. + 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; + + // Draft fee schedule from the UTXO reservation design: + // 40 bps all-in at initiation (20 bps mint leg + first-year + // custody), 20 bps per extension year, 20 bps at redemption. The + // reserved lane thereby strictly dominates the pooled path's + // 20 bps + 20 bps round trip at every holding horizon. + 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"); + + address treasury = bridge.treasury(); + + for (uint256 i = 0; i < depositors.length; i++) { + uint256 satAmount = depositedAmounts[i]; + uint256 grossTbtc = satAmount * SATOSHI_MULTIPLIER; + + // Convert the Bank balance credited by the Bridge into TBTC + // minted to this vault. + bank.approveBalance(address(tbtcVault), satAmount); + tbtcVault.mint(grossTbtc); + + uint256 fee = (grossTbtc * initiationFeeBps) / BASIS_POINTS; + if (fee > 0) { + IERC20(tbtcToken).safeTransfer(treasury, fee); + } + IERC20(tbtcToken).safeTransfer(depositors[i], grossTbtc - fee); + + // slither-disable-next-line reentrancy-events + emit ReservationCreditProcessed(depositors[i], satAmount, fee); + } + } + + /// @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). + /// @dev Requirements: + /// - The caller must be the reservation owner, + /// - 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 + ) 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; + + 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 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/95_deploy_reservation_vault.ts b/solidity/deploy/95_deploy_reservation_vault.ts new file mode 100644 index 0000000000..3216150843 --- /dev/null +++ b/solidity/deploy/95_deploy_reservation_vault.ts @@ -0,0 +1,33 @@ +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. Mark the vault as trusted: `Bridge.setVaultStatus(vault, true)`, + // 2. Wire it as the reservation vault and set the parameters: + // `Bridge.updateReservationParameters(vault, ...)`. + // 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", "Bridge"] From 9f55b97622eb0d22397454ab19e916cde9d7507b Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 15:24:09 -0400 Subject: [PATCH 03/22] test: cover reservation lifecycle entry points Covers parameter governance, the reserved-deposit sweep guard (recorded sweep proof against a reservation-routed deposit), term extension, reserved redemption request/timeout bookkeeping with wallet-slashing reuse, and the vault's gross-mint fee split and redemption surrender flow. The SPV proof validators (acceptance, redemption, re-anchor, dissolution) are exercised only up to proof validation and need Bitcoin-fixture coverage as a follow-up. --- solidity/contracts/test/BridgeStub.sol | 20 + .../test/bridge/Bridge.Reservation.test.ts | 562 ++++++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 solidity/test/bridge/Bridge.Reservation.test.ts 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/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts new file mode 100644 index 0000000000..45e9b1f97b --- /dev/null +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -0,0 +1,562 @@ +/* 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, + 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 reservationVault: ReservationVault + let bridgeGovernanceSigner: SignerWithAddress + + before(async () => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ + deployer, + governance, + spvMaintainer, + thirdParty, + treasury, + bank, + relay, + bridge, + 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, + } + } + + 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) + ).to.be.revertedWith("Caller is not the reservation owner") + }) + + it("should surrender gross TBTC, charge the 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) + + 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) + }) + }) + }) +}) From 279fd371d645a32d80c66f02795da05da7381ff3 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:01:36 -0400 Subject: [PATCH 04/22] fix(ci): register the Reservation library in deploy and test link maps The deploy-script-85 unit test mocks the deployments registry and the rebate recovery test links BridgeStub libraries explicitly; both needed to learn about the new Reservation library. --- solidity/test/bridge/Bridge.RebateRecovery.test.ts | 2 ++ .../85_deploy_tip109_governance_upgrade.test.ts | 14 ++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/solidity/test/bridge/Bridge.RebateRecovery.test.ts b/solidity/test/bridge/Bridge.RebateRecovery.test.ts index 3b6ab48538..d45effb551 100644 --- a/solidity/test/bridge/Bridge.RebateRecovery.test.ts +++ b/solidity/test/bridge/Bridge.RebateRecovery.test.ts @@ -46,6 +46,8 @@ 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/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) => { From bb8dc718c498a5d6d5c27725ee488cf01fb6b8dd Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:01:37 -0400 Subject: [PATCH 05/22] feat(vault): single-mint credit processing and a Bank-balance retry path Hoists the TBTCVault mint out of the per-depositor loop (one aggregate mint, per-depositor transfers), which also resolves the Slither calls-inside-a-loop findings. Adds retryRedeemReservation so an owner left holding Bank balance by a reserved-redemption timeout can re-request without manually re-minting TBTC. --- solidity/contracts/vault/ReservationVault.sol | 93 ++++++++++++++++--- 1 file changed, 81 insertions(+), 12 deletions(-) diff --git a/solidity/contracts/vault/ReservationVault.sol b/solidity/contracts/vault/ReservationVault.sol index 0f08b8dc89..ab3267fc30 100644 --- a/solidity/contracts/vault/ReservationVault.sol +++ b/solidity/contracts/vault/ReservationVault.sol @@ -174,25 +174,35 @@ contract ReservationVault is IVault, Ownable { ) external override onlyBank { require(depositors.length != 0, "No depositors specified"); - address treasury = bridge.treasury(); + uint256 totalSat = 0; + for (uint256 i = 0; i < depositedAmounts.length; i++) { + totalSat += depositedAmounts[i]; + } - for (uint256 i = 0; i < depositors.length; i++) { - uint256 satAmount = depositedAmounts[i]; - uint256 grossTbtc = satAmount * SATOSHI_MULTIPLIER; + // 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); - // Convert the Bank balance credited by the Bridge into TBTC - // minted to this vault. - bank.approveBalance(address(tbtcVault), satAmount); - tbtcVault.mint(grossTbtc); + uint256 totalFee = 0; + for (uint256 i = 0; i < depositors.length; i++) { + uint256 grossTbtc = depositedAmounts[i] * SATOSHI_MULTIPLIER; uint256 fee = (grossTbtc * initiationFeeBps) / BASIS_POINTS; - if (fee > 0) { - IERC20(tbtcToken).safeTransfer(treasury, fee); - } + totalFee += fee; + IERC20(tbtcToken).safeTransfer(depositors[i], grossTbtc - fee); // slither-disable-next-line reentrancy-events - emit ReservationCreditProcessed(depositors[i], satAmount, fee); + emit ReservationCreditProcessed( + depositors[i], + depositedAmounts[i], + fee + ); + } + + if (totalFee > 0) { + IERC20(tbtcToken).safeTransfer(bridge.treasury(), totalFee); } } @@ -293,6 +303,65 @@ contract ReservationVault is IVault, Ownable { 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 caller must have approved this vault for the redemption + /// fee in TBTC. + 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; + uint256 fee = (grossTbtc * redemptionFeeBps) / BASIS_POINTS; + if (fee > 0) { + IERC20(tbtcToken).safeTransferFrom( + msg.sender, + bridge.treasury(), + fee + ); + } + + 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, + fee + ); + + bridge.requestReservedRedemption( + reservationKey, + msg.sender, + redeemerOutputScript + ); + } + /// @notice Updates the vault fee parameters. /// @dev Requirements: /// - The caller must be the vault owner (governance), From 37cbfa5271f1a661b341860529fc7a6e63180ae8 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:01:37 -0400 Subject: [PATCH 06/22] feat(bridge): reserved redemption watchtower veto Mirrors Redemption.notifyRedemptionVeto for reserved redemptions: the watchtower detains the surrendered gross balance, the pending request is cleared, and the reservation returns to Active -- the anchor outpoint was not spent, so the in-kind claim survives. The guardian-side flow in the RedemptionWatchtower contract is a follow-up; this is the Bridge hook it needs. --- solidity/contracts/bridge/Bridge.sol | 13 +++++++ solidity/contracts/bridge/Reservation.sol | 45 +++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/solidity/contracts/bridge/Bridge.sol b/solidity/contracts/bridge/Bridge.sol index d0ccb1c3cc..901076dc20 100644 --- a/solidity/contracts/bridge/Bridge.sol +++ b/solidity/contracts/bridge/Bridge.sol @@ -140,6 +140,8 @@ contract Bridge is bytes20 indexed walletPubKeyHash ); + event ReservedRedemptionVetoed(uint256 indexed reservationKey); + event ReservationReanchored( uint256 indexed reservationKey, bytes20 indexed newWalletPubKeyHash, @@ -906,6 +908,17 @@ contract Bridge is 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 diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index 1d224c21b9..4253eb18d1 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -171,6 +171,8 @@ library Reservation { bytes20 indexed walletPubKeyHash ); + event ReservedRedemptionVetoed(uint256 indexed reservationKey); + event ReservationReanchored( uint256 indexed reservationKey, bytes20 indexed newWalletPubKeyHash, @@ -707,6 +709,49 @@ library Reservation { 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 From 9b60336038b0b1da3292066ed9ee5f80c49fef63 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:01:38 -0400 Subject: [PATCH 07/22] feat(governance): governance-delayed reservation parameters update Adds a grouped begin/finalize pair to BridgeGovernance staging all reservation parameters (including the reservation vault) behind the standard governance delay, applied atomically via the Bridge's single updateReservationParameters call. --- .../contracts/bridge/BridgeGovernance.sol | 45 +++++++++++ .../bridge/BridgeGovernanceParameters.sol | 74 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/solidity/contracts/bridge/BridgeGovernance.sol b/solidity/contracts/bridge/BridgeGovernance.sol index b1a6f206aa..d94d1d7682 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; @@ -1807,4 +1809,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; + } } From d39a413373b227b8fbafe07bb728bcb1b9d72108 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:01:39 -0400 Subject: [PATCH 08/22] test: Bitcoin SPV fixture coverage for reservation proofs Crafts structurally-valid Bitcoin transactions and regtest-difficulty headers in pure TypeScript (SPV validation checks structure, merkle inclusion, and header work -- not script signatures) and exercises all four reservation proof validators end to end: anchor acceptance with gross vault credit, in-kind redemption with gross burn, re-anchoring to a second wallet, and post-grace dissolution into the main UTXO. Also covers the watchtower veto, the vault retry path, and the governance-delayed parameters flow. --- .../test/bridge/Bridge.Reservation.test.ts | 642 ++++++++++++++++++ 1 file changed, 642 insertions(+) diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index 45e9b1f97b..33a2747bd6 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -9,6 +9,7 @@ import type { Bank, BankStub, Bridge, + BridgeGovernance, BridgeStub, IRelay, ReservationVault, @@ -49,6 +50,7 @@ describe("Bridge - Reservation", () => { let bridge: Bridge & BridgeStub let tbtc: TBTC & Contract let tbtcVault: TBTCVault & Contract + let bridgeGovernance: BridgeGovernance let reservationVault: ReservationVault let bridgeGovernanceSigner: SignerWithAddress @@ -63,6 +65,7 @@ describe("Bridge - Reservation", () => { bank, relay, bridge, + bridgeGovernance, tbtc, tbtcVault, } = await waffle.loadFixture(bridgeFixture)) @@ -559,4 +562,643 @@ describe("Bridge - Reservation", () => { }) }) }) + + 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", async () => { + const fee = grossTbtc.mul(20).div(10000) + await bank + .connect(thirdParty) + .approveBalance(reservationVault.address, amountSat) + await tbtc.connect(thirdParty).approve(reservationVault.address, fee) + + 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) + }) + }) + + 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 () => { + 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 + ) + + 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("reservation SPV proofs", () => { + // ---- 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)}` + + // ---- 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) + 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("re-anchors a reservation to another Live wallet", async () => { + const { anchorTx, reservationKey } = await makeAcceptedReservation() + await liveWallet(secondWalletPubKeyHash) + + const reanchorFee = 500 + const reanchorTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [ + { + valueSat: anchorAmount.sub(reanchorFee), + 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, + anchorAmount.sub(reanchorFee) + ) + + const reservation = await bridge.reservations(reservationKey) + expect(reservation.walletPubKeyHash).to.equal(secondWalletPubKeyHash) + expect(reservation.anchorTxHash).to.equal(reanchorTx.txHash) + expect(reservation.anchorAmount).to.equal(anchorAmount.sub(reanchorFee)) + // The gross claim is unchanged by in-kind re-anchor fees. + expect(reservation.mintedAmount).to.equal(anchorAmount) + }) + + 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)] + ) + ) + }) + }) }) From 06eff3c65c5719f845afdef5f7d5b94244f7bd8a Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:17:15 -0400 Subject: [PATCH 09/22] fix(ci): satisfy prettier in the RebateRecovery library map --- solidity/test/bridge/Bridge.RebateRecovery.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/solidity/test/bridge/Bridge.RebateRecovery.test.ts b/solidity/test/bridge/Bridge.RebateRecovery.test.ts index d45effb551..e83eaf5386 100644 --- a/solidity/test/bridge/Bridge.RebateRecovery.test.ts +++ b/solidity/test/bridge/Bridge.RebateRecovery.test.ts @@ -46,8 +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, + Reservation: (await helpers.contracts.getContract("Reservation")).address, } const bridgeFactory = await ethers.getContractFactory("BridgeStub", { From bc367b0b0f80000d04ecd4395353b163a352f2d6 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:17:16 -0400 Subject: [PATCH 10/22] feat(watchtower): guardian objection flow for reserved redemptions Adds raiseReservedObjection mirroring raiseObjection for pending reserved redemptions, reusing the existing VetoProposal/objections storage keyed by reservation key -- no storage layout changes, so the watchtower upgrade is layout-safe. Three objections finalize the veto: the surrendered gross amount is detained via the Bridge's notifyReservedRedemptionVeto hook, the penalty fee is burned, the redeemer is banned, and the reservation returns to Active on the Bridge. Also exposes getReservedRedemptionDelay for wallet-side coordination and extends the IRedemptionWatchtower interface accordingly. --- solidity/contracts/bridge/Redemption.sol | 9 ++ .../contracts/bridge/RedemptionWatchtower.sol | 120 ++++++++++++++++++ 2 files changed, 129 insertions(+) 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. From 01a9aa85562ffc0aeebf7759734da2f4d9eae5b5 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:17:17 -0400 Subject: [PATCH 11/22] feat(validator): reservation proposal validation Teaches WalletProposalValidator the reservation lifecycle: rejects sweep proposals containing reservation-vault deposits, and adds proposal validation for anchors (deposit eligibility, fee bounds, minimum anchor amount, refund safety margin), reserved redemptions (pending state, watchtower delay, timeout safety margin, snapshotted fee bound), re-anchors (Active state, Live target) and dissolutions (term + grace elapsed). Restores the grouped Bridge.reservationParameters() getter the validator consumes; Bridge stays under the EIP-170 limit (24.175 KB). --- solidity/contracts/bridge/Bridge.sol | 25 ++ .../bridge/WalletProposalValidator.sol | 354 ++++++++++++++++++ 2 files changed, 379 insertions(+) diff --git a/solidity/contracts/bridge/Bridge.sol b/solidity/contracts/bridge/Bridge.sol index 901076dc20..31a7a4d901 100644 --- a/solidity/contracts/bridge/Bridge.sol +++ b/solidity/contracts/bridge/Bridge.sol @@ -2289,4 +2289,29 @@ contract Bridge is { 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/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" + ); + } } From 5811a8f4a252f7d86b9a34ada78f4aeeac8a3bc9 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:17:17 -0400 Subject: [PATCH 12/22] test: watchtower veto e2e and proposal validator coverage Covers the full guardian objection flow (three objections, veto finalization through the Bridge hook, 100% penalty burn, redeemer ban blocking re-requests via the isSafeRedemption gate) and the validator's sweep exclusion, anchor, reserved redemption, re-anchor and dissolution proposal rules. --- .../test/bridge/Bridge.Reservation.test.ts | 531 ++++++++++++++---- 1 file changed, 430 insertions(+), 101 deletions(-) diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index 33a2747bd6..048c637ead 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -137,6 +137,123 @@ describe("Bridge - Reservation", () => { } } + // ---- 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() @@ -755,124 +872,336 @@ describe("Bridge - Reservation", () => { }) }) - describe("reservation SPV proofs", () => { - // ---- 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)) - ) + 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 + if ( + !(await redemptionWatchtower.isGuardian(guardianSigners[i].address)) + ) { + // 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 reverseHex = (hex: string): string => - hex.replace(/^0x/, "").match(/../g)!.reverse().join("") + const ValidatorFactory = await ethers.getContractFactory( + "WalletProposalValidator" + ) + validator = await ValidatorFactory.deploy(bridge.address) - const hash256 = (hexData: string): string => - ethers.utils.sha256(ethers.utils.sha256(hexData)) + await bridge.setDepositDustThreshold(10000) + await bridge.setDepositTxMaxFee(2000) + await bridge.setDepositRevealAheadPeriod(0) - const toLE = (value: number | BigNumber, byteLength: number): string => - reverseHex( - BigNumber.from(value) - .toHexString() - .slice(2) - .padStart(byteLength * 2, "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, + }) - const compactSize = (n: number): string => { - if (n >= 0xfd) { - throw new Error("compactSize > 252 not supported in fixtures") + depositKey = { fundingTxHash: fundingTx.txHash, fundingOutputIndex: 0 } + depositExtraInfo = { + fundingTx: fundingTx.info, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime: futureRefundLocktime, } - return n.toString(16).padStart(2, "0") - } - interface FixtureTxIn { - txHash: string - index: number - } + // Let the deposit exceed DEPOSIT_MIN_AGE (2 hours). + await increaseTime(7300) + }) - interface FixtureTxOut { - valueSat: BigNumber | number - script: string // raw script hex, no 0x, no length prefix - } + after(async () => { + await restoreSnapshot() + }) - 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}` + it("rejects sweep proposals containing reserved deposits", async () => { + await expect( + validator.validateDepositSweepProposal( + { + walletPubKeyHash, + depositsKeys: [depositKey], + sweepTxFee: 1500, + depositsRevealBlocks: [0], + }, + [depositExtraInfo] ) - .join("")}` - const info = { - version: "0x01000000", - inputVector, - outputVector, - locktime: "0x00000000", - } - const txHash = hash256( - `0x01000000${inputVector.slice(2)}${outputVector.slice(2)}00000000` + ).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) ) - 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 - } - } - } + // 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 - // 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, - } - } + // Dissolution: rejected before term + grace elapse. + await expect( + validator.validateReservationDissolutionProposal({ + walletPubKeyHash, + reservationKey, + dissolutionTxFee: 1500, + }) + ).to.be.revertedWith("Reservation term or grace period not elapsed") - 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` + // 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) - const p2wshScript = (script: string): string => - `0020${ethers.utils.sha256(`0x${script}`).slice(2)}` + expect( + await validator.validateReservedRedemptionProposal({ + walletPubKeyHash, + reservationKey, + redemptionTxFee: 1500, + }) + ).to.be.true - const p2wpkhScript = (pkh: string): string => `0014${pkh.slice(2)}` + 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" From d2fde116067079c7e7bb3c81140f9de0e1392427 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:34:49 -0400 Subject: [PATCH 13/22] fix(ci): hoist guardian check out of the loop condition Prettier reflowed the multi-line if condition, detaching the no-await-in-loop disable comment from the awaited call. --- solidity/test/bridge/Bridge.Reservation.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index 048c637ead..54141fffcb 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -906,9 +906,10 @@ describe("Bridge - Reservation", () => { ) for (let i = 0; i < guardianSigners.length; i++) { // eslint-disable-next-line no-await-in-loop - if ( - !(await redemptionWatchtower.isGuardian(guardianSigners[i].address)) - ) { + const alreadyGuardian = await redemptionWatchtower.isGuardian( + guardianSigners[i].address + ) + if (!alreadyGuardian) { // eslint-disable-next-line no-await-in-loop await redemptionWatchtower .connect(manager) From a991488e96a775e2a2be65dececb98517be0896b Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 18:48:45 -0400 Subject: [PATCH 14/22] feat(vault): make redemption free by default Adopts the custody-style pay-to-hold schedule: 40 bps all-in at initiation (mint leg + first-year custody), 20 bps per extension year, free redemption. Never cheaper than the pooled 20+20 bps round trip (parity at a one-year hold, premium beyond), exit-neutral where the product's in-kind promise lives, and it removes the fee re-charge on retries after wallet-fault timeouts. The redemptionFeeBps parameter is retained for governance; the minimum reservation size -- not the fee schedule -- is the dial that keeps carry covering per-position lifecycle costs as FROST lowers ceremony economics. --- solidity/contracts/vault/ReservationVault.sol | 18 ++++++++++++------ .../test/bridge/Bridge.Reservation.test.ts | 9 +++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/solidity/contracts/vault/ReservationVault.sol b/solidity/contracts/vault/ReservationVault.sol index ab3267fc30..354ded0af1 100644 --- a/solidity/contracts/vault/ReservationVault.sol +++ b/solidity/contracts/vault/ReservationVault.sol @@ -59,7 +59,8 @@ interface IReservationBridge { /// - 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. +/// - redemption: free by default; the parameter is retained for +/// governance. /// /// Wiring requirements: governance must mark this vault as trusted /// in the Bridge (`setVaultStatus`) so deposits can be revealed with @@ -93,7 +94,9 @@ contract ReservationVault is IVault, Ownable { /// 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. + /// when the in-kind redemption is requested. Zero by default: + /// redemption is exit-neutral, custody is paid for via the + /// initiation and extension fees. uint16 public redemptionFeeBps; event ReservationCreditProcessed( @@ -151,12 +154,15 @@ contract ReservationVault is IVault, Ownable { // Draft fee schedule from the UTXO reservation design: // 40 bps all-in at initiation (20 bps mint leg + first-year - // custody), 20 bps per extension year, 20 bps at redemption. The - // reserved lane thereby strictly dominates the pooled path's - // 20 bps + 20 bps round trip at every holding horizon. + // custody), 20 bps per extension year, free redemption -- + // custody-style pay-to-hold pricing that is never cheaper than the + // pooled path's 20 bps + 20 bps round trip (parity at a one-year + // hold, premium beyond). 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; + redemptionFeeBps = 0; } /// @notice Called by the Bank when the Bridge proves a reservation's diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index 54141fffcb..3aa52a5c74 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -654,8 +654,9 @@ describe("Bridge - Reservation", () => { ).to.be.revertedWith("Caller is not the reservation owner") }) - it("should surrender gross TBTC, charge the fee, and register the redemption", async () => { - const fee = grossTbtc.mul(20).div(10000) + it("should surrender gross TBTC and register the redemption", async () => { + // Redemption is free by default; the exit leg is exit-neutral. + const fee = BigNumber.from(0) const treasuryBalanceBefore = await tbtc.balanceOf(treasury.address) await tbtc @@ -804,7 +805,7 @@ describe("Bridge - Reservation", () => { }) it("retries the redemption from Bank balance", async () => { - const fee = grossTbtc.mul(20).div(10000) + const fee = BigNumber.from(0) await bank .connect(thirdParty) .approveBalance(reservationVault.address, amountSat) @@ -1399,7 +1400,7 @@ describe("Bridge - Reservation", () => { // balance held by the Bridge. const supplyBefore = await tbtc.totalSupply() - const redemptionFee = grossTbtc.mul(20).div(10000) + const redemptionFee = BigNumber.from(0) await tbtc .connect(thirdParty) .approve(reservationVault.address, grossTbtc.add(redemptionFee)) From fdf7c7bebd738580cdea7a876090b841c9bbc04e Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 18:54:02 -0400 Subject: [PATCH 15/22] feat(vault): settle the fee schedule at 40/20/20 with endpoint parity 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 redemption fee is not re-charged on retries after wallet-fault timeouts: it was collected by the original request, and the retry only exists because the wallet failed. --- solidity/contracts/vault/ReservationVault.sol | 54 ++++++++++--------- .../test/bridge/Bridge.Reservation.test.ts | 16 +++--- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/solidity/contracts/vault/ReservationVault.sol b/solidity/contracts/vault/ReservationVault.sol index 354ded0af1..fd959c4586 100644 --- a/solidity/contracts/vault/ReservationVault.sol +++ b/solidity/contracts/vault/ReservationVault.sol @@ -59,8 +59,9 @@ interface IReservationBridge { /// - initiation: charged when the acceptance credit is processed; /// covers the mint leg and the first custody term, /// - extension: charged per custody term extension, -/// - redemption: free by default; the parameter is retained for -/// governance. +/// - 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 @@ -94,9 +95,9 @@ contract ReservationVault is IVault, Ownable { /// 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. Zero by default: - /// redemption is exit-neutral, custody is paid for via the - /// initiation and extension fees. + /// 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( @@ -152,17 +153,18 @@ contract ReservationVault is IVault, Ownable { tbtcToken = _tbtcVault.tbtcToken(); bridge = _bridge; - // Draft fee schedule from the UTXO reservation design: - // 40 bps all-in at initiation (20 bps mint leg + first-year - // custody), 20 bps per extension year, free redemption -- - // custody-style pay-to-hold pricing that is never cheaper than the - // pooled path's 20 bps + 20 bps round trip (parity at a one-year - // hold, premium beyond). The minimum reservation size, not this - // schedule, is the governance dial that keeps the carry fee - // covering per-position lifecycle costs. + // 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 = 0; + redemptionFeeBps = 20; } /// @notice Called by the Bank when the Bridge proves a reservation's @@ -320,9 +322,12 @@ contract ReservationVault is IVault, Ownable { /// @dev Requirements: /// - The caller must be the reservation owner, /// - The caller must have approved this vault in the Bank for the - /// gross minted amount (`Bank.approveBalance`), - /// - The caller must have approved this vault for the redemption - /// fee in TBTC. + /// 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 @@ -337,14 +342,11 @@ contract ReservationVault is IVault, Ownable { uint256 grossTbtc = uint256(reservation.mintedAmount) * SATOSHI_MULTIPLIER; - uint256 fee = (grossTbtc * redemptionFeeBps) / BASIS_POINTS; - if (fee > 0) { - IERC20(tbtcToken).safeTransferFrom( - msg.sender, - bridge.treasury(), - fee - ); - } + + // 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, @@ -358,7 +360,7 @@ contract ReservationVault is IVault, Ownable { reservationKey, msg.sender, grossTbtc, - fee + 0 ); bridge.requestReservedRedemption( diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index 3aa52a5c74..1762aa1ba2 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -654,9 +654,8 @@ describe("Bridge - Reservation", () => { ).to.be.revertedWith("Caller is not the reservation owner") }) - it("should surrender gross TBTC and register the redemption", async () => { - // Redemption is free by default; the exit leg is exit-neutral. - const fee = BigNumber.from(0) + 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 @@ -804,12 +803,11 @@ describe("Bridge - Reservation", () => { await restoreSnapshot() }) - it("retries the redemption from Bank balance", async () => { - const fee = BigNumber.from(0) + it("retries the redemption from Bank balance without re-charging the fee", async () => { await bank .connect(thirdParty) .approveBalance(reservationVault.address, amountSat) - await tbtc.connect(thirdParty).approve(reservationVault.address, fee) + const treasuryBalanceBefore = await tbtc.balanceOf(treasury.address) const tx = await reservationVault .connect(thirdParty) @@ -818,6 +816,10 @@ describe("Bridge - Reservation", () => { 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 + ) }) }) @@ -1400,7 +1402,7 @@ describe("Bridge - Reservation", () => { // balance held by the Bridge. const supplyBefore = await tbtc.totalSupply() - const redemptionFee = BigNumber.from(0) + const redemptionFee = grossTbtc.mul(20).div(10000) await tbtc .connect(thirdParty) .approve(reservationVault.address, grossTbtc.add(redemptionFee)) From cc43ec1dce6ab1833a4f68336e0c4881956731fb Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 23:56:45 -0400 Subject: [PATCH 16/22] fix(reservation): guard redemption-range underflow and floor re-anchors Two correctness fixes surfaced by external review of the proof validators: 1. Redemption liveness. The redemption range check subtracted redemptionTxMaxFee (a governable parameter, not a tx-derived value) from anchorAmount, which reverts on underflow in Solidity 0.8 when the fee bound exceeds the anchor -- reachable via a governance fee increase against an existing anchor. That would make every redemption proof for the affected reservation revert, stranding the coins until dissolution. Reformulated as the underflow-safe equivalent (outputValue <= anchorAmount && anchorAmount - outputValue <= maxFee). 2. Re-anchor floor. Re-anchors bounded only the per-hop miner fee, never the resulting anchor, so repeated network-scheduled hops could grind the anchor toward the fee bound. Now require the re-anchored amount to stay >= reservationMinAmount; since the minimum is required to exceed the tx max fee, this also keeps the anchor clear of the redemption fee bound, reinforcing fix (1) across migrations. Adds regression tests for both. --- solidity/contracts/bridge/Reservation.sol | 28 +++- .../test/bridge/Bridge.Reservation.test.ts | 131 ++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index 4253eb18d1..55e1129b3d 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -638,10 +638,19 @@ library Reservation { ); } + // 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( - reservation.anchorAmount - reservation.redemptionTxMaxFee <= - outputValue && - outputValue <= reservation.anchorAmount, + outputValue <= reservation.anchorAmount && + reservation.anchorAmount - outputValue <= + reservation.redemptionTxMaxFee, "Output value is not within the acceptable range" ); @@ -801,12 +810,25 @@ library Reservation { "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" ); + // Floor the anchor at the reservation minimum so that repeated, + // network-scheduled re-anchor hops cannot grind the anchored amount + // down toward the miner fee. Because `reservationMinAmount` is + // required to exceed `reservationTxMaxFee`, this also keeps the + // anchor safely above the redemption fee bound, preserving + // redemption liveness across migrations. + require( + newAnchorAmount >= self.reservationMinAmount, + "Re-anchor amount below the reservation minimum" + ); + bytes20 oldWalletPubKeyHash = reservation.walletPubKeyHash; if (oldWalletPubKeyHash != newWalletPubKeyHash) { self.walletReservationsCount[oldWalletPubKeyHash] -= 1; diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index 1762aa1ba2..d1a0bd1d58 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -1439,6 +1439,137 @@ describe("Bridge - Reservation", () => { 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) + + // 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("rejects a re-anchor that drops below the reservation minimum", async () => { + // Accept a reservation whose anchor sits at the reservation minimum, + // so a single fee-capped hop can push it below the floor. + 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) + + // Fee of 1 sat keeps the hop within the per-hop cap, but the resulting + // anchor (min - 1) sits below the reservation minimum. + const belowMin = minAmount.sub(1) + const reanchorTx = buildTx( + [{ txHash: anchorTx.txHash, index: 0 }], + [{ valueSat: belowMin, script: p2wpkhScript(secondWalletPubKeyHash) }] + ) + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Reanchor, + reanchorTx.info, + proofFor(reanchorTx.txHash), + NO_MAIN_UTXO_PARAM, + reservationKey + ) + ).to.be.revertedWith("Re-anchor amount below the reservation minimum") + }) + it("re-anchors a reservation to another Live wallet", async () => { const { anchorTx, reservationKey } = await makeAcceptedReservation() await liveWallet(secondWalletPubKeyHash) From 656d32342dcfab1a50193274c39595940a082ea8 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 8 Aug 2026 07:45:10 -0400 Subject: [PATCH 17/22] docs(config): record measured ~0 runtime-gas cost of the Bridge runs=100 override A runs=1000-vs-100 gas diff over the deposit/redemption/reservation hot paths measures at 0-8 gas (noise): the Bridge is a dispatch shell over linked libraries that stay at runs=1000, so the size-preserving override carries no meaningful runtime cost. --- solidity/hardhat.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/solidity/hardhat.config.ts b/solidity/hardhat.config.ts index 4aa551b5f8..435f93d320 100644 --- a/solidity/hardhat.config.ts +++ b/solidity/hardhat.config.ts @@ -75,7 +75,11 @@ const config: HardhatUserConfig = { "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. DRAFT NOTE: this trades runtime gas for code size; the + // 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": { From 9120328b1f741393a08b6ff44cd6a5c9402d78f8 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 8 Aug 2026 08:09:44 -0400 Subject: [PATCH 18/22] fix(reservation): relax re-anchor floor to a dust floor (H-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External review found that the previous `>= reservationMinAmount` re-anchor floor, combined with the proposal validator's positive-fee requirement, left no compliant re-anchor for an exactly-minimum-sized reservation — pinning a retiring wallet, which contradicts mandatory migration. The floor's redemption-liveness purpose is already covered by the underflow-safe redemption range check, so relax it to a dust floor (`> reservationTxMaxFee`) that keeps anchors clear of dust while leaving minimum-sized reservations migratable. Bounding cumulative Byzantine re-anchor grinding is deferred to the authorized-action model (migration request with nonce + owner/target authorization + cumulative fee budget), tracked as a follow-up. Regression test asserts a minimum-sized reservation migrates. --- solidity/contracts/bridge/Reservation.sol | 21 ++++--- .../test/bridge/Bridge.Reservation.test.ts | 55 ++++--------------- 2 files changed, 24 insertions(+), 52 deletions(-) diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index 55e1129b3d..14d52142c6 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -818,15 +818,20 @@ library Reservation { "Transaction fee is too high" ); - // Floor the anchor at the reservation minimum so that repeated, - // network-scheduled re-anchor hops cannot grind the anchored amount - // down toward the miner fee. Because `reservationMinAmount` is - // required to exceed `reservationTxMaxFee`, this also keeps the - // anchor safely above the redemption fee bound, preserving - // redemption liveness across migrations. + // 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.reservationMinAmount, - "Re-anchor amount below the reservation minimum" + newAnchorAmount > self.reservationTxMaxFee, + "Re-anchor amount below the dust floor" ); bytes20 oldWalletPubKeyHash = reservation.walletPubKeyHash; diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index d1a0bd1d58..b68fef53c3 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -1493,9 +1493,13 @@ describe("Bridge - Reservation", () => { .withArgs(reservationKey, redemptionTx.txHash) }) - it("rejects a re-anchor that drops below the reservation minimum", async () => { - // Accept a reservation whose anchor sits at the reservation minimum, - // so a single fee-capped hop can push it below the floor. + 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( @@ -1550,41 +1554,12 @@ describe("Bridge - Reservation", () => { await liveWallet(secondWalletPubKeyHash) - // Fee of 1 sat keeps the hop within the per-hop cap, but the resulting - // anchor (min - 1) sits below the reservation minimum. - const belowMin = minAmount.sub(1) + // 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: belowMin, script: p2wpkhScript(secondWalletPubKeyHash) }] + [{ valueSat: migrated, script: p2wpkhScript(secondWalletPubKeyHash) }] ) - await expect( - bridge - .connect(spvMaintainer) - .submitReservationProof( - ProofType.Reanchor, - reanchorTx.info, - proofFor(reanchorTx.txHash), - NO_MAIN_UTXO_PARAM, - reservationKey - ) - ).to.be.revertedWith("Re-anchor amount below the reservation minimum") - }) - - it("re-anchors a reservation to another Live wallet", async () => { - const { anchorTx, reservationKey } = await makeAcceptedReservation() - await liveWallet(secondWalletPubKeyHash) - - const reanchorFee = 500 - const reanchorTx = buildTx( - [{ txHash: anchorTx.txHash, index: 0 }], - [ - { - valueSat: anchorAmount.sub(reanchorFee), - script: p2wpkhScript(secondWalletPubKeyHash), - }, - ] - ) - const tx = await bridge .connect(spvMaintainer) .submitReservationProof( @@ -1594,22 +1569,14 @@ describe("Bridge - Reservation", () => { NO_MAIN_UTXO_PARAM, reservationKey ) - await expect(tx) .to.emit(bridge, "ReservationReanchored") .withArgs( reservationKey, secondWalletPubKeyHash, reanchorTx.txHash, - anchorAmount.sub(reanchorFee) + migrated ) - - const reservation = await bridge.reservations(reservationKey) - expect(reservation.walletPubKeyHash).to.equal(secondWalletPubKeyHash) - expect(reservation.anchorTxHash).to.equal(reanchorTx.txHash) - expect(reservation.anchorAmount).to.equal(anchorAmount.sub(reanchorFee)) - // The gross claim is unchanged by in-kind re-anchor fees. - expect(reservation.mintedAmount).to.equal(anchorAmount) }) it("dissolves an expired reservation into the wallet main UTXO", async () => { From 5478695136fc2ca6bb34e267ee59e544432d0fb6 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 9 Aug 2026 21:33:56 -0400 Subject: [PATCH 19/22] fix(reservation): unblock core deployment workflows --- .../contracts/bridge/BridgeGovernance.sol | 11 +++ .../deploy/95_deploy_reservation_vault.ts | 9 +- .../test/bridge/Bridge.Reservation.test.ts | 20 +++- .../test/bridge/Bridge.StorageLayout.test.ts | 96 +++++++++++++++++++ .../95_deploy_reservation_vault.test.ts | 60 ++++++++++++ 5 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 solidity/test/bridge/Bridge.StorageLayout.test.ts create mode 100644 solidity/test/deploy/95_deploy_reservation_vault.test.ts diff --git a/solidity/contracts/bridge/BridgeGovernance.sol b/solidity/contracts/bridge/BridgeGovernance.sol index d94d1d7682..d4a9593f80 100644 --- a/solidity/contracts/bridge/BridgeGovernance.sol +++ b/solidity/contracts/bridge/BridgeGovernance.sol @@ -290,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; diff --git a/solidity/deploy/95_deploy_reservation_vault.ts b/solidity/deploy/95_deploy_reservation_vault.ts index 3216150843..0e4510ad81 100644 --- a/solidity/deploy/95_deploy_reservation_vault.ts +++ b/solidity/deploy/95_deploy_reservation_vault.ts @@ -17,9 +17,10 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { }) // NOTE: To activate reservations, governance must additionally: - // 1. Mark the vault as trusted: `Bridge.setVaultStatus(vault, true)`, - // 2. Wire it as the reservation vault and set the parameters: - // `Bridge.updateReservationParameters(vault, ...)`. + // 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) { @@ -30,4 +31,4 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { export default func func.tags = ["ReservationVault"] -func.dependencies = ["Bank", "TBTCVault", "Bridge"] +func.dependencies = ["Bank", "TBTCVault"] diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index b68fef53c3..04c970ad2c 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -833,7 +833,7 @@ describe("Bridge - Reservation", () => { }) it("stages the parameters and applies them after the governance delay", async () => { - await bridgeGovernance + const beginTx = await bridgeGovernance .connect(governance) .beginReservationParametersUpdate( reservationVault.address, @@ -845,6 +845,24 @@ describe("Bridge - Reservation", () => { 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) 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/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, + }, + }) + }) +}) From fe9c454c3e35bd6ba220c9a1067f89cf5c2dc1a7 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 14:05:31 -0400 Subject: [PATCH 20/22] fix(reservation): bound redemption fees --- solidity/contracts/vault/ReservationVault.sol | 7 +- .../test/bridge/Bridge.Reservation.test.ts | 85 ++++++++++++++++++- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/solidity/contracts/vault/ReservationVault.sol b/solidity/contracts/vault/ReservationVault.sol index fd959c4586..2836ec2041 100644 --- a/solidity/contracts/vault/ReservationVault.sol +++ b/solidity/contracts/vault/ReservationVault.sol @@ -222,8 +222,11 @@ contract ReservationVault is IVault, Ownable { /// @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. @@ -234,7 +237,8 @@ contract ReservationVault is IVault, Ownable { /// redemption again. function redeemReservation( uint256 reservationKey, - bytes calldata redeemerOutputScript + bytes calldata redeemerOutputScript, + uint256 maxFeeTbtc ) external { Reservation.ReservationRequest memory reservation = bridge.reservations( reservationKey @@ -247,6 +251,7 @@ contract ReservationVault is IVault, Ownable { 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, diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index 04c970ad2c..1eb2f87df1 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -650,7 +650,7 @@ describe("Bridge - Reservation", () => { await expect( reservationVault .connect(governance) - .redeemReservation(reservationKey, redeemerOutputScript) + .redeemReservation(reservationKey, redeemerOutputScript, 0) ).to.be.revertedWith("Caller is not the reservation owner") }) @@ -664,7 +664,7 @@ describe("Bridge - Reservation", () => { const tx = await reservationVault .connect(thirdParty) - .redeemReservation(reservationKey, redeemerOutputScript) + .redeemReservation(reservationKey, redeemerOutputScript, fee) await expect(tx) .to.emit(reservationVault, "ReservedRedemptionInitiated") @@ -677,6 +677,83 @@ describe("Bridge - Reservation", () => { 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) + }) }) }) @@ -1426,7 +1503,7 @@ describe("Bridge - Reservation", () => { .approve(reservationVault.address, grossTbtc.add(redemptionFee)) await reservationVault .connect(thirdParty) - .redeemReservation(reservationKey, redeemerScript) + .redeemReservation(reservationKey, redeemerScript, redemptionFee) const redemptionTx = buildTx( [{ txHash: anchorTx.txHash, index: 0 }], [ @@ -1489,7 +1566,7 @@ describe("Bridge - Reservation", () => { .approve(reservationVault.address, grossTbtc.add(redemptionFee)) await reservationVault .connect(thirdParty) - .redeemReservation(reservationKey, redeemerScript) + .redeemReservation(reservationKey, redeemerScript, redemptionFee) // Pay the full anchor value out (zero miner fee). The old lower bound // `anchorAmount - redemptionTxMaxFee` would revert on underflow here. From f7be0b875321ad44de037b17f6499266b4c109fd Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 11 Aug 2026 12:52:06 -0400 Subject: [PATCH 21/22] fix(reservation): preserve reveal-time deposit classification --- solidity/contracts/bridge/Bridge.sol | 10 + solidity/contracts/bridge/BridgeState.sol | 26 +- solidity/contracts/bridge/Deposit.sol | 17 +- solidity/contracts/bridge/DepositSweep.sol | 25 +- solidity/contracts/bridge/Reservation.sol | 4 + .../bridge/WalletProposalValidator.sol | 15 +- .../test/bridge/Bridge.Reservation.test.ts | 238 +++++++++++++++++- 7 files changed, 303 insertions(+), 32 deletions(-) diff --git a/solidity/contracts/bridge/Bridge.sol b/solidity/contracts/bridge/Bridge.sol index 31a7a4d901..f940655f91 100644 --- a/solidity/contracts/bridge/Bridge.sol +++ b/solidity/contracts/bridge/Bridge.sol @@ -1778,6 +1778,16 @@ contract Bridge is return self.deposits[depositKey]; } + /// @notice Returns whether the deposit was routed to the reservation + /// vault that was configured at reveal time. + function isReservedDeposit(uint256 depositKey) + external + view + returns (bool) + { + return self.pendingReservedDeposit[depositKey].isReserved; + } + /// @notice Collection of all pending redemption requests indexed by /// redemption key built as /// `keccak256(keccak256(redeemerOutputScript) | walletPubKeyHash)`. diff --git a/solidity/contracts/bridge/BridgeState.sol b/solidity/contracts/bridge/BridgeState.sol index e9ecbf5217..1752cfd588 100644 --- a/solidity/contracts/bridge/BridgeState.sol +++ b/solidity/contracts/bridge/BridgeState.sol @@ -29,6 +29,25 @@ import "./MovingFunds.sol"; import "../bank/Bank.sol"; library BridgeState { + /// @notice Reveal-time facts for a deposit routed to the reservation + /// vault. All fields fit in one storage word. `isReserved` is + /// permanent; the remaining fields are used by the reservation + /// action flow and may be cleared after acceptance or staleness. + struct PendingReservedDeposit { + // Immutable reveal-time reservation classification. + bool isReserved; + // Wallet committed by the deposit script and therefore the only + // wallet that can be authorized to anchor the deposit. + bytes20 walletPubKeyHash; + // Exact Bitcoin refund locktime validated at reveal time. Zero is a + // valid value when reveal-ahead validation was disabled. + uint32 refundDeadline; + // Whether the refund deadline was validated against a nonzero + // reveal-ahead period. This preserves the disabled validation mode + // without overloading a valid zero locktime. + bool refundDeadlineValidated; + } + struct Storage { // Address of the Bank the Bridge belongs to. Bank bank; @@ -366,6 +385,11 @@ library BridgeState { // The number of active reservations custodied by the given wallet, // identified by its 20-byte wallet public key hash. mapping(bytes20 => uint32) walletReservationsCount; + // Reveal-time facts for deposits routed to the reservation vault. + // The permanent classification prevents later reservation-vault + // updates from changing an ordinary deposit into a reservation or + // making a reserved deposit eligible for an ordinary sweep. + mapping(uint256 => PendingReservedDeposit) pendingReservedDeposit; // Reserved storage space in case we need to add more variables. // The convention from OpenZeppelin suggests the storage space should // add up to 50 slots. Here we want to have more slots as there are @@ -373,7 +397,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[43] __gap; + uint256[42] __gap; } event DepositParametersUpdated( diff --git a/solidity/contracts/bridge/Deposit.sol b/solidity/contracts/bridge/Deposit.sol index 6086f2d4e0..4c148711f0 100644 --- a/solidity/contracts/bridge/Deposit.sol +++ b/solidity/contracts/bridge/Deposit.sol @@ -317,13 +317,12 @@ library Deposit { ) .hash256View(); - DepositRequest storage deposit = self.deposits[ - uint256( - keccak256( - abi.encodePacked(fundingTxHash, reveal.fundingOutputIndex) - ) + uint256 depositKey = uint256( + keccak256( + abi.encodePacked(fundingTxHash, reveal.fundingOutputIndex) ) - ]; + ); + DepositRequest storage deposit = self.deposits[depositKey]; require(deposit.revealedAt == 0, "Deposit already revealed"); uint64 fundingOutputAmount = fundingOutput.extractValue(); @@ -343,6 +342,12 @@ library Deposit { : 0; deposit.extraData = extraData; + if ( + reveal.vault != address(0) && reveal.vault == self.reservationVault + ) { + self.pendingReservedDeposit[depositKey].isReserved = true; + } + if (deposit.treasuryFee > 0 && self.rebateStaking != address(0)) { deposit.treasuryFee = RebateStaking(self.rebateStaking) .applyForRebate( diff --git a/solidity/contracts/bridge/DepositSweep.sol b/solidity/contracts/bridge/DepositSweep.sol index 1ae6e72f3e..2700173d5d 100644 --- a/solidity/contracts/bridge/DepositSweep.sol +++ b/solidity/contracts/bridge/DepositSweep.sol @@ -158,17 +158,6 @@ 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. ( @@ -429,17 +418,21 @@ library DepositSweep { inputStartingIndex ); - Deposit.DepositRequest storage deposit = self.deposits[ - uint256( - keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) - ) - ]; + uint256 depositKey = uint256( + keccak256(abi.encodePacked(outpointTxHash, outpointIndex)) + ); + Deposit.DepositRequest storage deposit = self.deposits[depositKey]; if (deposit.revealedAt != 0) { // If we entered here, that means the input was identified as // a revealed deposit. require(deposit.sweptAt == 0, "Deposit already swept"); + require( + !self.pendingReservedDeposit[depositKey].isReserved, + "Reserved deposits must not be swept" + ); + require( deposit.vault == processInfo.vault, "Deposit should be routed to another vault" diff --git a/solidity/contracts/bridge/Reservation.sol b/solidity/contracts/bridge/Reservation.sol index 14d52142c6..50e40684a8 100644 --- a/solidity/contracts/bridge/Reservation.sol +++ b/solidity/contracts/bridge/Reservation.sol @@ -357,6 +357,10 @@ library Reservation { Deposit.DepositRequest storage deposit = self.deposits[reservationKey]; require(deposit.revealedAt != 0, "Deposit not revealed"); require(deposit.sweptAt == 0, "Deposit already swept"); + require( + self.pendingReservedDeposit[reservationKey].isReserved, + "Deposit was not revealed as reserved" + ); require( deposit.vault == self.reservationVault, "Deposit not routed to the reservation vault" diff --git a/solidity/contracts/bridge/WalletProposalValidator.sol b/solidity/contracts/bridge/WalletProposalValidator.sol index d2da5818fe..0c280edeb0 100644 --- a/solidity/contracts/bridge/WalletProposalValidator.sol +++ b/solidity/contracts/bridge/WalletProposalValidator.sol @@ -269,9 +269,6 @@ contract WalletProposalValidator { address proposalVault = address(0); - (address reservationVault, , , , , , , ) = bridge - .reservationParameters(); - uint256[] memory processedDepositKeys = new uint256[]( proposal.depositsKeys.length ); @@ -304,11 +301,10 @@ 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. + // Deposits classified as reserved when revealed are anchored via + // the reservation flow and must never be swept. require( - reservationVault == address(0) || - depositRequest.vault != reservationVault, + !bridge.isReservedDeposit(depositKeyUint), "Reserved deposits must not be swept" ); @@ -1013,6 +1009,11 @@ contract WalletProposalValidator { require(depositRequest.sweptAt == 0, "Deposit already swept"); + require( + bridge.isReservedDeposit(depositKeyUint), + "Deposit was not revealed as reserved" + ); + require( depositRequest.vault == reservationVault, "Deposit not routed to the reservation vault" diff --git a/solidity/test/bridge/Bridge.Reservation.test.ts b/solidity/test/bridge/Bridge.Reservation.test.ts index 1eb2f87df1..efe8c4de8b 100644 --- a/solidity/test/bridge/Bridge.Reservation.test.ts +++ b/solidity/test/bridge/Bridge.Reservation.test.ts @@ -357,12 +357,12 @@ describe("Bridge - Reservation", () => { }) describe("deposit sweep guard", () => { - before(async () => { + beforeEach(async () => { await createSnapshot() await wireReservations() }) - after(async () => { + afterEach(async () => { await restoreSnapshot() }) @@ -400,6 +400,27 @@ describe("Bridge - Reservation", () => { }) await bridge.connect(depositorSigner).revealDeposit(fundingTx, reveal) + const depositKey = ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.hash, reveal.fundingOutputIndex] + ) + expect(await bridge.isReservedDeposit(depositKey)).to.be.true + + // Moving the configured reservation vault away must not make this + // reveal-time reserved deposit eligible for an ordinary sweep. + await bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + tbtcVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + expect(await bridge.isReservedDeposit(depositKey)).to.be.true + await expect( bridge .connect(spvMaintainer) @@ -411,6 +432,154 @@ describe("Bridge - Reservation", () => { ) ).to.be.revertedWith("Reserved deposits must not be swept") }) + + it("should sweep an ordinary deposit after its vault becomes the reservation vault", async () => { + const data: DepositSweepTestData = JSON.parse( + JSON.stringify(SingleP2WSHDeposit) + ) + // The deposit is ordinary at reveal time because the reservation vault + // is still `reservationVault`. Governance then repurposes its trusted + // vault as the reservation vault before the sweep is proven. + data.deposits[0].reveal.vault = tbtcVault.address + data.vault = tbtcVault.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) + + const depositKey = ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTx.hash, reveal.fundingOutputIndex] + ) + expect(await bridge.isReservedDeposit(depositKey)).to.be.false + + // Build a fresh ordinary deposit with a future refund deadline so the + // wallet proposal validator can exercise the same migration path. + const validatorBlindingFactor = "0xf9f0c90d00039523" + const validatorRefundPubKeyHash = + "0x28e081f285138ccbe389c1eb8985716230129f89" + const validatorRefundLocktime = `0x${toLE( + (await lastBlockTime()) + 400 * 24 * 3600, + 4 + )}` + const validatorFundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: BigNumber.from(3000000), + script: p2wshScript( + buildDepositScript( + thirdParty.address, + validatorBlindingFactor, + reveal.walletPubKeyHash as string, + validatorRefundPubKeyHash, + validatorRefundLocktime + ) + ), + }, + ] + ) + await bridge.connect(thirdParty).revealDeposit(validatorFundingTx.info, { + fundingOutputIndex: 0, + blindingFactor: validatorBlindingFactor, + walletPubKeyHash: reveal.walletPubKeyHash, + refundPubKeyHash: validatorRefundPubKeyHash, + refundLocktime: validatorRefundLocktime, + vault: tbtcVault.address, + }) + const validatorDepositKey = ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [validatorFundingTx.txHash, 0] + ) + expect(await bridge.isReservedDeposit(validatorDepositKey)).to.be.false + + await bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + tbtcVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + + // Classification is a reveal-time fact, not a live vault-address + // comparison. + expect(await bridge.isReservedDeposit(depositKey)).to.be.false + expect(await bridge.isReservedDeposit(validatorDepositKey)).to.be.false + + await increaseTime(7300) + const ValidatorFactory = await ethers.getContractFactory( + "WalletProposalValidator" + ) + const validator = await ValidatorFactory.deploy(bridge.address) + expect( + await validator.validateDepositSweepProposal( + { + walletPubKeyHash: reveal.walletPubKeyHash, + depositsKeys: [ + { + fundingTxHash: validatorFundingTx.txHash, + fundingOutputIndex: 0, + }, + ], + sweepTxFee: 1500, + depositsRevealBlocks: [0], + }, + [ + { + fundingTx: validatorFundingTx.info, + blindingFactor: validatorBlindingFactor, + walletPubKeyHash: reveal.walletPubKeyHash, + refundPubKeyHash: validatorRefundPubKeyHash, + refundLocktime: validatorRefundLocktime, + }, + ] + ) + ).to.be.true + + // Let the ordinary TBTC vault mint the swept deposit balance. + const tbtcOwner = await impersonateContract(await tbtc.owner()) + await tbtc.connect(tbtcOwner).transferOwnership(tbtcVault.address) + + await bridge + .connect(spvMaintainer) + .submitDepositSweepProof( + data.sweepTx, + data.sweepProof, + data.mainUtxo, + data.vault + ) + }) }) describe("extendReservation", () => { @@ -1436,6 +1605,8 @@ describe("Bridge - Reservation", () => { const { anchorTx, acceptTx, reservationKey } = await makeAcceptedReservation() + expect(await bridge.isReservedDeposit(reservationKey)).to.be.true + await expect(acceptTx) .to.emit(bridge, "ReservationAccepted") .withArgs( @@ -1476,6 +1647,69 @@ describe("Bridge - Reservation", () => { ).to.be.revertedWith("Deposit already swept") }) + it("does not accept an ordinary deposit after its vault becomes the reservation vault", async () => { + const fundingTx = buildTx( + [ + { + txHash: ethers.utils.hexlify(ethers.utils.randomBytes(32)), + index: 0, + }, + ], + [ + { + valueSat: depositAmount, + script: p2wshScript( + buildDepositScript( + thirdParty.address, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime + ) + ), + }, + ] + ) + + await bridge.connect(thirdParty).revealDeposit(fundingTx.info, { + fundingOutputIndex: 0, + blindingFactor, + walletPubKeyHash, + refundPubKeyHash, + refundLocktime, + vault: tbtcVault.address, + }) + + await bridge + .connect(bridgeGovernanceSigner) + .updateReservationParameters( + tbtcVault.address, + RESERVATION_MIN_AMOUNT, + RESERVATION_TX_MAX_FEE, + RESERVATION_TERM, + RESERVATION_GRACE, + RESERVATION_MAX_TOTAL, + MAX_RESERVATIONS_PER_WALLET + ) + + const anchorTx = buildTx( + [{ txHash: fundingTx.txHash, index: 0 }], + [{ valueSat: anchorAmount, script: p2wpkhScript(walletPubKeyHash) }] + ) + + await expect( + bridge + .connect(spvMaintainer) + .submitReservationProof( + ProofType.Acceptance, + anchorTx.info, + proofFor(anchorTx.txHash), + NO_MAIN_UTXO_PARAM, + 0 + ) + ).to.be.revertedWith("Deposit was not revealed as reserved") + }) + it("rejects an anchor paying an excessive miner fee", async () => { await expect( makeAcceptedReservation(depositAmount.sub(RESERVATION_TX_MAX_FEE + 1)) From 3d4335d74f67c73f9793cfc664a4282cfb2a6fde Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 11 Aug 2026 13:59:04 -0400 Subject: [PATCH 22/22] chore(slither): classify trusted reservation lookup --- solidity/contracts/bridge/WalletProposalValidator.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/solidity/contracts/bridge/WalletProposalValidator.sol b/solidity/contracts/bridge/WalletProposalValidator.sol index 0c280edeb0..af8ecfeb48 100644 --- a/solidity/contracts/bridge/WalletProposalValidator.sol +++ b/solidity/contracts/bridge/WalletProposalValidator.sol @@ -303,6 +303,7 @@ contract WalletProposalValidator { // Deposits classified as reserved when revealed are anchored via // the reservation flow and must never be swept. + // slither-disable-next-line calls-loop require( !bridge.isReservedDeposit(depositKeyUint), "Reserved deposits must not be swept"