diff --git a/monitoring/docs/monitoring-and-telemetry.adoc b/monitoring/docs/monitoring-and-telemetry.adoc index 74dec42bf..315ae957d 100644 --- a/monitoring/docs/monitoring-and-telemetry.adoc +++ b/monitoring/docs/monitoring-and-telemetry.adoc @@ -181,7 +181,7 @@ team’s attention. The default action is making sure that the redemption is not a result of a malicious action, and if not, that the redemption is handled correctly by the system. -=== Stale redemption +==== Stale redemption A *warning system event* indicating that a redemption request became stale, i.e. was not handled within the expected time. This event is sent to Sentry hub and diff --git a/solidity/contracts/bridge/Bridge.sol b/solidity/contracts/bridge/Bridge.sol index f4506e83f..f1395e04e 100644 --- a/solidity/contracts/bridge/Bridge.sol +++ b/solidity/contracts/bridge/Bridge.sol @@ -376,6 +376,35 @@ contract Bridge is self.revealDeposit(fundingTx, reveal); } + /// @notice Sibling of the `revealDeposit` function. This function allows + /// to reveal a P2(W)SH Bitcoin deposit with 32-byte extra data + /// embedded in the deposit script. The extra data allows to + /// attach additional context to the deposit. For example, + /// it allows a third-party smart contract to reveal the + /// deposit on behalf of the original depositor and provide + /// additional services once the deposit is handled. In this + /// case, the address of the original depositor can be encoded + /// as extra data. + /// @param fundingTx Bitcoin funding transaction data, see `BitcoinTx.Info`. + /// @param reveal Deposit reveal data, see `RevealInfo struct. + /// @param extraData 32-byte deposit extra data. + /// @dev Requirements: + /// - All requirements from `revealDeposit` function must be met, + /// - `extraData` must not be bytes32(0), + /// - `extraData` must be the actual extra data used in the P2(W)SH + /// BTC deposit transaction. + /// + /// If any of these requirements is not met, the wallet _must_ refuse + /// to sweep the deposit and the depositor has to wait until the + /// deposit script unlocks to receive their BTC back. + function revealDepositWithExtraData( + BitcoinTx.Info calldata fundingTx, + Deposit.DepositRevealInfo calldata reveal, + bytes32 extraData + ) external { + self.revealDepositWithExtraData(fundingTx, reveal, extraData); + } + /// @notice Used by the wallet to prove the BTC deposit sweep transaction /// and to update Bank balances accordingly. Sweep is only accepted /// if it satisfies SPV proof. diff --git a/solidity/contracts/bridge/Deposit.sol b/solidity/contracts/bridge/Deposit.sol index 0fb51d785..a0b2d9d3e 100644 --- a/solidity/contracts/bridge/Deposit.sol +++ b/solidity/contracts/bridge/Deposit.sol @@ -44,6 +44,25 @@ import "./Wallets.sol"; /// Since each depositor has their own Ethereum address and their own /// blinding factor, each depositor’s script is unique, and the hash /// of each depositor’s script is unique. +/// +/// This library also supports another variant of the deposit script +/// allowing to embed 32-byte extra data. The extra data allows to attach +/// additional context to the deposit. The script with 32-byte extra data +/// looks like this: +/// +/// ``` +/// DROP +/// DROP +/// DROP +/// DUP HASH160 EQUAL +/// IF +/// CHECKSIG +/// ELSE +/// DUP HASH160 EQUALVERIFY +/// CHECKLOCKTIMEVERIFY DROP +/// CHECKSIG +/// ENDIF +/// ``` library Deposit { using BTCUtils for bytes; using BytesLib for bytes; @@ -96,6 +115,8 @@ library Deposit { // the time when the sweep proof was delivered to the Ethereum chain. // XXX: Unsigned 32-bit int unix seconds, will break February 7th 2106. uint32 sweptAt; + // The 32-byte deposit extra data. Optional, can be bytes32(0). + bytes32 extraData; // 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. @@ -152,6 +173,28 @@ library Deposit { BitcoinTx.Info calldata fundingTx, DepositRevealInfo calldata reveal ) external { + _revealDeposit(self, fundingTx, reveal, bytes32(0)); + } + + /// @notice Internal function encapsulating the core logic of the deposit + /// reveal process. Handles both regular deposits without extra data + /// as well as deposits with 32-byte extra data embedded in the + /// deposit script. The behavior is controlled by the `extraData` + /// parameter. If `extraData` is bytes32(0), the function triggers + /// the flow for regular deposits. If `extraData` is not bytes32(0), + /// the function triggers the flow for deposits with 32-byte + /// extra data. + /// @param fundingTx Bitcoin funding transaction data, see `BitcoinTx.Info`. + /// @param reveal Deposit reveal data, see `RevealInfo struct. + /// @param extraData 32-byte deposit extra data. Can be bytes32(0). + /// @dev Requirements are described in the docstrings of `revealDeposit` and + /// `revealDepositWithExtraData` external functions. + function _revealDeposit( + BridgeState.Storage storage self, + BitcoinTx.Info calldata fundingTx, + DepositRevealInfo calldata reveal, + bytes32 extraData + ) internal { require( self.registeredWallets[reveal.walletPubKeyHash].state == Wallets.WalletState.Live, @@ -167,33 +210,70 @@ library Deposit { validateDepositRefundLocktime(self, reveal.refundLocktime); } - bytes memory expectedScript = abi.encodePacked( - hex"14", // Byte length of depositor Ethereum address. - msg.sender, - hex"75", // OP_DROP - hex"08", // Byte length of blinding factor value. - reveal.blindingFactor, - hex"75", // OP_DROP - hex"76", // OP_DUP - hex"a9", // OP_HASH160 - hex"14", // Byte length of a compressed Bitcoin public key hash. - reveal.walletPubKeyHash, - hex"87", // OP_EQUAL - hex"63", // OP_IF - hex"ac", // OP_CHECKSIG - hex"67", // OP_ELSE - hex"76", // OP_DUP - hex"a9", // OP_HASH160 - hex"14", // Byte length of a compressed Bitcoin public key hash. - reveal.refundPubKeyHash, - hex"88", // OP_EQUALVERIFY - hex"04", // Byte length of refund locktime value. - reveal.refundLocktime, - hex"b1", // OP_CHECKLOCKTIMEVERIFY - hex"75", // OP_DROP - hex"ac", // OP_CHECKSIG - hex"68" // OP_ENDIF - ); + bytes memory expectedScript; + + if (extraData == bytes32(0)) { + // Regular deposit without 32-byte extra data. + expectedScript = abi.encodePacked( + hex"14", // Byte length of depositor Ethereum address. + msg.sender, + hex"75", // OP_DROP + hex"08", // Byte length of blinding factor value. + reveal.blindingFactor, + hex"75", // OP_DROP + hex"76", // OP_DUP + hex"a9", // OP_HASH160 + hex"14", // Byte length of a compressed Bitcoin public key hash. + reveal.walletPubKeyHash, + hex"87", // OP_EQUAL + hex"63", // OP_IF + hex"ac", // OP_CHECKSIG + hex"67", // OP_ELSE + hex"76", // OP_DUP + hex"a9", // OP_HASH160 + hex"14", // Byte length of a compressed Bitcoin public key hash. + reveal.refundPubKeyHash, + hex"88", // OP_EQUALVERIFY + hex"04", // Byte length of refund locktime value. + reveal.refundLocktime, + hex"b1", // OP_CHECKLOCKTIMEVERIFY + hex"75", // OP_DROP + hex"ac", // OP_CHECKSIG + hex"68" // OP_ENDIF + ); + } else { + // Deposit with 32-byte extra data. + expectedScript = abi.encodePacked( + hex"14", // Byte length of depositor Ethereum address. + msg.sender, + hex"75", // OP_DROP + hex"20", // Byte length of extra data. + extraData, + hex"75", // OP_DROP + hex"08", // Byte length of blinding factor value. + reveal.blindingFactor, + hex"75", // OP_DROP + hex"76", // OP_DUP + hex"a9", // OP_HASH160 + hex"14", // Byte length of a compressed Bitcoin public key hash. + reveal.walletPubKeyHash, + hex"87", // OP_EQUAL + hex"63", // OP_IF + hex"ac", // OP_CHECKSIG + hex"67", // OP_ELSE + hex"76", // OP_DUP + hex"a9", // OP_HASH160 + hex"14", // Byte length of a compressed Bitcoin public key hash. + reveal.refundPubKeyHash, + hex"88", // OP_EQUALVERIFY + hex"04", // Byte length of refund locktime value. + reveal.refundLocktime, + hex"b1", // OP_CHECKLOCKTIMEVERIFY + hex"75", // OP_DROP + hex"ac", // OP_CHECKSIG + hex"68" // OP_ENDIF + ); + } bytes memory fundingOutput = fundingTx .outputVector @@ -258,6 +338,21 @@ library Deposit { deposit.treasuryFee = self.depositTreasuryFeeDivisor > 0 ? fundingOutputAmount / self.depositTreasuryFeeDivisor : 0; + deposit.extraData = extraData; + + _emitDepositRevealedEvent(fundingTxHash, fundingOutputAmount, reveal); + } + + /// @notice Emits the `DepositRevealed` event. + /// @param fundingTxHash The funding transaction hash. + /// @param fundingOutputAmount The funding output amount in satoshi. + /// @param reveal Deposit reveal data, see `RevealInfo struct. + /// @dev This function is extracted to overcome the stack too deep error. + function _emitDepositRevealedEvent( + bytes32 fundingTxHash, + uint64 fundingOutputAmount, + DepositRevealInfo calldata reveal + ) internal { // slither-disable-next-line reentrancy-events emit DepositRevealed( fundingTxHash, @@ -272,6 +367,40 @@ library Deposit { ); } + /// @notice Sibling of the `revealDeposit` function. This function allows + /// to reveal a P2(W)SH Bitcoin deposit with 32-byte extra data + /// embedded in the deposit script. The extra data allows to + /// attach additional context to the deposit. For example, + /// it allows a third-party smart contract to reveal the + /// deposit on behalf of the original depositor and provide + /// additional services once the deposit is handled. In this + /// case, the address of the original depositor can be encoded + /// as extra data. + /// @param fundingTx Bitcoin funding transaction data, see `BitcoinTx.Info`. + /// @param reveal Deposit reveal data, see `RevealInfo struct. + /// @param extraData 32-byte deposit extra data. + /// @dev Requirements: + /// - All requirements from `revealDeposit` function must be met, + /// - `extraData` must not be bytes32(0), + /// - `extraData` must be the actual extra data used in the P2(W)SH + /// BTC deposit transaction. + /// + /// If any of these requirements is not met, the wallet _must_ refuse + /// to sweep the deposit and the depositor has to wait until the + /// deposit script unlocks to receive their BTC back. + function revealDepositWithExtraData( + BridgeState.Storage storage self, + BitcoinTx.Info calldata fundingTx, + DepositRevealInfo calldata reveal, + bytes32 extraData + ) external { + // Strong requirement in order to differentiate from the regular + // reveal flow and reduce potential attack surface. + require(extraData != bytes32(0), "Extra data must not be empty"); + + _revealDeposit(self, fundingTx, reveal, extraData); + } + /// @notice Validates the deposit refund locktime. The validation passes /// successfully only if the deposit reveal is done respectively /// earlier than the moment when the deposit refund locktime is diff --git a/solidity/contracts/bridge/WalletCoordinator.sol b/solidity/contracts/bridge/WalletProposalValidator.sol similarity index 53% rename from solidity/contracts/bridge/WalletCoordinator.sol rename to solidity/contracts/bridge/WalletProposalValidator.sol index b5311db7d..ab1d400e1 100644 --- a/solidity/contracts/bridge/WalletCoordinator.sol +++ b/solidity/contracts/bridge/WalletProposalValidator.sol @@ -17,61 +17,22 @@ 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 "@keep-network/random-beacon/contracts/Reimbursable.sol"; -import "@keep-network/random-beacon/contracts/ReimbursementPool.sol"; - -import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "./BitcoinTx.sol"; import "./Bridge.sol"; import "./Deposit.sol"; import "./Redemption.sol"; +import "./MovingFunds.sol"; import "./Wallets.sol"; -/// @title Wallet coordinator. -/// @notice The wallet coordinator contract aims to facilitate the coordination -/// of the off-chain wallet members during complex multi-chain wallet -/// operations like deposit sweeping, redemptions, or moving funds. -/// Such processes involve various moving parts and many steps that each -/// individual wallet member must do. Given the distributed nature of -/// the off-chain wallet software, full off-chain implementation is -/// challenging and prone to errors, especially byzantine faults. -/// This contract provides a single and trusted on-chain coordination -/// point thus taking the riskiest part out of the off-chain software. -/// The off-chain wallet members can focus on the core tasks and do not -/// bother about electing a trusted coordinator or aligning internal -/// states using complex consensus algorithms. -contract WalletCoordinator is OwnableUpgradeable, Reimbursable { +/// @title Wallet proposal validator. +/// @notice This contract exposes several view functions allowing to validate +/// specific wallet action proposals. This contract is non-upgradeable +/// and does not have any write functions. +contract WalletProposalValidator { using BTCUtils for bytes; using BytesLib for bytes; - /// @notice Represents wallet action: - enum WalletAction { - /// @dev The wallet does not perform any action. - Idle, - /// @dev The wallet is executing heartbeat. - Heartbeat, - /// @dev The wallet is handling a deposit sweep action. - DepositSweep, - /// @dev The wallet is handling a redemption action. - Redemption, - /// @dev The wallet is handling a moving funds action. - MovingFunds, - /// @dev The wallet is handling a moved funds sweep action. - MovedFundsSweep - } - - /// @notice Holds information about a wallet time lock. - struct WalletLock { - /// @notice A UNIX timestamp defining the moment until which the wallet - /// is locked and cannot receive new proposals. The value of 0 - /// means the wallet is not locked and can receive a proposal - /// at any time. - uint32 expiresAt; - /// @notice The wallet action being the cause of the lock. - WalletAction cause; - } - /// @notice Helper structure representing a deposit sweep proposal. struct DepositSweepProposal { // 20-byte public key hash of the target wallet. @@ -105,7 +66,7 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { uint32 fundingOutputIndex; } - /// @notice Helper structure holding deposit extra data required during + /// @notice Helper structure holding deposit extra info required during /// deposit sweep proposal validation. Basically, this structure /// is a combination of BitcoinTx.Info and relevant parts of /// Deposit.DepositRevealInfo. @@ -134,54 +95,52 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { uint256 redemptionTxFee; } - /// @notice Mapping that holds addresses allowed to submit proposals and - /// request heartbeats. - mapping(address => bool) public isCoordinator; + /// @notice Helper structure representing a moving funds proposal. + struct MovingFundsProposal { + // 20-byte public key hash of the source wallet. + bytes20 walletPubKeyHash; + // List of 20-byte public key hashes of target wallets. + bytes20[] targetWallets; + // Proposed BTC fee for the entire transaction. + uint256 movingFundsTxFee; + } - /// @notice Mapping that holds wallet time locks. The key is a 20-byte - /// wallet public key hash. - mapping(bytes20 => WalletLock) public walletLock; + /// @notice Helper structure representing a moved funds sweep proposal. + struct MovedFundsSweepProposal { + // 20-byte public key hash of the wallet. + bytes20 walletPubKeyHash; + // 32-byte hash of the moving funds transaction that caused the sweep + // request to be created. + bytes32 movingFundsTxHash; + // Index of the moving funds transaction output that is subject of the + // sweep request. + uint32 movingFundsTxOutputIndex; + // Proposed BTC fee for the entire transaction. + uint256 movedFundsSweepTxFee; + } - /// @notice Handle to the Bridge contract. - Bridge public bridge; + /// @notice Helper structure representing a heartbeat proposal. + struct HeartbeatProposal { + // 20-byte public key hash of the target wallet. + bytes20 walletPubKeyHash; + // Message to be signed as part of the heartbeat. + bytes message; + } - /// @notice Determines the wallet heartbeat request validity time. In other - /// words, this is the worst-case time for a wallet heartbeat - /// during which the wallet is busy and canot take other actions. - /// This is also the duration of the time lock applied to the wallet - /// once a new heartbeat request is submitted. - /// - /// For example, if a deposit sweep proposal was submitted at - /// 2 pm and heartbeatRequestValidity is 1 hour, the next request or - /// proposal (of any type) can be submitted after 3 pm. - uint32 public heartbeatRequestValidity; - - /// @notice Gas that is meant to balance the heartbeat request overall cost. - /// Can be updated by the owner based on the current conditions. - uint32 public heartbeatRequestGasOffset; - - /// @notice Determines the deposit sweep proposal validity time. In other - /// words, this is the worst-case time for a deposit sweep during - /// which the wallet is busy and cannot take another actions. This - /// is also the duration of the time lock applied to the wallet - /// once a new deposit sweep proposal is submitted. - /// - /// For example, if a deposit sweep proposal was submitted at - /// 2 pm and depositSweepProposalValidity is 4 hours, the next - /// proposal (of any type) can be submitted after 6 pm. - uint32 public depositSweepProposalValidity; + /// @notice Handle to the Bridge contract. + Bridge public immutable bridge; /// @notice The minimum time that must elapse since the deposit reveal /// before a deposit becomes eligible for a deposit sweep. /// - /// For example, if a deposit was revealed at 9 am and depositMinAge + /// For example, if a deposit was revealed at 9 am and DEPOSIT_MIN_AGE /// is 2 hours, the deposit is eligible for sweep after 11 am. /// /// @dev Forcing deposit minimum age ensures block finality for Ethereum. /// In the happy path case, i.e. where the deposit is revealed immediately /// after being broadcast on the Bitcoin network, the minimum age /// check also ensures block finality for Bitcoin. - uint32 public depositMinAge; + uint32 public constant DEPOSIT_MIN_AGE = 2 hours; /// @notice Each deposit can be technically swept until it reaches its /// refund timestamp after which it can be taken back by the depositor. @@ -196,39 +155,23 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { /// the deposit becomes refundable. /// /// For example, if a deposit becomes refundable after 8 pm and - /// depositRefundSafetyMargin is 6 hours, the deposit is valid for + /// DEPOSIT_REFUND_SAFETY_MARGIN is 6 hours, the deposit is valid /// for a sweep only before 2 pm. - uint32 public depositRefundSafetyMargin; + uint32 public constant DEPOSIT_REFUND_SAFETY_MARGIN = 24 hours; /// @notice The maximum count of deposits that can be swept within a /// single sweep. - uint16 public depositSweepMaxSize; - - /// @notice Gas that is meant to balance the deposit sweep proposal - /// submission overall cost. Can be updated by the owner based on - /// the current conditions. - uint32 public depositSweepProposalSubmissionGasOffset; - - /// @notice Determines the redemption proposal validity time. In other - /// words, this is the worst-case time for a redemption during - /// which the wallet is busy and cannot take another actions. This - /// is also the duration of the time lock applied to the wallet - /// once a new redemption proposal is submitted. - /// - /// For example, if a redemption proposal was submitted at - /// 2 pm and redemptionProposalValidity is 2 hours, the next - /// proposal (of any type) can be submitted after 4 pm. - uint32 public redemptionProposalValidity; + uint16 public constant DEPOSIT_SWEEP_MAX_SIZE = 20; /// @notice The minimum time that must elapse since the redemption request /// creation before a request becomes eligible for a processing. /// /// For example, if a request was created at 9 am and - /// redemptionRequestMinAge is 2 hours, the request is eligible for - /// processing after 11 am. + /// REDEMPTION_REQUEST_MIN_AGE is 2 hours, the request is + /// eligible for processing after 11 am. /// /// @dev Forcing request minimum age ensures block finality for Ethereum. - uint32 public redemptionRequestMinAge; + uint32 public constant REDEMPTION_REQUEST_MIN_AGE = 600; // 10 minutes or ~50 blocks. /// @notice Each redemption request can be technically handled until it /// reaches its timeout timestamp after which it can be reported @@ -245,276 +188,16 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { /// point after which the request can be reported as timed out. /// /// For example, if a request times out after 8 pm and - /// redemptionRequestTimeoutSafetyMargin is 2 hours, the request is - /// valid for processing only before 6 pm. - uint32 public redemptionRequestTimeoutSafetyMargin; + /// REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN is 2 hours, the + /// request is valid for processing only before 6 pm. + uint32 public constant REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN = 2 hours; /// @notice The maximum count of redemption requests that can be processed /// within a single redemption. - uint16 public redemptionMaxSize; - - /// @notice Gas that is meant to balance the redemption proposal - /// submission overall cost. Can be updated by the owner based on - /// the current conditions. - uint32 public redemptionProposalSubmissionGasOffset; - - event CoordinatorAdded(address indexed coordinator); - - event CoordinatorRemoved(address indexed coordinator); - - event WalletManuallyUnlocked(bytes20 indexed walletPubKeyHash); - - event HeartbeatRequestParametersUpdated( - uint32 heartbeatRequestValidity, - uint32 heartbeatRequestGasOffset - ); - - event HeartbeatRequestSubmitted( - bytes20 walletPubKeyHash, - bytes message, - address indexed coordinator - ); - - event DepositSweepProposalParametersUpdated( - uint32 depositSweepProposalValidity, - uint32 depositMinAge, - uint32 depositRefundSafetyMargin, - uint16 depositSweepMaxSize, - uint32 depositSweepProposalSubmissionGasOffset - ); - - event DepositSweepProposalSubmitted( - DepositSweepProposal proposal, - address indexed coordinator - ); - - event RedemptionProposalParametersUpdated( - uint32 redemptionProposalValidity, - uint32 redemptionRequestMinAge, - uint32 redemptionRequestTimeoutSafetyMargin, - uint16 redemptionMaxSize, - uint32 redemptionProposalSubmissionGasOffset - ); - - event RedemptionProposalSubmitted( - RedemptionProposal proposal, - address indexed coordinator - ); - - modifier onlyCoordinator() { - require(isCoordinator[msg.sender], "Caller is not a coordinator"); - _; - } - - modifier onlyAfterWalletLock(bytes20 walletPubKeyHash) { - require( - /* solhint-disable-next-line not-rely-on-time */ - block.timestamp > walletLock[walletPubKeyHash].expiresAt, - "Wallet locked" - ); - _; - } - - modifier onlyReimbursableAdmin() override { - require(owner() == msg.sender, "Caller is not the owner"); - _; - } - - function initialize(Bridge _bridge) external initializer { - __Ownable_init(); + uint16 public constant REDEMPTION_MAX_SIZE = 20; + constructor(Bridge _bridge) { bridge = _bridge; - // Pre-fetch addresses to save gas later. - (, , , reimbursementPool) = _bridge.contractReferences(); - - heartbeatRequestValidity = 1 hours; - heartbeatRequestGasOffset = 10_000; - - depositSweepProposalValidity = 4 hours; - depositMinAge = 2 hours; - depositRefundSafetyMargin = 24 hours; - depositSweepMaxSize = 5; - depositSweepProposalSubmissionGasOffset = 20_000; // optimized for 10 inputs - - redemptionProposalValidity = 2 hours; - redemptionRequestMinAge = 600; // 10 minutes or ~50 blocks. - redemptionRequestTimeoutSafetyMargin = 2 hours; - redemptionMaxSize = 20; - redemptionProposalSubmissionGasOffset = 20_000; - } - - /// @notice Adds the given address to the set of coordinator addresses. - /// @param coordinator Address of the new coordinator. - /// @dev Requirements: - /// - The caller must be the owner, - /// - The `coordinator` must not be an existing coordinator. - function addCoordinator(address coordinator) external onlyOwner { - require( - !isCoordinator[coordinator], - "This address is already a coordinator" - ); - isCoordinator[coordinator] = true; - emit CoordinatorAdded(coordinator); - } - - /// @notice Removes the given address from the set of coordinator addresses. - /// @param coordinator Address of the existing coordinator. - /// @dev Requirements: - /// - The caller must be the owner, - /// - The `coordinator` must be an existing coordinator. - function removeCoordinator(address coordinator) external onlyOwner { - require( - isCoordinator[coordinator], - "This address is not a coordinator" - ); - delete isCoordinator[coordinator]; - emit CoordinatorRemoved(coordinator); - } - - /// @notice Allows to unlock the given wallet before their time lock expires. - /// This function should be used in exceptional cases where - /// something went wrong and there is a need to unlock the wallet - /// without waiting. - /// @param walletPubKeyHash 20-byte public key hash of the wallet - /// @dev Requirements: - /// - The caller must be the owner. - function unlockWallet(bytes20 walletPubKeyHash) external onlyOwner { - // Just in case, allow the owner to unlock the wallet earlier. - walletLock[walletPubKeyHash] = WalletLock(0, WalletAction.Idle); - emit WalletManuallyUnlocked(walletPubKeyHash); - } - - /// @notice Updates parameters related to heartbeat request. - /// @param _heartbeatRequestValidity The new value of `heartbeatRequestValidity`. - /// @param _heartbeatRequestGasOffset The new value of `heartbeatRequestGasOffset`. - /// @dev Requirements: - /// - The caller must be the owner. - function updateHeartbeatRequestParameters( - uint32 _heartbeatRequestValidity, - uint32 _heartbeatRequestGasOffset - ) external onlyOwner { - heartbeatRequestValidity = _heartbeatRequestValidity; - heartbeatRequestGasOffset = _heartbeatRequestGasOffset; - emit HeartbeatRequestParametersUpdated( - _heartbeatRequestValidity, - _heartbeatRequestGasOffset - ); - } - - /// @notice Updates parameters related to deposit sweep proposal. - /// @param _depositSweepProposalValidity The new value of `depositSweepProposalValidity`. - /// @param _depositMinAge The new value of `depositMinAge`. - /// @param _depositRefundSafetyMargin The new value of `depositRefundSafetyMargin`. - /// @param _depositSweepMaxSize The new value of `depositSweepMaxSize`. - /// @dev Requirements: - /// - The caller must be the owner. - function updateDepositSweepProposalParameters( - uint32 _depositSweepProposalValidity, - uint32 _depositMinAge, - uint32 _depositRefundSafetyMargin, - uint16 _depositSweepMaxSize, - uint32 _depositSweepProposalSubmissionGasOffset - ) external onlyOwner { - depositSweepProposalValidity = _depositSweepProposalValidity; - depositMinAge = _depositMinAge; - depositRefundSafetyMargin = _depositRefundSafetyMargin; - depositSweepMaxSize = _depositSweepMaxSize; - depositSweepProposalSubmissionGasOffset = _depositSweepProposalSubmissionGasOffset; - - emit DepositSweepProposalParametersUpdated( - _depositSweepProposalValidity, - _depositMinAge, - _depositRefundSafetyMargin, - _depositSweepMaxSize, - _depositSweepProposalSubmissionGasOffset - ); - } - - /// @notice Submits a heartbeat request from the wallet. Locks the wallet - /// for a specific time, equal to the request validity period. - /// This function validates the proposed heartbeat messge to see - /// if it matches the heartbeat format expected by the Bridge. - /// @param walletPubKeyHash 20-byte public key hash of the wallet that is - /// supposed to execute the heartbeat. - /// @param message The proposed heartbeat message for the wallet to sign. - /// @dev Requirements: - /// - The caller is a coordinator, - /// - The wallet is not time-locked, - /// - The message to sign is a valid heartbeat message. - function requestHeartbeat(bytes20 walletPubKeyHash, bytes calldata message) - public - onlyCoordinator - onlyAfterWalletLock(walletPubKeyHash) - { - require( - Heartbeat.isValidHeartbeatMessage(message), - "Not a valid heartbeat message" - ); - - walletLock[walletPubKeyHash] = WalletLock( - /* solhint-disable-next-line not-rely-on-time */ - uint32(block.timestamp) + heartbeatRequestValidity, - WalletAction.Heartbeat - ); - - emit HeartbeatRequestSubmitted(walletPubKeyHash, message, msg.sender); - } - - /// @notice Wraps `requestHeartbeat` call and reimburses the caller's - /// transaction cost. - /// @dev See `requestHeartbeat` function documentation. - function requestHeartbeatWithReimbursement( - bytes20 walletPubKeyHash, - bytes calldata message - ) external { - uint256 gasStart = gasleft(); - - requestHeartbeat(walletPubKeyHash, message); - - reimbursementPool.refund( - (gasStart - gasleft()) + heartbeatRequestGasOffset, - msg.sender - ); - } - - /// @notice Submits a deposit sweep proposal. Locks the target wallet - /// for a specific time, equal to the proposal validity period. - /// This function does not store the proposal in the state but - /// just emits an event that serves as a guiding light for wallet - /// off-chain members. Wallet members are supposed to validate - /// the proposal on their own, before taking any action. - /// @param proposal The deposit sweep proposal - /// @dev Requirements: - /// - The caller is a coordinator, - /// - The wallet is not time-locked. - function submitDepositSweepProposal(DepositSweepProposal calldata proposal) - public - onlyCoordinator - onlyAfterWalletLock(proposal.walletPubKeyHash) - { - walletLock[proposal.walletPubKeyHash] = WalletLock( - /* solhint-disable-next-line not-rely-on-time */ - uint32(block.timestamp) + depositSweepProposalValidity, - WalletAction.DepositSweep - ); - - emit DepositSweepProposalSubmitted(proposal, msg.sender); - } - - /// @notice Wraps `submitDepositSweepProposal` call and reimburses the - /// caller's transaction cost. - /// @dev See `submitDepositSweepProposal` function documentation. - function submitDepositSweepProposalWithReimbursement( - DepositSweepProposal calldata proposal - ) external { - uint256 gasStart = gasleft(); - - submitDepositSweepProposal(proposal); - - reimbursementPool.refund( - (gasStart - gasleft()) + depositSweepProposalSubmissionGasOffset, - msg.sender - ); } /// @notice View function encapsulating the main rules of a valid deposit @@ -530,23 +213,23 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { /// complexity. Instead of that, each off-chain wallet member is /// supposed to do that check on their own. /// @param proposal The sweeping proposal to validate. - /// @param depositsExtraInfo Deposits extra data required to perform the validation. + /// @param depositsExtraInfo Deposits extra info required to perform the validation. /// @return True if the proposal is valid. Reverts otherwise. /// @dev Requirements: /// - The target wallet must be in the Live state, /// - The number of deposits included in the sweep must be in - /// the range [1, `depositSweepMaxSize`], + /// the range [1, `DEPOSIT_SWEEP_MAX_SIZE`], /// - The length of `depositsExtraInfo` array must be equal to the /// length of `proposal.depositsKeys`, i.e. each deposit must - /// have exactly one set of corresponding extra data, + /// have exactly one set of corresponding extra info, /// - The proposed sweep tx fee must be grater than zero, /// - The proposed maximum per-deposit sweep tx fee must be lesser than /// or equal the maximum fee allowed by the Bridge (`Bridge.depositTxMaxFee`), /// - Each deposit must be revealed to the Bridge, - /// - Each deposit must be old enough, i.e. at least `depositMinAge` + /// - Each deposit must be old enough, i.e. at least `DEPOSIT_MIN_AGE /// elapsed since their reveal time, /// - Each deposit must not be swept yet, - /// - Each deposit must have valid extra data (see `validateDepositExtraInfo`), + /// - Each deposit must have valid extra info (see `validateDepositExtraInfo`), /// - Each deposit must have the refund safety margin preserved, /// - Each deposit must be controlled by the same wallet, /// - Each deposit must target the same vault, @@ -568,13 +251,13 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { require(proposal.depositsKeys.length > 0, "Sweep below the min size"); require( - proposal.depositsKeys.length <= depositSweepMaxSize, + proposal.depositsKeys.length <= DEPOSIT_SWEEP_MAX_SIZE, "Sweep exceeds the max size" ); require( proposal.depositsKeys.length == depositsExtraInfo.length, - "Each deposit key must have matching extra data" + "Each deposit key must have matching extra info" ); validateSweepTxFee(proposal.sweepTxFee, proposal.depositsKeys.length); @@ -607,7 +290,7 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { require( /* solhint-disable-next-line not-rely-on-time */ - block.timestamp > depositRequest.revealedAt + depositMinAge, + block.timestamp > depositRequest.revealedAt + DEPOSIT_MIN_AGE, "Deposit min age not achieved yet" ); @@ -616,6 +299,7 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { validateDepositExtraInfo( depositKey, depositRequest.depositor, + depositRequest.extraData, depositExtraInfo ); @@ -625,7 +309,7 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { require( /* solhint-disable-next-line not-rely-on-time */ block.timestamp < - depositRefundableTimestamp - depositRefundSafetyMargin, + depositRefundableTimestamp - DEPOSIT_REFUND_SAFETY_MARGIN, "Deposit refund safety margin is not preserved" ); @@ -695,11 +379,12 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { ); } - /// @notice Validates the extra data for the given deposit. This function + /// @notice Validates the extra info for the given deposit. This function /// is heavily based on `Deposit.revealDeposit` function. /// @param depositKey Key of the given deposit. /// @param depositor Depositor that revealed the deposit. - /// @param depositExtraInfo Extra data being subject of the validation. + /// @param extraData 32-byte deposit extra data. Optional, can be bytes32(0). + /// @param depositExtraInfo Extra info being subject of the validation. /// @dev Requirements: /// - The transaction hash computed using `depositExtraInfo.fundingTx` /// must match the `depositKey.fundingTxHash`. This requirement @@ -709,11 +394,12 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { /// - The P2(W)SH script inferred from `depositExtraInfo` is actually /// used to lock funds by the `depositKey.fundingOutputIndex` output /// of the `depositExtraInfo.fundingTx` transaction. This requirement - /// ensures the reveal data provided in the extra data container + /// ensures the reveal data provided in the extra info container /// actually matches the given deposit. function validateDepositExtraInfo( DepositKey memory depositKey, address depositor, + bytes32 extraData, DepositExtraInfo memory depositExtraInfo ) internal view { bytes32 depositExtraFundingTxHash = abi @@ -725,39 +411,76 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { ) .hash256View(); - // Make sure the funding tx provided as part of deposit extra data + // Make sure the funding tx provided as part of deposit extra info // actually matches the deposit referred by the given deposit key. if (depositKey.fundingTxHash != depositExtraFundingTxHash) { revert("Extra info funding tx hash does not match"); } - bytes memory expectedScript = abi.encodePacked( - hex"14", // Byte length of depositor Ethereum address. - depositor, - hex"75", // OP_DROP - hex"08", // Byte length of blinding factor value. - depositExtraInfo.blindingFactor, - hex"75", // OP_DROP - hex"76", // OP_DUP - hex"a9", // OP_HASH160 - hex"14", // Byte length of a compressed Bitcoin public key hash. - depositExtraInfo.walletPubKeyHash, - hex"87", // OP_EQUAL - hex"63", // OP_IF - hex"ac", // OP_CHECKSIG - hex"67", // OP_ELSE - hex"76", // OP_DUP - hex"a9", // OP_HASH160 - hex"14", // Byte length of a compressed Bitcoin public key hash. - depositExtraInfo.refundPubKeyHash, - hex"88", // OP_EQUALVERIFY - hex"04", // Byte length of refund locktime value. - depositExtraInfo.refundLocktime, - hex"b1", // OP_CHECKLOCKTIMEVERIFY - hex"75", // OP_DROP - hex"ac", // OP_CHECKSIG - hex"68" // OP_ENDIF - ); + bytes memory expectedScript; + + if (extraData == bytes32(0)) { + // Regular deposit without 32-byte extra data. + expectedScript = abi.encodePacked( + hex"14", // Byte length of depositor Ethereum address. + depositor, + hex"75", // OP_DROP + hex"08", // Byte length of blinding factor value. + depositExtraInfo.blindingFactor, + hex"75", // OP_DROP + hex"76", // OP_DUP + hex"a9", // OP_HASH160 + hex"14", // Byte length of a compressed Bitcoin public key hash. + depositExtraInfo.walletPubKeyHash, + hex"87", // OP_EQUAL + hex"63", // OP_IF + hex"ac", // OP_CHECKSIG + hex"67", // OP_ELSE + hex"76", // OP_DUP + hex"a9", // OP_HASH160 + hex"14", // Byte length of a compressed Bitcoin public key hash. + depositExtraInfo.refundPubKeyHash, + hex"88", // OP_EQUALVERIFY + hex"04", // Byte length of refund locktime value. + depositExtraInfo.refundLocktime, + hex"b1", // OP_CHECKLOCKTIMEVERIFY + hex"75", // OP_DROP + hex"ac", // OP_CHECKSIG + hex"68" // OP_ENDIF + ); + } else { + // Deposit with 32-byte extra data. + expectedScript = abi.encodePacked( + hex"14", // Byte length of depositor Ethereum address. + depositor, + hex"75", // OP_DROP + hex"20", // Byte length of extra data. + extraData, + hex"75", // OP_DROP + hex"08", // Byte length of blinding factor value. + depositExtraInfo.blindingFactor, + hex"75", // OP_DROP + hex"76", // OP_DUP + hex"a9", // OP_HASH160 + hex"14", // Byte length of a compressed Bitcoin public key hash. + depositExtraInfo.walletPubKeyHash, + hex"87", // OP_EQUAL + hex"63", // OP_IF + hex"ac", // OP_CHECKSIG + hex"67", // OP_ELSE + hex"76", // OP_DUP + hex"a9", // OP_HASH160 + hex"14", // Byte length of a compressed Bitcoin public key hash. + depositExtraInfo.refundPubKeyHash, + hex"88", // OP_EQUALVERIFY + hex"04", // Byte length of refund locktime value. + depositExtraInfo.refundLocktime, + hex"b1", // OP_CHECKLOCKTIMEVERIFY + hex"75", // OP_DROP + hex"ac", // OP_CHECKSIG + hex"68" // OP_ENDIF + ); + } bytes memory fundingOutput = depositExtraInfo .fundingTx @@ -765,7 +488,7 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { .extractOutputAtIndex(depositKey.fundingOutputIndex); bytes memory fundingOutputHash = fundingOutput.extractHash(); - // Path that checks the deposit extra data validity in case the + // Path that checks the deposit extra info validity in case the // referred deposit is a P2SH. if ( // slither-disable-next-line calls-loop @@ -775,7 +498,7 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { return; } - // Path that checks the deposit extra data validity in case the + // Path that checks the deposit extra info validity in case the // referred deposit is a P2WSH. if ( fundingOutputHash.length == 32 && @@ -787,78 +510,6 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { revert("Extra info funding output script does not match"); } - /// @notice Updates parameters related to redemption proposal. - /// @param _redemptionProposalValidity The new value of `redemptionProposalValidity`. - /// @param _redemptionRequestMinAge The new value of `redemptionRequestMinAge`. - /// @param _redemptionRequestTimeoutSafetyMargin The new value of - /// `redemptionRequestTimeoutSafetyMargin`. - /// @param _redemptionMaxSize The new value of `redemptionMaxSize`. - /// @param _redemptionProposalSubmissionGasOffset The new value of - /// `redemptionProposalSubmissionGasOffset`. - /// @dev Requirements: - /// - The caller must be the owner. - function updateRedemptionProposalParameters( - uint32 _redemptionProposalValidity, - uint32 _redemptionRequestMinAge, - uint32 _redemptionRequestTimeoutSafetyMargin, - uint16 _redemptionMaxSize, - uint32 _redemptionProposalSubmissionGasOffset - ) external onlyOwner { - redemptionProposalValidity = _redemptionProposalValidity; - redemptionRequestMinAge = _redemptionRequestMinAge; - redemptionRequestTimeoutSafetyMargin = _redemptionRequestTimeoutSafetyMargin; - redemptionMaxSize = _redemptionMaxSize; - redemptionProposalSubmissionGasOffset = _redemptionProposalSubmissionGasOffset; - - emit RedemptionProposalParametersUpdated( - _redemptionProposalValidity, - _redemptionRequestMinAge, - _redemptionRequestTimeoutSafetyMargin, - _redemptionMaxSize, - _redemptionProposalSubmissionGasOffset - ); - } - - /// @notice Submits a redemption proposal. Locks the target wallet - /// for a specific time, equal to the proposal validity period. - /// This function does not store the proposal in the state but - /// just emits an event that serves as a guiding light for wallet - /// off-chain members. Wallet members are supposed to validate - /// the proposal on their own, before taking any action. - /// @param proposal The redemption proposal - /// @dev Requirements: - /// - The caller is a coordinator, - /// - The wallet is not time-locked. - function submitRedemptionProposal(RedemptionProposal calldata proposal) - public - onlyCoordinator - onlyAfterWalletLock(proposal.walletPubKeyHash) - { - walletLock[proposal.walletPubKeyHash] = WalletLock( - /* solhint-disable-next-line not-rely-on-time */ - uint32(block.timestamp) + redemptionProposalValidity, - WalletAction.Redemption - ); - - emit RedemptionProposalSubmitted(proposal, msg.sender); - } - - /// @notice Wraps `submitRedemptionProposal` call and reimburses the - /// caller's transaction cost. - /// @dev See `submitRedemptionProposal` function documentation. - function submitRedemptionProposalWithReimbursement( - RedemptionProposal calldata proposal - ) external { - uint256 gasStart = gasleft(); - - submitRedemptionProposal(proposal); - - reimbursementPool.refund( - (gasStart - gasleft()) + redemptionProposalSubmissionGasOffset, - msg.sender - ); - } - /// @notice View function encapsulating the main rules of a valid redemption /// proposal. This function is meant to facilitate the off-chain /// validation of the incoming proposals. Thanks to it, most @@ -897,7 +548,7 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { require(requestsCount > 0, "Redemption below the min size"); require( - requestsCount <= redemptionMaxSize, + requestsCount <= REDEMPTION_MAX_SIZE, "Redemption exceeds the max size" ); @@ -960,7 +611,7 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { require( /* solhint-disable-next-line not-rely-on-time */ block.timestamp > - redemptionRequest.requestedAt + redemptionRequestMinAge, + redemptionRequest.requestedAt + REDEMPTION_REQUEST_MIN_AGE, "Redemption request min age not achieved yet" ); @@ -971,7 +622,7 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { require( /* solhint-disable-next-line not-rely-on-time */ block.timestamp < - requestTimeout - redemptionRequestTimeoutSafetyMargin, + requestTimeout - REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN, "Redemption request timeout safety margin is not preserved" ); @@ -1000,4 +651,218 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable { return true; } + + /// @notice View function encapsulating the main rules of a valid moving + /// funds proposal. This function is meant to facilitate the + /// off-chain validation of the incoming proposals. Thanks to it, + /// most of the work can be done using a single readonly contract + /// call. + /// @param proposal The moving funds proposal to validate. + /// @param walletMainUtxo The main UTXO of the source wallet. + /// @return True if the proposal is valid. Reverts otherwise. + /// @dev Notice that this function is meant to be invoked after the moving + /// funds commitment has already been submitted. This function skips + /// some checks related to the moving funds procedure as they were + /// already checked on the commitment submission. + /// Requirements: + /// - The source wallet must be in the MovingFunds state, + /// - The target wallets commitment must be submitted, + /// - The target wallets commitment hash must match the target wallets + /// from the proposal, + /// - The source wallet BTC balance must be equal to or greater than + /// `movingFundsDustThreshold`, + /// - The proposed moving funds transaction fee must be greater than + /// zero, + /// - The proposed moving funds transaction fee must not exceed the + /// maximum total fee allowed for moving funds. + function validateMovingFundsProposal( + MovingFundsProposal calldata proposal, + BitcoinTx.UTXO calldata walletMainUtxo + ) external view returns (bool) { + Wallets.Wallet memory sourceWallet = bridge.wallets( + proposal.walletPubKeyHash + ); + + // Make sure the source wallet is in MovingFunds state. + require( + sourceWallet.state == Wallets.WalletState.MovingFunds, + "Source wallet is not in MovingFunds state" + ); + + // Make sure the moving funds commitment has been submitted and + // the commitment hash matches the target wallets from the proposal. + require( + sourceWallet.movingFundsTargetWalletsCommitmentHash != bytes32(0), + "Target wallets commitment is not submitted" + ); + + require( + sourceWallet.movingFundsTargetWalletsCommitmentHash == + keccak256(abi.encodePacked(proposal.targetWallets)), + "Target wallets do not match target wallets commitment hash" + ); + + ( + uint64 movingFundsTxMaxTotalFee, + uint64 movingFundsDustThreshold, + , + , + , + , + , + , + , + , + + ) = bridge.movingFundsParameters(); + + // Make sure the source wallet balance is correct. + uint64 sourceWalletBtcBalance = getWalletBtcBalance( + sourceWallet.mainUtxoHash, + walletMainUtxo + ); + + require( + sourceWalletBtcBalance >= movingFundsDustThreshold, + "Source wallet BTC balance is below the moving funds dust threshold" + ); + + // Make sure the proposed fee is valid. + require( + proposal.movingFundsTxFee > 0, + "Proposed transaction fee cannot be zero" + ); + + require( + proposal.movingFundsTxFee <= movingFundsTxMaxTotalFee, + "Proposed transaction fee is too high" + ); + + return true; + } + + /// @notice Calculates the Bitcoin balance of a wallet based on its main + /// UTXO. + /// @param walletMainUtxoHash The hash of the wallet's main UTXO. + /// @param walletMainUtxo The detailed data of the wallet's main UTXO. + /// @return walletBtcBalance The calculated Bitcoin balance of the wallet. + function getWalletBtcBalance( + bytes32 walletMainUtxoHash, + BitcoinTx.UTXO calldata walletMainUtxo + ) internal view returns (uint64 walletBtcBalance) { + // If the wallet has a main UTXO hash set, cross-check it with the + // provided plain-text parameter and get the transaction output value + // as BTC balance. Otherwise, the BTC balance is just zero. + if (walletMainUtxoHash != bytes32(0)) { + require( + keccak256( + abi.encodePacked( + walletMainUtxo.txHash, + walletMainUtxo.txOutputIndex, + walletMainUtxo.txOutputValue + ) + ) == walletMainUtxoHash, + "Invalid wallet main UTXO data" + ); + + walletBtcBalance = walletMainUtxo.txOutputValue; + } + + return walletBtcBalance; + } + + /// @notice View function encapsulating the main rules of a valid moved + /// funds sweep proposal. This function is meant to facilitate the + /// off-chain validation of the incoming proposals. Thanks to it, + /// most of the work can be done using a single readonly contract + /// call. + /// @param proposal The moved funds sweep proposal to validate. + /// @return True if the proposal is valid. Reverts otherwise. + /// @dev Requirements: + /// - The source wallet must be in the Live or MovingFunds state, + /// - The moved funds sweep request identified by the proposed + /// transaction hash and output index must be in the Pending state, + /// - The transaction hash and output index from the proposal must + /// identify a moved funds sweep request in the Pending state, + /// - The transaction hash and output index from the proposal must + /// identify a moved funds sweep request that belongs to the wallet, + /// - The proposed moved funds sweep transaction fee must be greater + /// than zero, + /// - The proposed moved funds sweep transaction fee must not exceed + /// the maximum total fee allowed for moved funds sweep. + function validateMovedFundsSweepProposal( + MovedFundsSweepProposal calldata proposal + ) external view returns (bool) { + Wallets.Wallet memory wallet = bridge.wallets( + proposal.walletPubKeyHash + ); + + // Make sure the wallet is in Live or MovingFunds state. + require( + wallet.state == Wallets.WalletState.Live || + wallet.state == Wallets.WalletState.MovingFunds, + "Source wallet is not in Live or MovingFunds state" + ); + + // Make sure the moved funds sweep request is valid. + uint256 sweepRequestKeyUint = uint256( + keccak256( + abi.encodePacked( + proposal.movingFundsTxHash, + proposal.movingFundsTxOutputIndex + ) + ) + ); + + MovingFunds.MovedFundsSweepRequest memory sweepRequest = bridge + .movedFundsSweepRequests(sweepRequestKeyUint); + + require( + sweepRequest.state == + MovingFunds.MovedFundsSweepRequestState.Pending, + "Sweep request is not in Pending state" + ); + + require( + sweepRequest.walletPubKeyHash == proposal.walletPubKeyHash, + "Sweep request does not belong to the wallet" + ); + + // Make sure the proposed fee is valid. + (, , , , , , , uint64 movedFundsSweepTxMaxTotalFee, , , ) = bridge + .movingFundsParameters(); + + require( + proposal.movedFundsSweepTxFee > 0, + "Proposed transaction fee cannot be zero" + ); + + require( + proposal.movedFundsSweepTxFee <= movedFundsSweepTxMaxTotalFee, + "Proposed transaction fee is too high" + ); + + return true; + } + + /// @notice View function encapsulating the main rules of a valid heartbeat + /// proposal. This function is meant to facilitate the off-chain + /// validation of the incoming proposals. Thanks to it, most + /// of the work can be done using a single readonly contract call. + /// @param proposal The heartbeat proposal to validate. + /// @return True if the proposal is valid. Reverts otherwise. + /// @dev Requirements: + /// - The message to sign is a valid heartbeat message. + function validateHeartbeatProposal(HeartbeatProposal calldata proposal) + external + view + returns (bool) + { + require( + Heartbeat.isValidHeartbeatMessage(proposal.message), + "Not a valid heartbeat message" + ); + + return true; + } } diff --git a/solidity/deploy/37_deploy_light_relay_maintainer_proxy.ts b/solidity/deploy/34_deploy_light_relay_maintainer_proxy.ts similarity index 100% rename from solidity/deploy/37_deploy_light_relay_maintainer_proxy.ts rename to solidity/deploy/34_deploy_light_relay_maintainer_proxy.ts diff --git a/solidity/deploy/34_deploy_wallet_coordinator.ts b/solidity/deploy/34_deploy_wallet_coordinator.ts deleted file mode 100644 index dac4c41c2..000000000 --- a/solidity/deploy/34_deploy_wallet_coordinator.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { HardhatRuntimeEnvironment } from "hardhat/types" -import { DeployFunction } from "hardhat-deploy/types" - -const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { - const { deployments, ethers, getNamedAccounts, helpers } = hre - const { deployer } = await getNamedAccounts() - - const Bridge = await deployments.get("Bridge") - - const [walletCoordinator, proxyDeployment] = - await helpers.upgrades.deployProxy("WalletCoordinator", { - contractName: "WalletCoordinator", - initializerArgs: [Bridge.address], - factoryOpts: { - signer: await ethers.getSigner(deployer), - }, - proxyOpts: { - kind: "transparent", - }, - }) - - if (hre.network.tags.etherscan) { - // We use `verify` instead of `verify:verify` as the `verify` task is defined - // in "@openzeppelin/hardhat-upgrades" to perform Etherscan verification - // of Proxy and Implementation contracts. - await hre.run("verify", { - address: proxyDeployment.address, - constructorArgsParams: proxyDeployment.args, - }) - } - - if (hre.network.tags.tenderly) { - await hre.tenderly.verify({ - name: "WalletCoordinator", - address: walletCoordinator.address, - }) - } -} - -export default func - -func.tags = ["WalletCoordinator"] -func.dependencies = ["Bridge"] diff --git a/solidity/deploy/35_add_coordinator_address.ts b/solidity/deploy/35_add_coordinator_address.ts deleted file mode 100644 index 0f52b056e..000000000 --- a/solidity/deploy/35_add_coordinator_address.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { HardhatRuntimeEnvironment } from "hardhat/types" -import { DeployFunction } from "hardhat-deploy/types" - -const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { - const { getNamedAccounts, deployments } = hre - const { execute } = deployments - const { deployer, coordinator } = await getNamedAccounts() - - await execute( - "WalletCoordinator", - { from: deployer, log: true, waitConfirmations: 1 }, - "addCoordinator", - coordinator - ) -} - -export default func - -func.tags = ["AddCoordinatorAddress"] -func.dependencies = ["WalletCoordinator"] diff --git a/solidity/deploy/38_authorize_maintainer_in_light_relay_maintainer_proxy.ts b/solidity/deploy/35_authorize_maintainer_in_light_relay_maintainer_proxy.ts similarity index 100% rename from solidity/deploy/38_authorize_maintainer_in_light_relay_maintainer_proxy.ts rename to solidity/deploy/35_authorize_maintainer_in_light_relay_maintainer_proxy.ts diff --git a/solidity/deploy/39_transfer_light_relay_maintainer_proxy_ownership.ts b/solidity/deploy/36_transfer_light_relay_maintainer_proxy_ownership.ts similarity index 100% rename from solidity/deploy/39_transfer_light_relay_maintainer_proxy_ownership.ts rename to solidity/deploy/36_transfer_light_relay_maintainer_proxy_ownership.ts diff --git a/solidity/deploy/36_transfer_wallet_coordinator_ownership.ts b/solidity/deploy/36_transfer_wallet_coordinator_ownership.ts deleted file mode 100644 index 7c8673bfc..000000000 --- a/solidity/deploy/36_transfer_wallet_coordinator_ownership.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HardhatRuntimeEnvironment } from "hardhat/types" -import { DeployFunction } from "hardhat-deploy/types" - -const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { - const { getNamedAccounts, helpers } = hre - const { deployer, governance } = await getNamedAccounts() - - await helpers.ownable.transferOwnership( - "WalletCoordinator", - governance, - deployer - ) -} - -export default func - -func.tags = ["TransferWalletCoordinatorOwnership"] -func.dependencies = ["AddCoordinatorAddress"] -func.runAtTheEnd = true diff --git a/solidity/deploy/40_authorize_light_relay_maintainer_proxy_in_reimbursement_pool.ts b/solidity/deploy/37_authorize_light_relay_maintainer_proxy_in_reimbursement_pool.ts similarity index 100% rename from solidity/deploy/40_authorize_light_relay_maintainer_proxy_in_reimbursement_pool.ts rename to solidity/deploy/37_authorize_light_relay_maintainer_proxy_in_reimbursement_pool.ts diff --git a/solidity/deploy/41_authorize_light_relay_maintainer_proxy_in_light_relay.ts b/solidity/deploy/38_authorize_light_relay_maintainer_proxy_in_light_relay.ts similarity index 100% rename from solidity/deploy/41_authorize_light_relay_maintainer_proxy_in_light_relay.ts rename to solidity/deploy/38_authorize_light_relay_maintainer_proxy_in_light_relay.ts diff --git a/solidity/deploy/39_deploy_wallet_proposal_validator.ts b/solidity/deploy/39_deploy_wallet_proposal_validator.ts new file mode 100644 index 000000000..2dbe998f7 --- /dev/null +++ b/solidity/deploy/39_deploy_wallet_proposal_validator.ts @@ -0,0 +1,33 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types" +import { DeployFunction } from "hardhat-deploy/types" + +const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const { deployments, helpers, getNamedAccounts } = hre + const { deploy } = deployments + const { deployer } = await getNamedAccounts() + + const Bridge = await deployments.get("Bridge") + + const walletProposalValidator = await deploy("WalletProposalValidator", { + from: deployer, + args: [Bridge.address], + log: true, + waitConfirmations: 1, + }) + + if (hre.network.tags.etherscan) { + await helpers.etherscan.verify(walletProposalValidator) + } + + if (hre.network.tags.tenderly) { + await hre.tenderly.verify({ + name: "WalletProposalValidator", + address: walletProposalValidator.address, + }) + } +} + +export default func + +func.tags = ["WalletProposalValidator"] +func.dependencies = ["Bridge"] diff --git a/solidity/deploy/81_upgrade_wallet_coordinator_v2.ts b/solidity/deploy/81_upgrade_wallet_coordinator_v2.ts deleted file mode 100644 index 09f34895a..000000000 --- a/solidity/deploy/81_upgrade_wallet_coordinator_v2.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Artifact, HardhatRuntimeEnvironment } from "hardhat/types" -import { DeployFunction, Deployment } from "hardhat-deploy/types" -import { ContractFactory } from "ethers" -import { ProxyAdmin, WalletCoordinator } from "../typechain" - -const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { - const { ethers, helpers, deployments } = hre - - const { deployer } = await helpers.signers.getNamedSigners() - - const proxyDeployment: Deployment = await deployments.get("WalletCoordinator") - const implementationContractFactory: ContractFactory = - await ethers.getContractFactory("WalletCoordinator", { - signer: deployer, - }) - - // Deploy new implementation contract - const newImplementationAddress: string = (await hre.upgrades.prepareUpgrade( - proxyDeployment, - implementationContractFactory, - { - kind: "transparent", - } - )) as string - - deployments.log( - `new implementation contract deployed at: ${newImplementationAddress}` - ) - - // Assemble proxy upgrade transaction. - const proxyAdmin: ProxyAdmin = - (await hre.upgrades.admin.getInstance()) as ProxyAdmin - const proxyAdminOwner = await proxyAdmin.owner() - - const upgradeTxData = await proxyAdmin.interface.encodeFunctionData( - "upgrade", - [proxyDeployment.address, newImplementationAddress] - ) - - deployments.log( - `proxy admin owner ${proxyAdminOwner} is required to upgrade proxy implementation with transaction:\n` + - `\t\tfrom: ${proxyAdminOwner}\n` + - `\t\tto: ${proxyAdmin.address}\n` + - `\t\tdata: ${upgradeTxData}` - ) - - // Update Deployment Artifact - const walletCoordinatorArtifact: Artifact = - hre.artifacts.readArtifactSync("WalletCoordinator") - - await deployments.save("WalletCoordinator", { - ...proxyDeployment, - abi: walletCoordinatorArtifact.abi, - implementation: newImplementationAddress, - }) - - // Assemble parameters upgrade transaction. - const walletCoordinator: WalletCoordinator = - await helpers.contracts.getContract("WalletCoordinator") - - const walletCoordinatorOwner = await walletCoordinator.owner() - - const updateRedemptionProposalParametersTxData = - walletCoordinator.interface.encodeFunctionData( - "updateRedemptionProposalParameters", - [7200, 600, 7200, 20, 20000] - ) - - deployments.log( - `WalletCoordinator owner ${walletCoordinatorOwner} is required to update redemption proposal parameters with transaction:\n` + - `\t\tfrom: ${walletCoordinatorOwner}\n` + - `\t\tto: ${walletCoordinator.address}\n` + - `\t\tdata: ${updateRedemptionProposalParametersTxData}` - ) - - if (hre.network.tags.etherscan) { - // We use `verify` instead of `verify:verify` as the `verify` task is defined - // in "@openzeppelin/hardhat-upgrades" to perform Etherscan verification - // of Proxy and Implementation contracts. - await hre.run("verify", { - address: newImplementationAddress, - constructorArgsParams: proxyDeployment.args, - }) - } - - if (hre.network.tags.tenderly) { - await hre.tenderly.verify({ - name: "WalletCoordinator", - address: newImplementationAddress, - }) - } -} - -export default func - -func.tags = ["UpgradeWalletCoordinator"] -// When running an upgrade uncomment the skip below and run the command: -// yarn deploy --tags UpgradeWalletCoordinator --network -func.skip = async () => true diff --git a/solidity/hardhat.config.ts b/solidity/hardhat.config.ts index 672999953..93ac97c18 100644 --- a/solidity/hardhat.config.ts +++ b/solidity/hardhat.config.ts @@ -219,11 +219,6 @@ const config: HardhatUserConfig = { sepolia: 0, // We are not setting SPV maintainer for mainnet in deployment scripts. }, - coordinator: { - default: 9, - sepolia: "0x4815cd81fFc21039a25aCFbD97CE75cCE8579042", - mainnet: "0x0595acCca29654c43Bd67E18578b30a405265234", - }, v1Redeemer: { default: 10, sepolia: 0, diff --git a/solidity/test/bridge/Bridge.Deposit.test.ts b/solidity/test/bridge/Bridge.Deposit.test.ts index 218f0b126..a15799eed 100644 --- a/solidity/test/bridge/Bridge.Deposit.test.ts +++ b/solidity/test/bridge/Bridge.Deposit.test.ts @@ -16,7 +16,10 @@ import type { IVault, BridgeGovernance, } from "../../typechain" -import type { DepositRevealInfoStruct } from "../../typechain/Bridge" +import type { + DepositRevealInfoStruct, + InfoStruct as BitcoinTxInfoStruct, +} from "../../typechain/Bridge" import bridgeFixture from "../fixtures/bridge" import { constants, walletState } from "../fixtures" import { @@ -74,12 +77,19 @@ describe("Bridge - Deposit", () => { await bridge.setDepositRevealAheadPeriod(0) }) - describe("revealDeposit", () => { + type RevealDepositFixture = { + P2SHFundingTx: BitcoinTxInfoStruct + P2WSHFundingTx: BitcoinTxInfoStruct + depositorAddress: string + reveal: DepositRevealInfoStruct + extraData?: string + } + + // Fixture used for revealDeposit test scenario. + const revealDepositFixture: RevealDepositFixture = { // Data of a proper P2SH deposit funding transaction. Little-endian hash is: - // 0x17350f81cdb61cd8d7014ad1507d4af8d032b75812cf88d2c636c1c022991af2 and - // this is the same as `expectedP2SHDeposit.transaction` mentioned in - // tbtc-ts/test/deposit.test.ts file. - const P2SHFundingTx = { + // 0x17350f81cdb61cd8d7014ad1507d4af8d032b75812cf88d2c636c1c022991af2 + P2SHFundingTx: { version: "0x01000000", inputVector: "0x018348cdeb551134fe1f19d378a8adec9b146671cb67b945b71bf56b20d" + @@ -89,13 +99,10 @@ describe("Bridge - Deposit", () => { "da455877ed73b00000000001600147ac2d9378a1c47e589dfb8095ca95ed2" + "140d2726", locktime: "0x00000000", - } - + }, // Data of a proper P2WSH deposit funding transaction. Little-endian hash is: - // 0x6a81de17ce3da1eadc833c5fd9d85dac307d3b78235f57afbcd9f068fc01b99e and - // this is the same as `expectedP2WSHDeposit.transaction` mentioned in - // tbtc-ts/test/deposit.test.ts file. - const P2WSHFundingTx = { + // 0x6a81de17ce3da1eadc833c5fd9d85dac307d3b78235f57afbcd9f068fc01b99e. + P2WSHFundingTx: { version: "0x01000000", inputVector: "0x018348cdeb551134fe1f19d378a8adec9b146671cb67b945b71bf56b20d" + @@ -105,12 +112,56 @@ describe("Bridge - Deposit", () => { "b87d2b6a37d6c3b64722be83c636f10d73b00000000001600147ac2d9378a" + "1c47e589dfb8095ca95ed2140d2726", locktime: "0x00000000", - } + }, + // Data matching the redeem script locking the funding output of + // P2SHFundingTx and P2WSHFundingTx. + depositorAddress: "0x934B98637cA318a4D6E7CA6ffd1690b8e77df637", + reveal: { + fundingOutputIndex: 0, + blindingFactor: "0xf9f0c90d00039523", + // HASH160 of 03989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9. + walletPubKeyHash: "0x8db50eb52063ea9d98b3eac91489a90f738986f6", + // HASH160 of 0300d6f28a2f6bf9836f57fcda5d284c9a8f849316119779f0d6090830d97763a9. + refundPubKeyHash: "0x28e081f285138ccbe389c1eb8985716230129f89", + refundLocktime: "0x60bcea61", + vault: "0x594cfd89700040163727828AE20B52099C58F02C", + }, + } + // Fixture used for revealDepositWithExtraData test scenario. + const revealDepositWithExtraDataFixture: RevealDepositFixture = { + // Data of a proper P2SH deposit funding transaction embedding some + // extra data. Little-endian hash is: + // 0x6383cd1829260b6034cd12bad36171748e8c3c6a8d57fcb6463c62f96116dfbc. + P2SHFundingTx: { + version: "0x01000000", + inputVector: + "0x018348cdeb551134fe1f19d378a8adec9b146671cb67b945b71bf56b20d" + + "c2b952f0100000000ffffffff", + outputVector: + "0x02102700000000000017a9149fe6615a307aa1d7eee668c1227802b2fbc" + + "aa919877ed73b00000000001600147ac2d9378a1c47e589dfb8095ca95ed2" + + "140d2726", + locktime: "0x00000000", + }, + // Data of a proper P2WSH deposit funding transaction embedding some + // extra data. Little-endian hash is: + // 0xc9312103d0d8d55344ef2d51acc409e004fbaaba7893b1725fa505ff73795732. + P2WSHFundingTx: { + version: "0x01000000", + inputVector: + "0x018348cdeb551134fe1f19d378a8adec9b146671cb67b945b71bf56b20d" + + "c2b952f0100000000ffffffff", + outputVector: + "0x021027000000000000220020bfaeddba12b0de6feeb649af76376876bc1" + + "feb6c2248fbfef9293ba3ac51bb4a10d73b00000000001600147ac2d9378a" + + "1c47e589dfb8095ca95ed2140d2726", + locktime: "0x00000000", + }, // Data matching the redeem script locking the funding output of // P2SHFundingTx and P2WSHFundingTx. - const depositorAddress = "0x934B98637cA318a4D6E7CA6ffd1690b8e77df637" - const reveal = { + depositorAddress: "0x934B98637cA318a4D6E7CA6ffd1690b8e77df637", + reveal: { fundingOutputIndex: 0, blindingFactor: "0xf9f0c90d00039523", // HASH160 of 03989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9. @@ -119,7 +170,15 @@ describe("Bridge - Deposit", () => { refundPubKeyHash: "0x28e081f285138ccbe389c1eb8985716230129f89", refundLocktime: "0x60bcea61", vault: "0x594cfd89700040163727828AE20B52099C58F02C", - } + }, + // sha256("fancy extra data") + extraData: + "0xa9b38ea6435c8941d6eda6a46b68e3e2117196995bd154ab55196396b03d9bda", + } + + describe("revealDeposit", () => { + const { P2SHFundingTx, P2WSHFundingTx, depositorAddress, reveal } = + revealDepositFixture let depositor: SignerWithAddress @@ -208,6 +267,10 @@ describe("Bridge - Deposit", () => { expect(deposit.treasuryFee).to.be.equal(5) // Swept time should be unset. expect(deposit.sweptAt).to.be.equal(0) + // Extra data must not be set. + expect(deposit.extraData).to.be.equal( + ethers.constants.HashZero + ) }) it("should emit DepositRevealed event", async () => { @@ -306,6 +369,11 @@ describe("Bridge - Deposit", () => { // value of the `depositTreasuryFeeDivisor`. // The divisor is 0 so the treasury fee is 0 as well. expect(deposit.treasuryFee).to.be.equal(0) + + // Extra data must not be set. + expect(deposit.extraData).to.be.equal( + ethers.constants.HashZero + ) }) it("should accept the deposit", async () => { @@ -425,6 +493,18 @@ describe("Bridge - Deposit", () => { }) } ) + + context("when funding transaction embeds extra data", () => { + it("should revert", async () => { + await expect( + bridge.connect(depositor).revealDeposit( + // Use a transaction that embeds extra data in the deposit script. + revealDepositWithExtraDataFixture.P2SHFundingTx, + reveal + ) + ).to.be.revertedWith("Wrong 20-byte script hash") + }) + }) }) context("when funding transaction is P2WSH", () => { @@ -476,6 +556,10 @@ describe("Bridge - Deposit", () => { expect(deposit.treasuryFee).to.be.equal(5) // Swept time should be unset. expect(deposit.sweptAt).to.be.equal(0) + // Extra data must not be set. + expect(deposit.extraData).to.be.equal( + ethers.constants.HashZero + ) }) it("should emit DepositRevealed event", async () => { @@ -607,6 +691,18 @@ describe("Bridge - Deposit", () => { }) } ) + + context("when funding transaction embeds extra data", () => { + it("should revert", async () => { + await expect( + bridge.connect(depositor).revealDeposit( + // Use a transaction that embeds extra data in the deposit script. + revealDepositWithExtraDataFixture.P2WSHFundingTx, + reveal + ) + ).to.be.revertedWith("Wrong 32-byte script hash") + }) + }) }) context("when funding transaction is neither P2SH nor P2WSH", () => { @@ -781,6 +877,856 @@ describe("Bridge - Deposit", () => { }) }) + describe("revealDepositWithExtraData", () => { + const { + P2SHFundingTx, + P2WSHFundingTx, + depositorAddress, + reveal, + extraData, + } = revealDepositWithExtraDataFixture + + let depositor: SignerWithAddress + + before(async () => { + depositor = await impersonateAccount(depositorAddress, { + from: governance, + value: 10, + }) + }) + + context("when extra data is non-zero", () => { + context("when wallet is in Live state", () => { + before(async () => { + await createSnapshot() + + await bridgeGovernance + .connect(governance) + .setVaultStatus(reveal.vault, true) + + // Simulate the wallet is a Live one and is known in the system. + await bridge.setWallet(reveal.walletPubKeyHash, { + ecdsaWalletID: ethers.constants.HashZero, + mainUtxoHash: ethers.constants.HashZero, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: ethers.constants.HashZero, + }) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when reveal ahead period validation is disabled", () => { + context("when funding transaction is P2SH", () => { + context("when funding output script hash is correct", () => { + context("when deposit was not revealed yet", () => { + context("when amount is not below the dust threshold", () => { + context("when deposit is routed to a trusted vault", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + tx = await bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + reveal, + extraData + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should store proper deposit data", async () => { + // Deposit key is keccak256(fundingTxHash | fundingOutputIndex). + const depositKey = ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [ + "0x6383cd1829260b6034cd12bad36171748e8c3c6a8d57fcb6463c62f96116dfbc", + reveal.fundingOutputIndex, + ] + ) + + const deposit = await bridge.deposits(depositKey) + + // Depositor address, same as in `reveal.depositor`. + expect(deposit.depositor).to.be.equal( + "0x934B98637cA318a4D6E7CA6ffd1690b8e77df637" + ) + // Deposit amount in satoshi. In this case it's 10000 satoshi + // because the P2SH deposit transaction set this value for the + // funding output. + expect(deposit.amount).to.be.equal(10000) + // Revealed time should be set. + expect(deposit.revealedAt).to.be.equal( + await lastBlockTime() + ) + // Deposit vault, same as in `reveal.vault`. + expect(deposit.vault).to.be.equal( + "0x594cfd89700040163727828AE20B52099C58F02C" + ) + // Treasury fee should be computed according to the current + // value of the `depositTreasuryFeeDivisor`. + expect(deposit.treasuryFee).to.be.equal(5) + // Swept time should be unset. + expect(deposit.sweptAt).to.be.equal(0) + // Extra data must be set. + expect(deposit.extraData).to.be.equal(extraData) + }) + + it("should emit DepositRevealed event", async () => { + await expect(tx) + .to.emit(bridge, "DepositRevealed") + .withArgs( + "0x6383cd1829260b6034cd12bad36171748e8c3c6a8d57fcb6463c62f96116dfbc", + reveal.fundingOutputIndex, + "0x934B98637cA318a4D6E7CA6ffd1690b8e77df637", + 10000, + "0xf9f0c90d00039523", + "0x8db50eb52063ea9d98b3eac91489a90f738986f6", + "0x28e081f285138ccbe389c1eb8985716230129f89", + "0x60bcea61", + reveal.vault + ) + }) + }) + + context("when deposit is not routed to a vault", () => { + let tx: ContractTransaction + let nonRoutedReveal: DepositRevealInfoStruct + + before(async () => { + await createSnapshot() + + nonRoutedReveal = { ...reveal } + nonRoutedReveal.vault = ZERO_ADDRESS + tx = await bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + nonRoutedReveal, + extraData + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should accept the deposit", async () => { + await expect(tx) + .to.emit(bridge, "DepositRevealed") + .withArgs( + "0x6383cd1829260b6034cd12bad36171748e8c3c6a8d57fcb6463c62f96116dfbc", + reveal.fundingOutputIndex, + "0x934B98637cA318a4D6E7CA6ffd1690b8e77df637", + 10000, + "0xf9f0c90d00039523", + "0x8db50eb52063ea9d98b3eac91489a90f738986f6", + "0x28e081f285138ccbe389c1eb8985716230129f89", + "0x60bcea61", + ZERO_ADDRESS + ) + }) + }) + + context("when deposit treasury fee is zero", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + await bridgeGovernance + .connect(governance) + .beginDepositTreasuryFeeDivisorUpdate(0) + await helpers.time.increaseTime(constants.governanceDelay) + await bridgeGovernance + .connect(governance) + .finalizeDepositTreasuryFeeDivisorUpdate() + + tx = await bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + reveal, + extraData + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should store proper deposit data", async () => { + // Deposit key is keccak256(fundingTxHash | fundingOutputIndex). + const depositKey = ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [ + "0x6383cd1829260b6034cd12bad36171748e8c3c6a8d57fcb6463c62f96116dfbc", + reveal.fundingOutputIndex, + ] + ) + + const deposit = await bridge.deposits(depositKey) + + // Deposit amount in satoshi. In this case it's 10000 satoshi + // because the P2SH deposit transaction set this value for the + // funding output. + expect(deposit.amount).to.be.equal(10000) + + // Treasury fee should be computed according to the current + // value of the `depositTreasuryFeeDivisor`. + // The divisor is 0 so the treasury fee is 0 as well. + expect(deposit.treasuryFee).to.be.equal(0) + // Extra data must be set. + expect(deposit.extraData).to.be.equal(extraData) + }) + + it("should accept the deposit", async () => { + await expect(tx) + .to.emit(bridge, "DepositRevealed") + .withArgs( + "0x6383cd1829260b6034cd12bad36171748e8c3c6a8d57fcb6463c62f96116dfbc", + reveal.fundingOutputIndex, + "0x934B98637cA318a4D6E7CA6ffd1690b8e77df637", + 10000, + "0xf9f0c90d00039523", + "0x8db50eb52063ea9d98b3eac91489a90f738986f6", + "0x28e081f285138ccbe389c1eb8985716230129f89", + "0x60bcea61", + reveal.vault + ) + }) + }) + + context( + "when deposit is routed to a non-trusted vault", + () => { + let nonTrustedVaultReveal + + before(async () => { + await createSnapshot() + + nonTrustedVaultReveal = { ...reveal } + nonTrustedVaultReveal.vault = + "0x92499afEAD6c41f757Ec3558D0f84bf7ec5aD967" + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + nonTrustedVaultReveal, + extraData + ) + ).to.be.revertedWith("Vault is not trusted") + }) + } + ) + }) + + context("when amount is below the dust threshold", () => { + before(async () => { + await createSnapshot() + + // The `P2SHFundingTx` used within this scenario has an output + // whose value is 10000 satoshi. To make the scenario happen, it + // is enough that the contract's deposit dust threshold is + // bigger by 1 satoshi. + await bridge.setDepositDustThreshold(10001) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + reveal, + extraData + ) + ).to.be.revertedWith("Deposit amount too small") + }) + }) + }) + + context("when deposit was already revealed", () => { + before(async () => { + await createSnapshot() + + await bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + reveal, + extraData + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + reveal, + extraData + ) + ).to.be.revertedWith("Deposit already revealed") + }) + }) + }) + + context("when funding output script hash is wrong", () => { + it("should revert", async () => { + // Corrupt reveal data by setting a wrong blinding factor + const corruptedReveal = { ...reveal } + corruptedReveal.blindingFactor = "0xf9f0c90d00039524" + + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + corruptedReveal, + extraData + ) + ).to.be.revertedWith("Wrong 20-byte script hash") + }) + }) + + context( + "when the caller address does not match the funding output script", + () => { + it("should revert", async () => { + const accounts = await getUnnamedAccounts() + const thirdParty = await ethers.getSigner(accounts[0]) + + await expect( + bridge + .connect(thirdParty) + .revealDepositWithExtraData( + P2SHFundingTx, + reveal, + extraData + ) + ).to.be.revertedWith("Wrong 20-byte script hash") + }) + } + ) + + context("when the revealed extra data do not match", () => { + it("should revert", async () => { + // Corrupt the extra data. + const corruptedExtraData = ethers.utils.keccak256(extraData) + + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + reveal, + corruptedExtraData + ) + ).to.be.revertedWith("Wrong 20-byte script hash") + }) + }) + + context( + "when funding transaction does not embed extra data", + () => { + it("should revert", async () => { + await expect( + bridge.connect(depositor).revealDepositWithExtraData( + // Use a transaction that doesn't embed extra data in the deposit script. + revealDepositFixture.P2SHFundingTx, + reveal, + extraData + ) + ).to.be.revertedWith("Wrong 20-byte script hash") + }) + } + ) + }) + + context("when funding transaction is P2WSH", () => { + context("when funding output script hash is correct", () => { + context("when deposit was not revealed yet", () => { + context("when deposit is routed to a trusted vault", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + tx = await bridge + .connect(depositor) + .revealDepositWithExtraData( + P2WSHFundingTx, + reveal, + extraData + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should store proper deposit data", async () => { + // Deposit key is keccak256(fundingTxHash | fundingOutputIndex). + const depositKey = ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [ + "0xc9312103d0d8d55344ef2d51acc409e004fbaaba7893b1725fa505ff73795732", + reveal.fundingOutputIndex, + ] + ) + + const deposit = await bridge.deposits(depositKey) + + // Depositor address, same as in `reveal.depositor`. + expect(deposit.depositor).to.be.equal( + "0x934B98637cA318a4D6E7CA6ffd1690b8e77df637" + ) + // Deposit amount in satoshi. In this case it's 10000 satoshi + // because the P2SH deposit transaction set this value for the + // funding output. + expect(deposit.amount).to.be.equal(10000) + // Revealed time should be set. + expect(deposit.revealedAt).to.be.equal( + await lastBlockTime() + ) + // Deposit vault, same as in `reveal.vault`. + expect(deposit.vault).to.be.equal( + "0x594cfd89700040163727828AE20B52099C58F02C" + ) + // Treasury fee should be computed according to the current + // value of the `depositTreasuryFeeDivisor`. + expect(deposit.treasuryFee).to.be.equal(5) + // Swept time should be unset. + expect(deposit.sweptAt).to.be.equal(0) + // Extra data must be set. + expect(deposit.extraData).to.be.equal(extraData) + }) + + it("should emit DepositRevealed event", async () => { + await expect(tx) + .to.emit(bridge, "DepositRevealed") + .withArgs( + "0xc9312103d0d8d55344ef2d51acc409e004fbaaba7893b1725fa505ff73795732", + reveal.fundingOutputIndex, + "0x934B98637cA318a4D6E7CA6ffd1690b8e77df637", + 10000, + "0xf9f0c90d00039523", + "0x8db50eb52063ea9d98b3eac91489a90f738986f6", + "0x28e081f285138ccbe389c1eb8985716230129f89", + "0x60bcea61", + reveal.vault + ) + }) + }) + + context("when deposit is not routed to a vault", () => { + let tx: ContractTransaction + let nonRoutedReveal: DepositRevealInfoStruct + + before(async () => { + await createSnapshot() + + nonRoutedReveal = { ...reveal } + nonRoutedReveal.vault = ZERO_ADDRESS + tx = await bridge + .connect(depositor) + .revealDepositWithExtraData( + P2WSHFundingTx, + nonRoutedReveal, + extraData + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should accept the deposit", async () => { + await expect(tx) + .to.emit(bridge, "DepositRevealed") + .withArgs( + "0xc9312103d0d8d55344ef2d51acc409e004fbaaba7893b1725fa505ff73795732", + reveal.fundingOutputIndex, + "0x934B98637cA318a4D6E7CA6ffd1690b8e77df637", + 10000, + "0xf9f0c90d00039523", + "0x8db50eb52063ea9d98b3eac91489a90f738986f6", + "0x28e081f285138ccbe389c1eb8985716230129f89", + "0x60bcea61", + ZERO_ADDRESS + ) + }) + }) + + context("when deposit is routed to a non-trusted vault", () => { + let nonTrustedVaultReveal + + before(async () => { + await createSnapshot() + + nonTrustedVaultReveal = { ...reveal } + nonTrustedVaultReveal.vault = + "0x92499afEAD6c41f757Ec3558D0f84bf7ec5aD967" + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2WSHFundingTx, + nonTrustedVaultReveal, + extraData + ) + ).to.be.revertedWith("Vault is not trusted") + }) + }) + }) + + context("when deposit was already revealed", () => { + before(async () => { + await createSnapshot() + + await bridge + .connect(depositor) + .revealDepositWithExtraData( + P2WSHFundingTx, + reveal, + extraData + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2WSHFundingTx, + reveal, + extraData + ) + ).to.be.revertedWith("Deposit already revealed") + }) + }) + }) + + context("when funding output script hash is wrong", () => { + it("should revert", async () => { + // Corrupt reveal data by setting a wrong blinding factor + const corruptedReveal = { ...reveal } + corruptedReveal.blindingFactor = "0xf9f0c90d00039524" + + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2WSHFundingTx, + corruptedReveal, + extraData + ) + ).to.be.revertedWith("Wrong 32-byte script hash") + }) + }) + + context( + "when the caller address does not match the funding output script", + () => { + it("should revert", async () => { + const accounts = await getUnnamedAccounts() + const thirdParty = await ethers.getSigner(accounts[0]) + + await expect( + bridge + .connect(thirdParty) + .revealDepositWithExtraData( + P2WSHFundingTx, + reveal, + extraData + ) + ).to.be.revertedWith("Wrong 32-byte script hash") + }) + } + ) + + context("when the revealed extra data do not match", () => { + it("should revert", async () => { + // Corrupt the extra data. + const corruptedExtraData = ethers.utils.keccak256(extraData) + + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2WSHFundingTx, + reveal, + corruptedExtraData + ) + ).to.be.revertedWith("Wrong 32-byte script hash") + }) + }) + + context( + "when funding transaction does not embed extra data", + () => { + it("should revert", async () => { + await expect( + bridge.connect(depositor).revealDepositWithExtraData( + // Use a transaction that doesn't embed extra data in the deposit script. + revealDepositFixture.P2WSHFundingTx, + reveal, + extraData + ) + ).to.be.revertedWith("Wrong 32-byte script hash") + }) + } + ) + }) + + context("when funding transaction is neither P2SH nor P2WSH", () => { + it("should revert", async () => { + // Corrupt transaction output data by making a 21-byte script hash. + const corruptedP2SHFundingTx = { ...P2SHFundingTx } + corruptedP2SHFundingTx.outputVector = + "0x02102700000000000017a9156a6ade1c799a3e5a59678e776f21be14d66dc" + + "15ed8877ed73b00000000001600147ac2d9378a1c47e589dfb8095ca95ed2" + + "140d2726" + + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + corruptedP2SHFundingTx, + reveal, + extraData + ) + ).to.be.revertedWith("Wrong script hash length") + }) + }) + }) + + context("when reveal ahead period validation is enabled", () => { + const encodeRefundLocktime = (refundLocktimeTimestamp: number) => { + const refundLocktimeTimestampHex = BigNumber.from( + refundLocktimeTimestamp + ) + .toHexString() + .substring(2) + const refundLocktimeBuffer = Buffer.from( + refundLocktimeTimestampHex, + "hex" + ) + return `0x${refundLocktimeBuffer.reverse().toString("hex")}` + } + + before(async () => { + await createSnapshot() + + // Reveal ahead period is disabled by default in this test suite + // (see root before clause). We need to enable it manually. + await bridge.setDepositRevealAheadPeriod( + constants.depositRevealAheadPeriod + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when reveal ahead period is preserved", () => { + it("should pass the refund locktime validation", async () => { + const now = Math.floor(Date.now() / 1000) + const refundLocktimeDuration = 2592000 // 30 days + const refundLocktimeTimestamp = now + refundLocktimeDuration + const latestPossibleRevealTimestamp = + refundLocktimeTimestamp - constants.depositRevealAheadPeriod + + const alteredReveal = { + ...reveal, + refundLocktime: encodeRefundLocktime(refundLocktimeTimestamp), + } + + await ethers.provider.send("evm_setNextBlockTimestamp", [ + BigNumber.from(latestPossibleRevealTimestamp).toHexString(), + ]) + + // We cannot assert that the reveal transaction succeeded since + // we modified the revealed refund locktime which differs from + // the one embedded in the transaction P2SH. We just make sure + // the execution does not revert on the refund locktime validation. + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2WSHFundingTx, + alteredReveal, + extraData + ) + ).to.be.not.revertedWith("Deposit refund locktime is too close") + }) + }) + + context("when reveal ahead period is not preserved", () => { + it("should revert", async () => { + const now = Math.floor(Date.now() / 1000) + const refundLocktimeDuration = 2592000 // 30 days + const refundLocktimeTimestamp = now + refundLocktimeDuration + const latestPossibleRevealTimestamp = + refundLocktimeTimestamp - constants.depositRevealAheadPeriod + + const alteredReveal = { + ...reveal, + refundLocktime: encodeRefundLocktime(refundLocktimeTimestamp), + } + + await ethers.provider.send("evm_setNextBlockTimestamp", [ + BigNumber.from(latestPossibleRevealTimestamp + 1).toHexString(), + ]) + + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2WSHFundingTx, + alteredReveal, + extraData + ) + ).to.be.revertedWith("Deposit refund locktime is too close") + }) + }) + + context( + "when refund locktime integer value is less than 500M", + () => { + it("should revert", async () => { + const alteredReveal = { + ...reveal, + refundLocktime: encodeRefundLocktime(499999999), + } + + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2WSHFundingTx, + alteredReveal, + extraData + ) + ).to.be.revertedWith("Refund locktime must be a value >= 500M") + }) + } + ) + }) + }) + + context("when wallet is not in Live state", () => { + const testData = [ + { + testName: "when wallet state is Unknown", + walletState: walletState.Unknown, + }, + { + testName: "when wallet state is MovingFunds", + walletState: walletState.MovingFunds, + }, + { + testName: "when the source wallet is in the Closing state", + walletState: walletState.Closing, + }, + { + testName: "when wallet state is Closed", + walletState: walletState.Closed, + }, + { + testName: "when wallet state is Terminated", + walletState: walletState.Terminated, + }, + ] + + testData.forEach((test) => { + context(test.testName, () => { + before(async () => { + await createSnapshot() + await bridge.setWallet(reveal.walletPubKeyHash, { + ecdsaWalletID: ethers.constants.HashZero, + mainUtxoHash: ethers.constants.HashZero, + pendingRedemptionsValue: 0, + createdAt: await lastBlockTime(), + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: test.walletState, + movingFundsTargetWalletsCommitmentHash: + ethers.constants.HashZero, + }) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData(P2SHFundingTx, reveal, extraData) + ).to.be.revertedWith("Wallet must be in Live state") + }) + }) + }) + }) + }) + + context("when extra data is zero", () => { + it("should revert", async () => { + await expect( + bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + reveal, + ethers.constants.HashZero + ) + ).to.be.revertedWith("Extra data must not be empty") + }) + }) + }) + describe("submitDepositSweepProof", () => { const walletDraft = { ecdsaWalletID: ethers.constants.HashZero, diff --git a/solidity/test/bridge/WalletCoordinator.test.ts b/solidity/test/bridge/WalletProposalValidator.test.ts similarity index 66% rename from solidity/test/bridge/WalletCoordinator.test.ts rename to solidity/test/bridge/WalletProposalValidator.test.ts index 3e4bf96a6..fa3e7ab5d 100644 --- a/solidity/test/bridge/WalletCoordinator.test.ts +++ b/solidity/test/bridge/WalletProposalValidator.test.ts @@ -1,21 +1,15 @@ import crypto from "crypto" -import { ethers, helpers, waffle } from "hardhat" +import { ethers, helpers } from "hardhat" import chai, { expect } from "chai" import { FakeContract, smock } from "@defi-wonderland/smock" -import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" -import { BigNumber, BigNumberish, BytesLike, ContractTransaction } from "ethers" -import type { - WalletCoordinator, - Bridge, - ReimbursementPool, - IWalletRegistry, -} from "../../typechain" -import { walletAction, walletState } from "../fixtures" +import { BigNumber, BigNumberish, BytesLike } from "ethers" +import type { Bridge, WalletProposalValidator } from "../../typechain" +import { walletState, movedFundsSweepRequestState } from "../fixtures" +import { NO_MAIN_UTXO } from "../data/deposit-sweep" chai.use(smock.matchers) -const { provider } = waffle -const { lastBlockTime, increaseTime } = helpers.time +const { lastBlockTime } = helpers.time const { createSnapshot, restoreSnapshot } = helpers.snapshot const { AddressZero, HashZero } = ethers.constants @@ -35,1021 +29,1274 @@ const emptyDepositExtraInfo = { refundLocktime: "0x00000000", } -describe("WalletCoordinator", () => { - let owner: SignerWithAddress - let thirdParty: SignerWithAddress - - let walletRegistry: FakeContract +describe("WalletProposalValidator", () => { let bridge: FakeContract - let reimbursementPool: ReimbursementPool - let walletCoordinator: WalletCoordinator + let walletProposalValidator: WalletProposalValidator before(async () => { const { deployer } = await helpers.signers.getNamedSigners() - // eslint-disable-next-line @typescript-eslint/no-extra-semi - ;[owner, thirdParty] = await helpers.signers.getUnnamedSigners() - walletRegistry = await smock.fake("IWalletRegistry") bridge = await smock.fake("Bridge") - const ReimbursementPool = await ethers.getContractFactory( - "ReimbursementPool" - ) - // Using the same parameter values as currently on mainnet - reimbursementPool = await ReimbursementPool.connect(deployer).deploy( - 40_800, - 500_000_000_000 - ) - - const WalletCoordinator = await ethers.getContractFactory( - "WalletCoordinator" + const WalletProposalValidator = await ethers.getContractFactory( + "WalletProposalValidator" ) - walletCoordinator = await WalletCoordinator.connect(deployer).deploy() - - bridge.contractReferences.returns([ - AddressZero, - AddressZero, - walletRegistry.address, - reimbursementPool.address, - ]) - await walletCoordinator.connect(deployer).initialize(bridge.address) - - await reimbursementPool - .connect(deployer) - .authorize(walletCoordinator.address) - await walletCoordinator.connect(deployer).transferOwnership(owner.address) - - // Fund the ReimbursementPool - await deployer.sendTransaction({ - to: reimbursementPool.address, - value: ethers.utils.parseEther("10"), - }) + walletProposalValidator = await WalletProposalValidator.connect( + deployer + ).deploy(bridge.address) }) - describe("addCoordinator", () => { + describe("validateDepositSweepProposal", () => { + const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" + const ecdsaWalletID = + "0x4ad6b3ccbca81645865d8d0d575797a15528e98ced22f29a6f906d3259569863" + const vault = "0x2553E09f832c9f5C656808bb7A24793818877732" + const bridgeDepositTxMaxFee = 10000 + before(async () => { await createSnapshot() + + bridge.depositParameters.returns([0, 0, bridgeDepositTxMaxFee, 0]) }) after(async () => { - await restoreSnapshot() - }) + bridge.depositParameters.reset() - context("when called by a third party", () => { - it("should revert", async () => { - await expect( - walletCoordinator - .connect(thirdParty) - .addCoordinator(thirdParty.address) - ).to.be.revertedWith("Ownable: caller is not the owner") - }) + await restoreSnapshot() }) - context("when called by the owner", () => { - context("when the coordinator already exists", () => { - before(async () => { - await createSnapshot() - - await walletCoordinator - .connect(owner) - .addCoordinator(thirdParty.address) - }) - - after(async () => { - await restoreSnapshot() - }) + context("when wallet is not Live", () => { + const testData = [ + { + testName: "when wallet state is Unknown", + walletState: walletState.Unknown, + }, + { + testName: "when wallet state is MovingFunds", + walletState: walletState.MovingFunds, + }, + { + testName: "when wallet state is Closing", + walletState: walletState.Closing, + }, + { + testName: "when wallet state is Closed", + walletState: walletState.Closed, + }, + { + testName: "when wallet state is Terminated", + walletState: walletState.Terminated, + }, + ] - it("should revert", async () => { - await expect( - walletCoordinator.connect(owner).addCoordinator(thirdParty.address) - ).to.be.revertedWith("This address is already a coordinator") - }) - }) + testData.forEach((test) => { + context(test.testName, () => { + before(async () => { + await createSnapshot() - context("when the coordinator does not exist yet", () => { - let tx: ContractTransaction + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID, + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: test.walletState, + movingFundsTargetWalletsCommitmentHash: HashZero, + }) + }) - before(async () => { - await createSnapshot() + after(async () => { + bridge.wallets.reset() - tx = await walletCoordinator - .connect(owner) - .addCoordinator(thirdParty.address) - }) + await restoreSnapshot() + }) - after(async () => { - await restoreSnapshot() + it("should revert", async () => { + await expect( + // Only walletPubKeyHash argument is relevant in this scenario. + walletProposalValidator.validateDepositSweepProposal( + { + walletPubKeyHash, + depositsKeys: [], + sweepTxFee: 0, + depositsRevealBlocks: [], + }, + [] + ) + ).to.be.revertedWith("Wallet is not in Live state") + }) }) + }) + }) - it("should add the new coordinator", async () => { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - expect(await walletCoordinator.isCoordinator(thirdParty.address)).to - .be.true - }) + context("when wallet is Live", () => { + before(async () => { + await createSnapshot() - it("should emit the CoordinatorAdded event", async () => { - await expect(tx) - .to.emit(walletCoordinator, "CoordinatorAdded") - .withArgs(thirdParty.address) + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID, + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.Live, + movingFundsTargetWalletsCommitmentHash: HashZero, }) }) - }) - }) - describe("removeCoordinator", () => { - before(async () => { - await createSnapshot() - }) - - after(async () => { - await restoreSnapshot() - }) + after(async () => { + bridge.wallets.reset() - context("when called by a third party", () => { - it("should revert", async () => { - await expect( - walletCoordinator - .connect(thirdParty) - .removeCoordinator(thirdParty.address) - ).to.be.revertedWith("Ownable: caller is not the owner") + await restoreSnapshot() }) - }) - context("when called by the owner", () => { - context("when the coordinator does not exist", () => { + context("when sweep is below the min size", () => { it("should revert", async () => { await expect( - walletCoordinator - .connect(owner) - .removeCoordinator(thirdParty.address) - ).to.be.revertedWith("This address is not a coordinator") + walletProposalValidator.validateDepositSweepProposal( + { + walletPubKeyHash, + depositsKeys: [], // Set size to 0. + sweepTxFee: 0, // Not relevant in this scenario. + depositsRevealBlocks: [], // Not relevant in this scenario. + }, + [] // Not relevant in this scenario. + ) + ).to.be.revertedWith("Sweep below the min size") }) }) - context("when the coordinator exists", () => { - let tx: ContractTransaction - - before(async () => { - await createSnapshot() - - await walletCoordinator - .connect(owner) - .addCoordinator(thirdParty.address) + context("when sweep is above the min size", () => { + context("when sweep exceeds the max size", () => { + it("should revert", async () => { + const maxSize = + await walletProposalValidator.DEPOSIT_SWEEP_MAX_SIZE() - tx = await walletCoordinator - .connect(owner) - .removeCoordinator(thirdParty.address) - }) + // Pick more deposits than allowed. + const depositsKeys = new Array(maxSize + 1).fill( + createTestDeposit(walletPubKeyHash, vault).key + ) - after(async () => { - await restoreSnapshot() + await expect( + walletProposalValidator.validateDepositSweepProposal( + { + walletPubKeyHash, + depositsKeys, + sweepTxFee: 0, // Not relevant in this scenario. + depositsRevealBlocks: [], // Not relevant in this scenario. + }, + [] // Not relevant in this scenario. + ) + ).to.be.revertedWith("Sweep exceeds the max size") + }) }) - it("should remove the coordinator", async () => { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - expect(await walletCoordinator.isCoordinator(thirdParty.address)).to - .be.false - }) + context("when sweep does not exceed the max size", () => { + context("when deposit extra info length does not match", () => { + it("should revert", async () => { + // The proposal contains one deposit. + const proposal = { + walletPubKeyHash, + depositsKeys: [createTestDeposit(walletPubKeyHash, vault).key], + sweepTxFee: 0, // Not relevant in this scenario. + depositsRevealBlocks: [], // Not relevant in this scenario. + } - it("should emit the CoordinatorRemoved event", async () => { - await expect(tx) - .to.emit(walletCoordinator, "CoordinatorRemoved") - .withArgs(thirdParty.address) - }) - }) - }) - }) + // The extra info array contains two items. + const depositsExtraInfo = [ + emptyDepositExtraInfo, + emptyDepositExtraInfo, + ] - describe("unlockWallet", () => { - const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Each deposit key must have matching extra info" + ) + }) + }) - before(async () => { - await createSnapshot() - }) + context("when deposit extra info length matches", () => { + context("when proposed sweep tx fee is invalid", () => { + context("when proposed sweep tx fee is zero", () => { + let depositOne + let depositTwo - after(async () => { - await restoreSnapshot() - }) + before(async () => { + await createSnapshot() - context("when called by a third party", () => { - it("should revert", async () => { - await expect( - walletCoordinator.connect(thirdParty).unlockWallet(walletPubKeyHash) - ).to.be.revertedWith("Ownable: caller is not the owner") - }) - }) + depositOne = createTestDeposit(walletPubKeyHash, vault, true) + depositTwo = createTestDeposit(walletPubKeyHash, vault, false) - context("when called by the owner", () => { - let tx: ContractTransaction + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - before(async () => { - await createSnapshot() + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns(depositTwo.request) + }) - await walletCoordinator - .connect(owner) - .addCoordinator(thirdParty.address) - - // Submit a proposal to set a wallet time lock. - await walletCoordinator.connect(thirdParty).submitDepositSweepProposal({ - walletPubKeyHash, - depositsKeys: [ - { - fundingTxHash: - "0x51f373dcbb6122bcb1c62964b5f3be923092dc64bc9e31257931d58c4eadb9f5", - fundingOutputIndex: 0, - }, - ], - sweepTxFee: 5000, - depositsRevealBlocks: [1000], - }) + after(async () => { + bridge.deposits.reset() - tx = await walletCoordinator - .connect(owner) - .unlockWallet(walletPubKeyHash) - }) + await restoreSnapshot() + }) - after(async () => { - await restoreSnapshot() - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [depositOne.key, depositTwo.key], + sweepTxFee: 0, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - it("should unlock the wallet", async () => { - const walletLock = await walletCoordinator.walletLock(walletPubKeyHash) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - expect(walletLock.expiresAt).to.be.equal(0) - expect(walletLock.cause).to.be.equal(walletAction.Idle) - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Proposed transaction fee cannot be zero" + ) + }) + }) - it("should emit the WalletManuallyUnlocked event", async () => { - await expect(tx) - .to.emit(walletCoordinator, "WalletManuallyUnlocked") - .withArgs(walletPubKeyHash) - }) - }) - }) + context( + "when proposed sweep tx fee is greater than the allowed", + () => { + let depositOne + let depositTwo - describe("updateHeartbeatRequestParameters", () => { - before(async () => { - await createSnapshot() - }) + before(async () => { + await createSnapshot() - after(async () => { - await restoreSnapshot() - }) + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false + ) - context("when called by a third party", () => { - it("should revert", async () => { - await expect( - walletCoordinator - .connect(thirdParty) - .updateHeartbeatRequestParameters(3600, 1000) - ).to.be.revertedWith("Ownable: caller is not the owner") - }) - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - context("when called by the owner", () => { - let tx: ContractTransaction + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns(depositTwo.request) + }) - before(async () => { - await createSnapshot() + after(async () => { + bridge.deposits.reset() - tx = await walletCoordinator - .connect(owner) - .updateHeartbeatRequestParameters(125, 126) - }) + await restoreSnapshot() + }) - after(async () => { - await restoreSnapshot() - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [depositOne.key, depositTwo.key], + // Exceed the max per-deposit fee by one. + sweepTxFee: bridgeDepositTxMaxFee * 2 + 1, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - it("should update heartbeat parameters", async () => { - expect(await walletCoordinator.heartbeatRequestValidity()).to.be.equal( - 125 - ) - expect(await walletCoordinator.heartbeatRequestGasOffset()).to.be.equal( - 126 - ) - }) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - it("should emit the HeartbeatRequestParametersUpdated event", async () => { - await expect(tx) - .to.emit(walletCoordinator, "HeartbeatRequestParametersUpdated") - .withArgs(125, 126) - }) - }) - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith("Proposed transaction fee is too high") + }) + } + ) + }) - describe("updateDepositSweepProposalParameters", () => { - before(async () => { - await createSnapshot() - }) + context("when proposed sweep tx fee is valid", () => { + const sweepTxFee = 5000 - after(async () => { - await restoreSnapshot() - }) + context("when there is a non-revealed deposit", () => { + let depositOne + let depositTwo - context("when called by a third party", () => { - it("should revert", async () => { - await expect( - walletCoordinator - .connect(thirdParty) - .updateDepositSweepProposalParameters(101, 102, 103, 104, 105) - ).to.be.revertedWith("Ownable: caller is not the owner") - }) - }) + before(async () => { + await createSnapshot() - context("when called by the owner", () => { - let tx: ContractTransaction + depositOne = createTestDeposit(walletPubKeyHash, vault, true) + depositTwo = createTestDeposit(walletPubKeyHash, vault, false) - before(async () => { - await createSnapshot() + // Deposit one is a proper one. + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - tx = await walletCoordinator - .connect(owner) - .updateDepositSweepProposalParameters(101, 102, 103, 104, 105) - }) + // Simulate the deposit two is not revealed. + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns({ + ...depositTwo.request, + revealedAt: 0, + }) + }) - after(async () => { - await restoreSnapshot() - }) + after(async () => { + bridge.deposits.reset() - it("should update deposit sweep proposal parameters", async () => { - expect( - await walletCoordinator.depositSweepProposalValidity() - ).to.be.equal(101) - expect(await walletCoordinator.depositMinAge()).to.be.equal(102) - expect(await walletCoordinator.depositRefundSafetyMargin()).to.be.equal( - 103 - ) - expect(await walletCoordinator.depositSweepMaxSize()).to.be.equal(104) - expect( - await walletCoordinator.depositSweepProposalSubmissionGasOffset() - ).to.be.equal(105) - }) + await restoreSnapshot() + }) - it("should emit the DepositSweepProposalParametersUpdated event", async () => { - await expect(tx) - .to.emit(walletCoordinator, "DepositSweepProposalParametersUpdated") - .withArgs(101, 102, 103, 104, 105) - }) - }) - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [depositOne.key, depositTwo.key], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - describe("updateReimbursementPool", () => { - before(async () => { - await createSnapshot() - }) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - after(async () => { - await restoreSnapshot() - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith("Deposit not revealed") + }) + }) - context("when called by a third party", () => { - it("should revert", async () => { - await expect( - walletCoordinator - .connect(thirdParty) - .updateReimbursementPool(thirdParty.address) - ).to.be.revertedWith("Caller is not the owner") - }) - }) + context("when all deposits are revealed", () => { + context("when there is an immature deposit", () => { + let depositOne + let depositTwo - context("when called by the owner", () => { - let tx: ContractTransaction + before(async () => { + await createSnapshot() - before(async () => { - await createSnapshot() - tx = await walletCoordinator - .connect(owner) - .updateReimbursementPool(thirdParty.address) - }) + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false + ) - after(async () => { - await restoreSnapshot() - }) + // Deposit one is a proper one. + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - it("should update the reimbursement pool address", async () => { - expect(await walletCoordinator.reimbursementPool()).to.be.equal( - thirdParty.address - ) - }) + // Simulate the deposit two has just been revealed thus not + // achieved the min age yet. + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns({ + ...depositTwo.request, + revealedAt: await lastBlockTime(), + }) + }) - it("should emit the ReimbursementPoolUpdated event", async () => { - await expect(tx) - .to.emit(walletCoordinator, "ReimbursementPoolUpdated") - .withArgs(thirdParty.address) - }) - }) - }) + after(async () => { + bridge.deposits.reset() - describe("requestHeartbeat", () => { - const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" + await restoreSnapshot() + }) - before(async () => { - await createSnapshot() - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [depositOne.key, depositTwo.key], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - after(async () => { - await restoreSnapshot() - }) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - context("when the caller is not a coordinator", () => { - before(async () => { - await createSnapshot() - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith("Deposit min age not achieved yet") + }) + }) - after(async () => { - await restoreSnapshot() - }) + context("when all deposits achieved the min age", () => { + context("when there is an already swept deposit", () => { + let depositOne + let depositTwo - it("should revert", async () => { - const tx = walletCoordinator - .connect(thirdParty) - .requestHeartbeat( - walletPubKeyHash, - "0xffffffffffffffff1111111111111111" - ) + before(async () => { + await createSnapshot() - await expect(tx).to.be.revertedWith("Caller is not a coordinator") - }) - }) - - context("when the caller is a coordinator", () => { - before(async () => { - await createSnapshot() + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false + ) - await walletCoordinator - .connect(owner) - .addCoordinator(thirdParty.address) - }) + // Deposit one is a proper one. + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - after(async () => { - await restoreSnapshot() - }) + // Simulate the deposit two has already been swept. + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns({ + ...depositTwo.request, + sweptAt: await lastBlockTime(), + }) + }) - context("when the wallet is time-locked", () => { - before(async () => { - await createSnapshot() + after(async () => { + bridge.deposits.reset() - // Submit a request to set a wallet time lock. - await walletCoordinator - .connect(thirdParty) - .requestHeartbeat( - walletPubKeyHash, - "0xffffffffffffffff1111111111111111" - ) + await restoreSnapshot() + }) - // Jump to the end of the lock period but not beyond it. - await increaseTime( - (await walletCoordinator.heartbeatRequestValidity()) - 1 - ) - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [depositOne.key, depositTwo.key], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - after(async () => { - await restoreSnapshot() - }) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - it("should revert", async () => { - await expect( - walletCoordinator - .connect(thirdParty) - .requestHeartbeat( - walletPubKeyHash, - "0xffffffffffffffff1111111111111111" - ) - ).to.be.revertedWith("Wallet locked") - }) - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith("Deposit already swept") + }) + }) - context("when the wallet is not time-locked", () => { - before(async () => { - await createSnapshot() + context("when all deposits are not swept yet", () => { + context( + "when there is a deposit with invalid extra info", + () => { + context("when funding tx hashes don't match", () => { + let deposit - await walletCoordinator - .connect(thirdParty) - .requestHeartbeat( - walletPubKeyHash, - "0xffffffffffffffff1111111111111111" - ) + before(async () => { + await createSnapshot() - // Jump beyond the lock period. - await increaseTime(await walletCoordinator.heartbeatRequestValidity()) - }) + deposit = createTestDeposit( + walletPubKeyHash, + vault, + true + ) - after(async () => { - await restoreSnapshot() - }) + bridge.deposits + .whenCalledWith( + depositKey( + deposit.key.fundingTxHash, + deposit.key.fundingOutputIndex + ) + ) + .returns(deposit.request) + }) - context("when the message is not a valid heartbeat", () => { - it("should revert", async () => { - await expect( - walletCoordinator - .connect(thirdParty) - .requestHeartbeat( - walletPubKeyHash, - "0xff000000000000000000000000000000" - ) - ).to.be.revertedWith("Not a valid heartbeat message") - }) - }) + after(async () => { + bridge.deposits.reset() - context("when the message is a valid heartbeat", () => { - let tx: ContractTransaction + await restoreSnapshot() + }) - before(async () => { - await createSnapshot() + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [deposit.key], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - tx = await walletCoordinator - .connect(thirdParty) - .requestHeartbeat( - walletPubKeyHash, - "0xffffffffffffffff1111111111111111" - ) - }) + // Corrupt the extra info by setting a different + // version than 0x01000000 used to produce the hash. + const depositsExtraInfo = [ + { + ...deposit.extraInfo, + fundingTx: { + ...deposit.extraInfo.fundingTx, + version: "0x02000000", + }, + }, + ] - after(async () => { - await restoreSnapshot() - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Extra info funding tx hash does not match" + ) + }) + }) - it("should time lock the wallet", async () => { - const lockedUntil = - (await lastBlockTime()) + - (await walletCoordinator.heartbeatRequestValidity()) + context( + "when 20-byte funding output hash does not match", + () => { + let deposit - const walletLock = await walletCoordinator.walletLock( - walletPubKeyHash - ) + before(async () => { + await createSnapshot() - expect(walletLock.expiresAt).to.be.equal(lockedUntil) - expect(walletLock.cause).to.be.equal(walletAction.Heartbeat) - }) + deposit = createTestDeposit( + walletPubKeyHash, + vault, + false // Produce a non-witness deposit with 20-byte script + ) - it("should emit the HeartbeatRequestSubmitted event", async () => { - await expect(tx) - .to.emit(walletCoordinator, "HeartbeatRequestSubmitted") - .withArgs( - walletPubKeyHash, - "0xffffffffffffffff1111111111111111", - thirdParty.address - ) - }) - }) - }) - }) - }) + bridge.deposits + .whenCalledWith( + depositKey( + deposit.key.fundingTxHash, + deposit.key.fundingOutputIndex + ) + ) + .returns(deposit.request) + }) - describe("requestHeartbeatWithReimbursement", () => { - const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" + after(async () => { + bridge.deposits.reset() - before(async () => { - await createSnapshot() - }) + await restoreSnapshot() + }) - after(async () => { - await restoreSnapshot() - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [deposit.key], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - // Just double check that `requestHeartbeatWithReimbursement` has - // the same ACL as `requestHeartbeat`. - context("when the caller is not a coordinator", () => { - before(async () => { - await createSnapshot() - }) + // Corrupt the extra info by reversing the proper + // blinding factor used to produce the script. + const depositsExtraInfo = [ + { + ...deposit.extraInfo, + blindingFactor: `0x${Buffer.from( + deposit.extraInfo.blindingFactor.substring( + 2 + ), + "hex" + ) + .reverse() + .toString("hex")}`, + }, + ] - after(async () => { - await restoreSnapshot() - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Extra info funding output script does not match" + ) + }) + } + ) - it("should revert", async () => { - const tx = walletCoordinator - .connect(thirdParty) - .requestHeartbeatWithReimbursement( - walletPubKeyHash, - "0xffffffffffffffff1111111111111111" - ) + context( + "when 32-byte funding output hash does not match", + () => { + let deposit - await expect(tx).to.be.revertedWith("Caller is not a coordinator") - }) - }) + before(async () => { + await createSnapshot() - // Here we just check that the reimbursement works. Detailed - // assertions are already done within the scenario stressing the - // `requestHeartbeat` function. - context("when the caller is a coordinator", () => { - let coordinatorBalanceBefore: BigNumber - let coordinatorBalanceAfter: BigNumber + deposit = createTestDeposit( + walletPubKeyHash, + vault, + true // Produce a witness deposit with 32-byte script + ) - before(async () => { - await createSnapshot() + bridge.deposits + .whenCalledWith( + depositKey( + deposit.key.fundingTxHash, + deposit.key.fundingOutputIndex + ) + ) + .returns(deposit.request) + }) - await walletCoordinator - .connect(owner) - .addCoordinator(thirdParty.address) - - // The first-ever heartbeat request will be more expensive given it has - // to set fields to non-zero values. We shouldn't adjust gas offset - // based on it. - await walletCoordinator - .connect(thirdParty) - .requestHeartbeatWithReimbursement( - walletPubKeyHash, - "0xffffffffffffffff1111111111111111" - ) - // Jump beyond the lock period. - await increaseTime(await walletCoordinator.heartbeatRequestValidity()) + after(async () => { + bridge.deposits.reset() - coordinatorBalanceBefore = await provider.getBalance(thirdParty.address) - - await walletCoordinator - .connect(thirdParty) - .requestHeartbeatWithReimbursement( - walletPubKeyHash, - "0xffffffffffffffff1111111111111111" - ) + await restoreSnapshot() + }) - coordinatorBalanceAfter = await provider.getBalance(thirdParty.address) - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [deposit.key], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - after(async () => { - await restoreSnapshot() - }) + // Corrupt the extra info by reversing the proper + // blinding factor used to produce the script. + const depositsExtraInfo = [ + { + ...deposit.extraInfo, + blindingFactor: `0x${Buffer.from( + deposit.extraInfo.blindingFactor.substring( + 2 + ), + "hex" + ) + .reverse() + .toString("hex")}`, + }, + ] - it("should do the refund", async () => { - const diff = coordinatorBalanceAfter.sub(coordinatorBalanceBefore) - expect(diff).to.be.gt(0) - expect(diff).to.be.lt(ethers.utils.parseUnits("2000000", "gwei")) // 0,002 ETH - }) - }) - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Extra info funding output script does not match" + ) + }) + } + ) + } + ) - describe("submitDepositSweepProposal", () => { - const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" + context("when all deposits extra info are valid", () => { + context( + "when there is a deposit that violates the refund safety margin", + () => { + let depositOne + let depositTwo - before(async () => { - await createSnapshot() - }) + before(async () => { + await createSnapshot() - after(async () => { - await restoreSnapshot() - }) + // Deposit one is a proper one. + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) - context("when the caller is not a coordinator", () => { - before(async () => { - await createSnapshot() - }) + // Simulate that deposit two violates the refund. + // In order to do so, we need to use `createTestDeposit` + // with a custom reveal time that will produce + // a refund locktime being closer to the current + // moment than allowed by the refund safety margin. + const safetyMarginViolatedAt = await lastBlockTime() + const depositRefundableAt = + safetyMarginViolatedAt + + (await walletProposalValidator.DEPOSIT_REFUND_SAFETY_MARGIN()) + const depositRevealedAt = + depositRefundableAt - depositLocktime - after(async () => { - await restoreSnapshot() - }) + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false, + depositRevealedAt + ) - it("should revert", async () => { - const tx = walletCoordinator - .connect(thirdParty) - .submitDepositSweepProposal({ - walletPubKeyHash, - depositsKeys: [ - { - fundingTxHash: - "0x51f373dcbb6122bcb1c62964b5f3be923092dc64bc9e31257931d58c4eadb9f5", - fundingOutputIndex: 0, - }, - ], - sweepTxFee: 5000, - depositsRevealBlocks: [1000], - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - await expect(tx).to.be.revertedWith("Caller is not a coordinator") - }) - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns(depositTwo.request) + }) - context("when the caller is a coordinator", () => { - before(async () => { - await createSnapshot() + after(async () => { + bridge.deposits.reset() - await walletCoordinator - .connect(owner) - .addCoordinator(thirdParty.address) - }) + await restoreSnapshot() + }) - after(async () => { - await restoreSnapshot() - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [depositOne.key, depositTwo.key], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - context("when wallet is time-locked", () => { - before(async () => { - await createSnapshot() + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - // Submit a proposal to set a wallet time lock. - await walletCoordinator - .connect(thirdParty) - .submitDepositSweepProposal({ - walletPubKeyHash, - depositsKeys: [ - { - fundingTxHash: - "0x51f373dcbb6122bcb1c62964b5f3be923092dc64bc9e31257931d58c4eadb9f5", - fundingOutputIndex: 0, - }, - ], - sweepTxFee: 5000, - depositsRevealBlocks: [1000], - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Deposit refund safety margin is not preserved" + ) + }) + } + ) - // Jump to the end of the lock period but not beyond it. - await increaseTime( - (await walletCoordinator.depositSweepProposalValidity()) - 1 - ) - }) + context( + "when all deposits preserve the refund safety margin", + () => { + context( + "when there is a deposit controlled by a different wallet", + () => { + let depositOne + let depositTwo - after(async () => { - await restoreSnapshot() - }) + before(async () => { + await createSnapshot() - it("should revert", async () => { - await expect( - walletCoordinator.connect(thirdParty).submitDepositSweepProposal({ - walletPubKeyHash, - depositsKeys: [ - { - fundingTxHash: - "0x51f373dcbb6122bcb1c62964b5f3be923092dc64bc9e31257931d58c4eadb9f5", - fundingOutputIndex: 1, - }, - ], - sweepTxFee: 5000, - depositsRevealBlocks: [1000], - }) - ).to.be.revertedWith("Wallet locked") - }) - }) + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) - context("when wallet is not time-locked", () => { - let tx: ContractTransaction + // Deposit two uses a different wallet than deposit + // one. + depositTwo = createTestDeposit( + `0x${Buffer.from( + walletPubKeyHash.substring(2), + "hex" + ) + .reverse() + .toString("hex")}`, + vault, + false + ) - before(async () => { - await createSnapshot() + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - // Submit a proposal to set a wallet time lock. - await walletCoordinator - .connect(thirdParty) - .submitDepositSweepProposal({ - walletPubKeyHash, - depositsKeys: [ - { - fundingTxHash: - "0x51f373dcbb6122bcb1c62964b5f3be923092dc64bc9e31257931d58c4eadb9f5", - fundingOutputIndex: 0, - }, - ], - sweepTxFee: 5000, - depositsRevealBlocks: [1000], - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns(depositTwo.request) + }) - // Jump beyond the lock period. - await increaseTime( - await walletCoordinator.depositSweepProposalValidity() - ) + after(async () => { + bridge.deposits.reset() - tx = await walletCoordinator - .connect(thirdParty) - .submitDepositSweepProposal({ - walletPubKeyHash, - depositsKeys: [ - { - fundingTxHash: - "0x51f373dcbb6122bcb1c62964b5f3be923092dc64bc9e31257931d58c4eadb9f5", - fundingOutputIndex: 1, - }, - ], - sweepTxFee: 5000, - depositsRevealBlocks: [1000], - }) - }) + await restoreSnapshot() + }) - after(async () => { - await restoreSnapshot() - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [ + depositOne.key, + depositTwo.key, + ], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - it("should time lock the wallet", async () => { - const lockedUntil = - (await lastBlockTime()) + - (await walletCoordinator.depositSweepProposalValidity()) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - const walletLock = await walletCoordinator.walletLock( - walletPubKeyHash - ) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Deposit controlled by different wallet" + ) + }) + } + ) - expect(walletLock.expiresAt).to.be.equal(lockedUntil) - expect(walletLock.cause).to.be.equal(walletAction.DepositSweep) - }) + context( + "when all deposits are controlled by the same wallet", + () => { + context( + "when there is a deposit targeting a different vault", + () => { + let depositOne + let depositTwo - it("should emit the DepositSweepProposalSubmitted event", async () => { - await expect(tx).to.emit( - walletCoordinator, - "DepositSweepProposalSubmitted" - ) + before(async () => { + await createSnapshot() - // The `expect.to.emit.withArgs` assertion has troubles with - // matching complex event arguments as it uses strict equality - // underneath. To overcome that problem, we manually get event's - // arguments and check it against the expected ones using deep - // equality assertion (eql). - const receipt = await ethers.provider.getTransactionReceipt(tx.hash) - expect(receipt.logs.length).to.be.equal(1) - expect( - walletCoordinator.interface.parseLog(receipt.logs[0]).args - ).to.be.eql([ - [ - walletPubKeyHash, - [ - [ - "0x51f373dcbb6122bcb1c62964b5f3be923092dc64bc9e31257931d58c4eadb9f5", - 1, - ], - ], - BigNumber.from(5000), - [BigNumber.from(1000)], - ], - thirdParty.address, - ]) - }) - }) - }) - }) + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) - describe("submitDepositSweepProposalWithReimbursement", () => { - const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" + // Deposit two uses a different vault than deposit + // one. + depositTwo = createTestDeposit( + walletPubKeyHash, + `0x${Buffer.from( + vault.substring(2), + "hex" + ) + .reverse() + .toString("hex")}`, + false + ) - before(async () => { - await createSnapshot() - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - after(async () => { - await restoreSnapshot() - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns(depositTwo.request) + }) - // Just double check that `submitDepositSweepProposalWithReimbursement` has - // the same ACL as `submitDepositSweepProposal`. - context("when the caller is not a coordinator", () => { - before(async () => { - await createSnapshot() - }) + after(async () => { + bridge.deposits.reset() - after(async () => { - await restoreSnapshot() - }) + await restoreSnapshot() + }) - it("should revert", async () => { - const tx = walletCoordinator - .connect(thirdParty) - .submitDepositSweepProposalWithReimbursement({ - walletPubKeyHash, - depositsKeys: [ - { - fundingTxHash: - "0x51f373dcbb6122bcb1c62964b5f3be923092dc64bc9e31257931d58c4eadb9f5", - fundingOutputIndex: 0, - }, - ], - sweepTxFee: 5000, - depositsRevealBlocks: [1000], - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [ + depositOne.key, + depositTwo.key, + ], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - await expect(tx).to.be.revertedWith("Caller is not a coordinator") - }) - }) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - // Here we just check that the reimbursement works. Detailed - // assertions are already done within the scenario stressing the - // `submitDepositSweepProposal` function. - context("when the caller is a coordinator", () => { - let coordinatorBalanceBefore: BigNumber - let coordinatorBalanceAfter: BigNumber + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Deposit targets different vault" + ) + }) + } + ) - before(async () => { - await createSnapshot() + context( + "when all deposits targets the same vault", + () => { + context( + "when there are duplicated deposits", + () => { + let depositOne + let depositTwo + let depositThree - await walletCoordinator - .connect(owner) - .addCoordinator(thirdParty.address) - - // The first-ever proposal will be more expensive given it has to set - // fields to non-zero values. We shouldn't adjust gas offset based on it. - await walletCoordinator - .connect(thirdParty) - .submitDepositSweepProposalWithReimbursement({ - walletPubKeyHash, - depositsKeys: [ - { - fundingTxHash: - "0x51f373dcbb6122bcb1c62964b5f3be923092dc64bc9e31257931d58c4eadb9f5", - fundingOutputIndex: 0, - }, - ], - sweepTxFee: 5000, - depositsRevealBlocks: [1000], - }) + before(async () => { + await createSnapshot() - // Jump beyond the lock period. - await increaseTime( - await walletCoordinator.depositSweepProposalValidity() - ) + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) - coordinatorBalanceBefore = await provider.getBalance(thirdParty.address) + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false + ) - await walletCoordinator - .connect(thirdParty) - .submitDepositSweepProposalWithReimbursement({ - walletPubKeyHash, - depositsKeys: [ - { - fundingTxHash: - "0x51f373dcbb6122bcb1c62964b5f3be923092dc64bc9e31257931d58c4eadb9f5", - fundingOutputIndex: 0, - }, - ], - sweepTxFee: 5000, - depositsRevealBlocks: [1000], - }) + depositThree = createTestDeposit( + walletPubKeyHash, + vault, + false + ) - coordinatorBalanceAfter = await provider.getBalance(thirdParty.address) - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - after(async () => { - await restoreSnapshot() - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns(depositTwo.request) - it("should do the refund", async () => { - const diff = coordinatorBalanceAfter.sub(coordinatorBalanceBefore) - expect(diff).to.be.gt(0) - expect(diff).to.be.lt(ethers.utils.parseUnits("4000000", "gwei")) // 0,004 ETH - }) - }) - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositThree.key.fundingTxHash, + depositThree.key + .fundingOutputIndex + ) + ) + .returns(depositThree.request) + }) - describe("validateDepositSweepProposal", () => { - const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" - const ecdsaWalletID = - "0x4ad6b3ccbca81645865d8d0d575797a15528e98ced22f29a6f906d3259569863" - const vault = "0x2553E09f832c9f5C656808bb7A24793818877732" - const bridgeDepositTxMaxFee = 10000 + after(async () => { + bridge.deposits.reset() - before(async () => { - await createSnapshot() + await restoreSnapshot() + }) - bridge.depositParameters.returns([0, 0, bridgeDepositTxMaxFee, 0]) - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [ + depositOne.key, + depositTwo.key, + depositThree.key, + depositTwo.key, // duplicate + ], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - after(async () => { - bridge.depositParameters.reset() + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + depositThree.extraInfo, + depositTwo.extraInfo, // duplicate + ] - await restoreSnapshot() - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Duplicated deposit" + ) + }) + } + ) - context("when wallet is not Live", () => { - const testData = [ - { - testName: "when wallet state is Unknown", - walletState: walletState.Unknown, - }, - { - testName: "when wallet state is MovingFunds", - walletState: walletState.MovingFunds, - }, - { - testName: "when wallet state is Closing", - walletState: walletState.Closing, - }, - { - testName: "when wallet state is Closed", - walletState: walletState.Closed, - }, - { - testName: "when wallet state is Terminated", - walletState: walletState.Terminated, - }, - ] + context( + "when all deposits are unique", + () => { + let depositOne + let depositTwo + let depositThree - testData.forEach((test) => { - context(test.testName, () => { - before(async () => { - await createSnapshot() + before(async () => { + await createSnapshot() - bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ - ecdsaWalletID, - mainUtxoHash: HashZero, - pendingRedemptionsValue: 0, - createdAt: 0, - movingFundsRequestedAt: 0, - closingStartedAt: 0, - pendingMovedFundsSweepRequestsCount: 0, - state: test.walletState, - movingFundsTargetWalletsCommitmentHash: HashZero, - }) - }) + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) - after(async () => { - bridge.wallets.reset() + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false + ) + + // Use a deposit with embedded 32-byte extra data + // to make sure validation handles them correctly. + depositThree = createTestDeposit( + walletPubKeyHash, + vault, + true, + undefined, + "0xa9b38ea6435c8941d6eda6a46b68e3e2117196995bd154ab55196396b03d9bda" + ) + + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) + + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns(depositTwo.request) + + bridge.deposits + .whenCalledWith( + depositKey( + depositThree.key.fundingTxHash, + depositThree.key + .fundingOutputIndex + ) + ) + .returns(depositThree.request) + }) + + after(async () => { + bridge.deposits.reset() + + await restoreSnapshot() + }) + + it("should succeed", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [ + depositOne.key, + depositTwo.key, + depositThree.key, + ], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } + + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + depositThree.extraInfo, + ] + + const result = + await walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(result).to.be.true + }) + } + ) + } + ) + } + ) + } + ) + }) + }) + }) + }) + }) + }) + }) + }) + }) + }) + + describe("validateRedemptionProposal", () => { + const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" + const ecdsaWalletID = + "0x4ad6b3ccbca81645865d8d0d575797a15528e98ced22f29a6f906d3259569863" + + const bridgeRedemptionTxMaxTotalFee = 10000 + const bridgeRedemptionTimeout = 5 * 86400 // 5 days + + before(async () => { + await createSnapshot() + + bridge.redemptionParameters.returns([ + 0, + 0, + 0, + bridgeRedemptionTxMaxTotalFee, + bridgeRedemptionTimeout, + 0, + 0, + ]) + }) + + after(async () => { + bridge.redemptionParameters.reset() + + await restoreSnapshot() + }) + + context("when wallet is not Live", () => { + const testData = [ + { + testName: "when wallet state is Unknown", + walletState: walletState.Unknown, + }, + { + testName: "when wallet state is MovingFunds", + walletState: walletState.MovingFunds, + }, + { + testName: "when wallet state is Closing", + walletState: walletState.Closing, + }, + { + testName: "when wallet state is Closed", + walletState: walletState.Closed, + }, + { + testName: "when wallet state is Terminated", + walletState: walletState.Terminated, + }, + ] + + testData.forEach((test) => { + context(test.testName, () => { + before(async () => { + await createSnapshot() + + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID, + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: test.walletState, + movingFundsTargetWalletsCommitmentHash: HashZero, + }) + }) + + after(async () => { + bridge.wallets.reset() await restoreSnapshot() }) @@ -1057,15 +1304,11 @@ describe("WalletCoordinator", () => { it("should revert", async () => { await expect( // Only walletPubKeyHash argument is relevant in this scenario. - walletCoordinator.validateDepositSweepProposal( - { - walletPubKeyHash, - depositsKeys: [], - sweepTxFee: 0, - depositsRevealBlocks: [], - }, - [] - ) + walletProposalValidator.validateRedemptionProposal({ + walletPubKeyHash, + redeemersOutputScripts: [], + redemptionTxFee: 0, + }) ).to.be.revertedWith("Wallet is not in Live state") }) }) @@ -1095,244 +1338,184 @@ describe("WalletCoordinator", () => { await restoreSnapshot() }) - context("when sweep is below the min size", () => { + context("when redemption is below the min size", () => { it("should revert", async () => { await expect( - walletCoordinator.validateDepositSweepProposal( - { - walletPubKeyHash, - depositsKeys: [], // Set size to 0. - sweepTxFee: 0, // Not relevant in this scenario. - depositsRevealBlocks: [], // Not relevant in this scenario. - }, - [] // Not relevant in this scenario. - ) - ).to.be.revertedWith("Sweep below the min size") + walletProposalValidator.validateRedemptionProposal({ + walletPubKeyHash, + redeemersOutputScripts: [], // Set size to 0. + redemptionTxFee: 0, // Not relevant in this scenario. + }) + ).to.be.revertedWith("Redemption below the min size") }) }) - context("when sweep is above the min size", () => { - context("when sweep exceeds the max size", () => { + context("when redemption is above the min size", () => { + context("when redemption exceeds the max size", () => { it("should revert", async () => { - const maxSize = await walletCoordinator.depositSweepMaxSize() + const maxSize = await walletProposalValidator.REDEMPTION_MAX_SIZE() - // Pick more deposits than allowed. - const depositsKeys = new Array(maxSize + 1).fill( - createTestDeposit(walletPubKeyHash, vault).key + // Pick more redemption requests than allowed. + const redeemersOutputScripts = new Array(maxSize + 1).fill( + createTestRedemptionRequest(walletPubKeyHash).key + .redeemerOutputScript ) await expect( - walletCoordinator.validateDepositSweepProposal( - { - walletPubKeyHash, - depositsKeys, - sweepTxFee: 0, // Not relevant in this scenario. - depositsRevealBlocks: [], // Not relevant in this scenario. - }, - [] // Not relevant in this scenario. - ) - ).to.be.revertedWith("Sweep exceeds the max size") + walletProposalValidator.validateRedemptionProposal({ + walletPubKeyHash, + redeemersOutputScripts, + redemptionTxFee: 0, // Not relevant in this scenario. + }) + ).to.be.revertedWith("Redemption exceeds the max size") }) }) - context("when sweep does not exceed the max size", () => { - context("when deposit extra data length does not match", () => { - it("should revert", async () => { - // The proposal contains one deposit. - const proposal = { - walletPubKeyHash, - depositsKeys: [createTestDeposit(walletPubKeyHash, vault).key], - sweepTxFee: 0, // Not relevant in this scenario. - depositsRevealBlocks: [], // Not relevant in this scenario. - } - - // The extra data array contains two items. - const depositsExtraInfo = [ - emptyDepositExtraInfo, - emptyDepositExtraInfo, - ] - - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Each deposit key must have matching extra data" - ) + context("when redemption does not exceed the max size", () => { + context("when proposed redemption tx fee is invalid", () => { + context("when proposed redemption tx fee is zero", () => { + it("should revert", async () => { + await expect( + walletProposalValidator.validateRedemptionProposal({ + walletPubKeyHash, + redeemersOutputScripts: [ + createTestRedemptionRequest(walletPubKeyHash).key + .redeemerOutputScript, + ], + redemptionTxFee: 0, + }) + ).to.be.revertedWith("Proposed transaction fee cannot be zero") + }) }) - }) - - context("when deposit extra data length matches", () => { - context("when proposed sweep tx fee is invalid", () => { - context("when proposed sweep tx fee is zero", () => { - let depositOne - let depositTwo - - before(async () => { - await createSnapshot() - - depositOne = createTestDeposit(walletPubKeyHash, vault, true) - depositTwo = createTestDeposit(walletPubKeyHash, vault, false) - - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex - ) - ) - .returns(depositOne.request) - - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex - ) - ) - .returns(depositTwo.request) - }) - - after(async () => { - bridge.deposits.reset() - - await restoreSnapshot() - }) + context( + "when proposed redemption tx fee is greater than the allowed total fee", + () => { it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [depositOne.key, depositTwo.key], - sweepTxFee: 0, - depositsRevealBlocks: [], // Not relevant in this scenario. - } - - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Proposed transaction fee cannot be zero" - ) + walletProposalValidator.validateRedemptionProposal({ + walletPubKeyHash, + redeemersOutputScripts: [ + createTestRedemptionRequest(walletPubKeyHash).key + .redeemerOutputScript, + ], + // Exceed the max per-request fee by one. + redemptionTxFee: bridgeRedemptionTxMaxTotalFee + 1, + }) + ).to.be.revertedWith("Proposed transaction fee is too high") }) - }) + } + ) - context( - "when proposed sweep tx fee is greater than the allowed", - () => { - let depositOne - let depositTwo + // The context block covering the per-redemption fee checks is + // declared at the end of the `validateRedemptionProposal` test suite + // due to the actual order of checks performed by this function. + // See: "when there is a request that incurs an unacceptable tx fee share" + }) - before(async () => { - await createSnapshot() + context("when proposed redemption tx fee is valid", () => { + const redemptionTxFee = 9000 - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true - ) - depositTwo = createTestDeposit( - walletPubKeyHash, - vault, - false - ) + context("when there is a non-pending request", () => { + let requestOne + let requestTwo - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex - ) - ) - .returns(depositOne.request) + before(async () => { + await createSnapshot() - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex - ) - ) - .returns(depositTwo.request) - }) + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 5000 // necessary to pass the fee share validation + ) + requestTwo = createTestRedemptionRequest(walletPubKeyHash) - after(async () => { - bridge.deposits.reset() + // Request one is a proper one. + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript + ) + ) + .returns(requestOne.content) - await restoreSnapshot() + // Simulate the request two is non-pending. + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript + ) + ) + .returns({ + ...requestTwo.content, + requestedAt: 0, }) + }) - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [depositOne.key, depositTwo.key], - // Exceed the max per-deposit fee by one. - sweepTxFee: bridgeDepositTxMaxFee * 2 + 1, - depositsRevealBlocks: [], // Not relevant in this scenario. - } + after(async () => { + bridge.pendingRedemptions.reset() - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] + await restoreSnapshot() + }) - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith("Proposed transaction fee is too high") - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, } - ) - }) - context("when proposed sweep tx fee is valid", () => { - const sweepTxFee = 5000 + await expect( + walletProposalValidator.validateRedemptionProposal(proposal) + ).to.be.revertedWith("Not a pending redemption request") + }) + }) - context("when there is a non-revealed deposit", () => { - let depositOne - let depositTwo + context("when all requests are pending", () => { + context("when there is an immature request", () => { + let requestOne + let requestTwo before(async () => { await createSnapshot() - depositOne = createTestDeposit(walletPubKeyHash, vault, true) - depositTwo = createTestDeposit(walletPubKeyHash, vault, false) + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 5000 // necessary to pass the fee share validation + ) + requestTwo = createTestRedemptionRequest(walletPubKeyHash) - // Deposit one is a proper one. - bridge.deposits + // Request one is a proper one. + bridge.pendingRedemptions .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript ) ) - .returns(depositOne.request) + .returns(requestOne.content) - // Simulate the deposit two is not revealed. - bridge.deposits + // Simulate the request two has just been created thus not + // achieved the min age yet. + bridge.pendingRedemptions .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript ) ) .returns({ - ...depositTwo.request, - revealedAt: 0, + ...requestTwo.content, + requestedAt: await lastBlockTime(), }) }) after(async () => { - bridge.deposits.reset() + bridge.pendingRedemptions.reset() await restoreSnapshot() }) @@ -1340,142 +1523,76 @@ describe("WalletCoordinator", () => { it("should revert", async () => { const proposal = { walletPubKeyHash, - depositsKeys: [depositOne.key, depositTwo.key], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, } - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith("Deposit not revealed") + walletProposalValidator.validateRedemptionProposal(proposal) + ).to.be.revertedWith( + "Redemption request min age not achieved yet" + ) }) }) - context("when all deposits are revealed", () => { - context("when there is an immature deposit", () => { - let depositOne - let depositTwo + context("when all requests achieved the min age", () => { + context( + "when there is a request that violates the timeout safety margin", + () => { + let requestOne + let requestTwo - before(async () => { - await createSnapshot() + before(async () => { + await createSnapshot() - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true - ) - depositTwo = createTestDeposit( - walletPubKeyHash, - vault, - false - ) - - // Deposit one is a proper one. - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex - ) - ) - .returns(depositOne.request) - - // Simulate the deposit two has just been revealed thus not - // achieved the min age yet. - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex - ) - ) - .returns({ - ...depositTwo.request, - revealedAt: await lastBlockTime(), - }) - }) - - after(async () => { - bridge.deposits.reset() - - await restoreSnapshot() - }) - - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [depositOne.key, depositTwo.key], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } - - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] - - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo + // Request one is a proper one. + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 5000 // necessary to pass the fee share validation ) - ).to.be.revertedWith("Deposit min age not achieved yet") - }) - }) - context("when all deposits achieved the min age", () => { - context("when there is an already swept deposit", () => { - let depositOne - let depositTwo - - before(async () => { - await createSnapshot() + // Simulate that request two violates the timeout safety margin. + // In order to do so, we need to use `createTestRedemptionRequest` + // with a custom request creation time that will produce + // a timeout timestamp being closer to the current + // moment than allowed by the refund safety margin. + const safetyMarginViolatedAt = await lastBlockTime() + const requestTimedOutAt = + safetyMarginViolatedAt + + (await walletProposalValidator.REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN()) + const requestCreatedAt = + requestTimedOutAt - bridgeRedemptionTimeout - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true - ) - depositTwo = createTestDeposit( + requestTwo = createTestRedemptionRequest( walletPubKeyHash, - vault, - false + 0, + requestCreatedAt ) - // Deposit one is a proper one. - bridge.deposits + bridge.pendingRedemptions .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript ) ) - .returns(depositOne.request) + .returns(requestOne.content) - // Simulate the deposit two has already been swept. - bridge.deposits + bridge.pendingRedemptions .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript ) ) - .returns({ - ...depositTwo.request, - sweptAt: await lastBlockTime(), - }) + .returns(requestTwo.content) }) after(async () => { - bridge.deposits.reset() + bridge.pendingRedemptions.reset() await restoreSnapshot() }) @@ -1483,53 +1600,75 @@ describe("WalletCoordinator", () => { it("should revert", async () => { const proposal = { walletPubKeyHash, - depositsKeys: [depositOne.key, depositTwo.key], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, } - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo + walletProposalValidator.validateRedemptionProposal( + proposal ) - ).to.be.revertedWith("Deposit already swept") + ).to.be.revertedWith( + "Redemption request timeout safety margin is not preserved" + ) }) - }) + } + ) - context("when all deposits are not swept yet", () => { + context( + "when all requests preserve the timeout safety margin", + () => { context( - "when there is a deposit with invalid extra data", + "when there is a request that incurs an unacceptable tx fee share", () => { - context("when funding tx hashes don't match", () => { - let deposit + context("when there is no fee remainder", () => { + let requestOne + let requestTwo before(async () => { await createSnapshot() - deposit = createTestDeposit( + // Request one is a proper one. + requestOne = createTestRedemptionRequest( walletPubKeyHash, - vault, - true + 4500 // necessary to pass the fee share validation ) - bridge.deposits + // Simulate that request two takes an unacceptable + // tx fee share. Because redemptionTxFee used + // in the proposal is 9000, the actual fee share + // per-request is 4500. In order to test this case + // the second request must allow for 4499 as allowed + // fee share at maximum. + requestTwo = createTestRedemptionRequest( + walletPubKeyHash, + 4499 + ) + + bridge.pendingRedemptions .whenCalledWith( - depositKey( - deposit.key.fundingTxHash, - deposit.key.fundingOutputIndex + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript ) ) - .returns(deposit.request) + .returns(requestOne.content) + + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript + ) + ) + .returns(requestTwo.content) }) after(async () => { - bridge.deposits.reset() + bridge.pendingRedemptions.reset() await restoreSnapshot() }) @@ -1537,225 +1676,151 @@ describe("WalletCoordinator", () => { it("should revert", async () => { const proposal = { walletPubKeyHash, - depositsKeys: [deposit.key], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, } - // Corrupt the extra data by setting a different - // version than 0x01000000 used to produce the hash. - const depositsExtraInfo = [ - { - ...deposit.extraInfo, - fundingTx: { - ...deposit.extraInfo.fundingTx, - version: "0x02000000", - }, - }, - ] - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo + walletProposalValidator.validateRedemptionProposal( + proposal ) ).to.be.revertedWith( - "Extra info funding tx hash does not match" + "Proposed transaction per-request fee share is too high" ) }) }) - context( - "when 20-byte funding output hash does not match", - () => { - let deposit + context("when there is a fee remainder", () => { + let requestOne + let requestTwo - before(async () => { - await createSnapshot() + before(async () => { + await createSnapshot() - deposit = createTestDeposit( - walletPubKeyHash, - vault, - false // Produce a non-witness deposit with 20-byte script - ) + // Request one is a proper one. + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 4500 // necessary to pass the fee share validation + ) - bridge.deposits - .whenCalledWith( - depositKey( - deposit.key.fundingTxHash, - deposit.key.fundingOutputIndex - ) - ) - .returns(deposit.request) - }) + // Simulate that request two takes an unacceptable + // tx fee share. Because redemptionTxFee used + // in the proposal is 9001, the actual fee share + // per-request is 4500 and 4501 for the last request + // which takes the remainder. In order to test this + // case the second (last) request must allow for + // 4500 as allowed fee share at maximum. + requestTwo = createTestRedemptionRequest( + walletPubKeyHash, + 4500 + ) - after(async () => { - bridge.deposits.reset() - - await restoreSnapshot() - }) - - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [deposit.key], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } - - // Corrupt the extra data by reversing the proper - // blinding factor used to produce the script. - const depositsExtraInfo = [ - { - ...deposit.extraInfo, - blindingFactor: `0x${Buffer.from( - deposit.extraInfo.blindingFactor.substring( - 2 - ), - "hex" - ) - .reverse() - .toString("hex")}`, - }, - ] - - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript ) - ).to.be.revertedWith( - "Extra info funding output script does not match" - ) - }) - } - ) - - context( - "when 32-byte funding output hash does not match", - () => { - let deposit - - before(async () => { - await createSnapshot() - - deposit = createTestDeposit( - walletPubKeyHash, - vault, - true // Produce a witness deposit with 32-byte script ) + .returns(requestOne.content) - bridge.deposits - .whenCalledWith( - depositKey( - deposit.key.fundingTxHash, - deposit.key.fundingOutputIndex - ) + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript ) - .returns(deposit.request) - }) - - after(async () => { - bridge.deposits.reset() + ) + .returns(requestTwo.content) + }) - await restoreSnapshot() - }) + after(async () => { + bridge.pendingRedemptions.reset() - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [deposit.key], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } + await restoreSnapshot() + }) - // Corrupt the extra data by reversing the proper - // blinding factor used to produce the script. - const depositsExtraInfo = [ - { - ...deposit.extraInfo, - blindingFactor: `0x${Buffer.from( - deposit.extraInfo.blindingFactor.substring( - 2 - ), - "hex" - ) - .reverse() - .toString("hex")}`, - }, - ] + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee: 9001, + } - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Extra info funding output script does not match" + await expect( + walletProposalValidator.validateRedemptionProposal( + proposal ) - }) - } - ) + ).to.be.revertedWith( + "Proposed transaction per-request fee share is too high" + ) + }) + }) } ) - context("when all deposits extra data are valid", () => { - context( - "when there is a deposit that violates the refund safety margin", - () => { - let depositOne - let depositTwo + context( + "when all requests incur an acceptable tx fee share", + () => { + context("when there are duplicated requests", () => { + let requestOne + let requestTwo + let requestThree before(async () => { await createSnapshot() - // Deposit one is a proper one. - depositOne = createTestDeposit( + requestOne = createTestRedemptionRequest( walletPubKeyHash, - vault, - true + 2500 // necessary to pass the fee share validation ) - // Simulate that deposit two violates the refund. - // In order to do so, we need to use `createTestDeposit` - // with a custom reveal time that will produce - // a refund locktime being closer to the current - // moment than allowed by the refund safety margin. - const safetyMarginViolatedAt = await lastBlockTime() - const depositRefundableAt = - safetyMarginViolatedAt + - (await walletCoordinator.depositRefundSafetyMargin()) - const depositRevealedAt = - depositRefundableAt - depositLocktime + requestTwo = createTestRedemptionRequest( + walletPubKeyHash, + 2500 // necessary to pass the fee share validation + ) - depositTwo = createTestDeposit( + requestThree = createTestRedemptionRequest( walletPubKeyHash, - vault, - false, - depositRevealedAt + 2500 // necessary to pass the fee share validation ) - bridge.deposits + bridge.pendingRedemptions .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript ) ) - .returns(depositOne.request) + .returns(requestOne.content) - bridge.deposits + bridge.pendingRedemptions .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript ) ) - .returns(depositTwo.request) + .returns(requestTwo.content) + + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestThree.key.walletPubKeyHash, + requestThree.key.redeemerOutputScript + ) + ) + .returns(requestThree.content) }) after(async () => { - bridge.deposits.reset() + bridge.pendingRedemptions.reset() await restoreSnapshot() }) @@ -1763,1393 +1828,792 @@ describe("WalletCoordinator", () => { it("should revert", async () => { const proposal = { walletPubKeyHash, - depositsKeys: [depositOne.key, depositTwo.key], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + requestThree.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, // duplicate + ], + redemptionTxFee, } - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo + walletProposalValidator.validateRedemptionProposal( + proposal ) - ).to.be.revertedWith( - "Deposit refund safety margin is not preserved" - ) + ).to.be.revertedWith("Duplicated request") }) - } - ) + }) - context( - "when all deposits preserve the refund safety margin", - () => { - context( - "when there is a deposit controlled by a different wallet", - () => { - let depositOne - let depositTwo + context("when all requests are unique", () => { + let requestOne + let requestTwo - before(async () => { - await createSnapshot() + before(async () => { + await createSnapshot() - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 5000 // necessary to pass the fee share validation + ) + + requestTwo = createTestRedemptionRequest( + walletPubKeyHash, + 5000 // necessary to pass the fee share validation + ) + + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript ) + ) + .returns(requestOne.content) - // Deposit two uses a different wallet than deposit - // one. - depositTwo = createTestDeposit( - `0x${Buffer.from( - walletPubKeyHash.substring(2), - "hex" - ) - .reverse() - .toString("hex")}`, - vault, - false - ) - - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex - ) - ) - .returns(depositOne.request) - - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex - ) - ) - .returns(depositTwo.request) - }) - - after(async () => { - bridge.deposits.reset() - - await restoreSnapshot() - }) - - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [ - depositOne.key, - depositTwo.key, - ], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } - - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] - - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Deposit controlled by different wallet" + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript ) - }) - } - ) - - context( - "when all deposits are controlled by the same wallet", - () => { - context( - "when there is a deposit targeting a different vault", - () => { - let depositOne - let depositTwo - - before(async () => { - await createSnapshot() - - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true - ) - - // Deposit two uses a different vault than deposit - // one. - depositTwo = createTestDeposit( - walletPubKeyHash, - `0x${Buffer.from( - vault.substring(2), - "hex" - ) - .reverse() - .toString("hex")}`, - false - ) - - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex - ) - ) - .returns(depositOne.request) - - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex - ) - ) - .returns(depositTwo.request) - }) - - after(async () => { - bridge.deposits.reset() - - await restoreSnapshot() - }) - - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [ - depositOne.key, - depositTwo.key, - ], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } - - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] - - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Deposit targets different vault" - ) - }) - } ) + .returns(requestTwo.content) + }) - context( - "when all deposits targets the same vault", - () => { - context( - "when there are duplicated deposits", - () => { - let depositOne - let depositTwo - let depositThree - - before(async () => { - await createSnapshot() - - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true - ) - - depositTwo = createTestDeposit( - walletPubKeyHash, - vault, - false - ) - - depositThree = createTestDeposit( - walletPubKeyHash, - vault, - false - ) - - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex - ) - ) - .returns(depositOne.request) - - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex - ) - ) - .returns(depositTwo.request) - - bridge.deposits - .whenCalledWith( - depositKey( - depositThree.key.fundingTxHash, - depositThree.key - .fundingOutputIndex - ) - ) - .returns(depositThree.request) - }) - - after(async () => { - bridge.deposits.reset() - - await restoreSnapshot() - }) - - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [ - depositOne.key, - depositTwo.key, - depositThree.key, - depositTwo.key, // duplicate - ], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } - - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - depositThree.extraInfo, - depositTwo.extraInfo, // duplicate - ] + after(async () => { + bridge.pendingRedemptions.reset() - await expect( - walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Duplicated deposit" - ) - }) - } - ) + await restoreSnapshot() + }) - context( - "when all deposits are unique", - () => { - let depositOne - let depositTwo - - before(async () => { - await createSnapshot() - - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true - ) - - depositTwo = createTestDeposit( - walletPubKeyHash, - vault, - false - ) - - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex - ) - ) - .returns(depositOne.request) - - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex - ) - ) - .returns(depositTwo.request) - }) - - after(async () => { - bridge.deposits.reset() - - await restoreSnapshot() - }) - - it("should succeed", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [ - depositOne.key, - depositTwo.key, - ], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } - - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] - - const result = - await walletCoordinator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - expect(result).to.be.true - }) - } - ) - } - ) - } - ) - } - ) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - - describe("updateRedemptionProposalParameters", () => { - before(async () => { - await createSnapshot() - }) - - after(async () => { - await restoreSnapshot() - }) - - context("when called by a third party", () => { - it("should revert", async () => { - await expect( - walletCoordinator - .connect(thirdParty) - .updateRedemptionProposalParameters(101, 102, 103, 104, 105) - ).to.be.revertedWith("Ownable: caller is not the owner") - }) - }) - - context("when called by the owner", () => { - let tx: ContractTransaction - - before(async () => { - await createSnapshot() - - tx = await walletCoordinator - .connect(owner) - .updateRedemptionProposalParameters(101, 102, 103, 104, 105) - }) - - after(async () => { - await restoreSnapshot() - }) - - it("should update redemption proposal parameters", async () => { - expect( - await walletCoordinator.redemptionProposalValidity() - ).to.be.equal(101) - expect(await walletCoordinator.redemptionRequestMinAge()).to.be.equal( - 102 - ) - expect( - await walletCoordinator.redemptionRequestTimeoutSafetyMargin() - ).to.be.equal(103) - expect(await walletCoordinator.redemptionMaxSize()).to.be.equal(104) - expect( - await walletCoordinator.redemptionProposalSubmissionGasOffset() - ).to.be.equal(105) - }) - - it("should emit the RedemptionProposalParametersUpdated event", async () => { - await expect(tx) - .to.emit(walletCoordinator, "RedemptionProposalParametersUpdated") - .withArgs(101, 102, 103, 104, 105) - }) - }) - }) - - describe("submitRedemptionProposal", () => { - const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" - - before(async () => { - await createSnapshot() - }) - - after(async () => { - await restoreSnapshot() - }) - - context("when the caller is not a coordinator", () => { - before(async () => { - await createSnapshot() - }) - - after(async () => { - await restoreSnapshot() - }) - - it("should revert", async () => { - const tx = walletCoordinator - .connect(thirdParty) - .submitRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts: [ - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", - ], - redemptionTxFee: 5000, - }) - - await expect(tx).to.be.revertedWith("Caller is not a coordinator") - }) - }) - - context("when the caller is a coordinator", () => { - before(async () => { - await createSnapshot() - - await walletCoordinator - .connect(owner) - .addCoordinator(thirdParty.address) - }) - - after(async () => { - await restoreSnapshot() - }) - - context("when wallet is time-locked", () => { - before(async () => { - await createSnapshot() - - // Submit a proposal to set a wallet time lock. - await walletCoordinator.connect(thirdParty).submitRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts: [ - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", - ], - redemptionTxFee: 5000, - }) - - // Jump to the end of the lock period but not beyond it. - await increaseTime( - (await walletCoordinator.redemptionProposalValidity()) - 1 - ) - }) - - after(async () => { - await restoreSnapshot() - }) - - it("should revert", async () => { - await expect( - walletCoordinator.connect(thirdParty).submitRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts: [ - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", - ], - redemptionTxFee: 5000, - }) - ).to.be.revertedWith("Wallet locked") - }) - }) - - context("when wallet is not time-locked", () => { - let tx: ContractTransaction - - before(async () => { - await createSnapshot() - - // Submit a proposal to set a wallet time lock. - await walletCoordinator.connect(thirdParty).submitRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts: [ - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", - ], - redemptionTxFee: 5000, - }) - - // Jump beyond the lock period. - await increaseTime( - await walletCoordinator.redemptionProposalValidity() - ) - - tx = await walletCoordinator - .connect(thirdParty) - .submitRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts: [ - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", - ], - redemptionTxFee: 6000, - }) - }) - - after(async () => { - await restoreSnapshot() - }) - - it("should time-lock the wallet", async () => { - const lockedUntil = - (await lastBlockTime()) + - (await walletCoordinator.redemptionProposalValidity()) - - const walletLock = await walletCoordinator.walletLock( - walletPubKeyHash - ) - - expect(walletLock.expiresAt).to.be.equal(lockedUntil) - expect(walletLock.cause).to.be.equal(walletAction.Redemption) - }) - - it("should emit the RedemptionProposalSubmitted event", async () => { - await expect(tx).to.emit( - walletCoordinator, - "RedemptionProposalSubmitted" - ) - - // The `expect.to.emit.withArgs` assertion has troubles with - // matching complex event arguments as it uses strict equality - // underneath. To overcome that problem, we manually get event's - // arguments and check it against the expected ones using deep - // equality assertion (eql). - const receipt = await ethers.provider.getTransactionReceipt(tx.hash) - expect(receipt.logs.length).to.be.equal(1) - expect( - walletCoordinator.interface.parseLog(receipt.logs[0]).args - ).to.be.eql([ - [ - walletPubKeyHash, - [ - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", - ], - BigNumber.from(6000), - ], - thirdParty.address, - ]) - }) - }) - }) - }) - - describe("submitRedemptionProposalWithReimbursement", () => { - const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" - - before(async () => { - await createSnapshot() - }) - - after(async () => { - await restoreSnapshot() - }) - - // Just double check that `submitRedemptionProposalWithReimbursement` has - // the same ACL as `submitRedemptionProposal`. - context("when the caller is not a coordinator", () => { - before(async () => { - await createSnapshot() - }) - - after(async () => { - await restoreSnapshot() - }) - - it("should revert", async () => { - const tx = walletCoordinator - .connect(thirdParty) - .submitRedemptionProposalWithReimbursement({ - walletPubKeyHash, - redeemersOutputScripts: [ - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", - ], - redemptionTxFee: 5000, - }) - - await expect(tx).to.be.revertedWith("Caller is not a coordinator") - }) - }) - - // Here we just check that the reimbursement works. Detailed - // assertions are already done within the scenario stressing the - // `submitRedemptionProposal` function. - context("when the caller is a coordinator", () => { - let coordinatorBalanceBefore: BigNumber - let coordinatorBalanceAfter: BigNumber - - before(async () => { - await createSnapshot() - - await walletCoordinator - .connect(owner) - .addCoordinator(thirdParty.address) - - // The first-ever proposal will be more expensive given it has to set - // fields to non-zero values. We shouldn't adjust gas offset based on it. - await walletCoordinator - .connect(thirdParty) - .submitRedemptionProposalWithReimbursement({ - walletPubKeyHash, - redeemersOutputScripts: [ - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", - ], - redemptionTxFee: 5000, - }) - - // Jump beyond the lock period. - await increaseTime(await walletCoordinator.redemptionProposalValidity()) - - coordinatorBalanceBefore = await provider.getBalance(thirdParty.address) - - await walletCoordinator - .connect(thirdParty) - .submitRedemptionProposalWithReimbursement({ - walletPubKeyHash, - redeemersOutputScripts: [ - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", - ], - redemptionTxFee: 5000, - }) - - coordinatorBalanceAfter = await provider.getBalance(thirdParty.address) - }) - - after(async () => { - await restoreSnapshot() - }) - - it("should do the refund", async () => { - const diff = coordinatorBalanceAfter.sub(coordinatorBalanceBefore) - expect(diff).to.be.gt(0) - expect(diff).to.be.lt(ethers.utils.parseUnits("4000000", "gwei")) // 0,004 ETH - }) - }) - }) - - describe("validateRedemptionProposal", () => { - const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" - const ecdsaWalletID = - "0x4ad6b3ccbca81645865d8d0d575797a15528e98ced22f29a6f906d3259569863" - - const bridgeRedemptionTxMaxTotalFee = 10000 - const bridgeRedemptionTimeout = 5 * 86400 // 5 days - - before(async () => { - await createSnapshot() - - bridge.redemptionParameters.returns([ - 0, - 0, - 0, - bridgeRedemptionTxMaxTotalFee, - bridgeRedemptionTimeout, - 0, - 0, - ]) - }) - - after(async () => { - bridge.redemptionParameters.reset() - - await restoreSnapshot() - }) - - context("when wallet is not Live", () => { - const testData = [ - { - testName: "when wallet state is Unknown", - walletState: walletState.Unknown, - }, - { - testName: "when wallet state is MovingFunds", - walletState: walletState.MovingFunds, - }, - { - testName: "when wallet state is Closing", - walletState: walletState.Closing, - }, - { - testName: "when wallet state is Closed", - walletState: walletState.Closed, - }, - { - testName: "when wallet state is Terminated", - walletState: walletState.Terminated, - }, - ] - - testData.forEach((test) => { - context(test.testName, () => { - before(async () => { - await createSnapshot() - - bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ - ecdsaWalletID, - mainUtxoHash: HashZero, - pendingRedemptionsValue: 0, - createdAt: 0, - movingFundsRequestedAt: 0, - closingStartedAt: 0, - pendingMovedFundsSweepRequestsCount: 0, - state: test.walletState, - movingFundsTargetWalletsCommitmentHash: HashZero, - }) - }) - - after(async () => { - bridge.wallets.reset() - - await restoreSnapshot() - }) - - it("should revert", async () => { - await expect( - // Only walletPubKeyHash argument is relevant in this scenario. - walletCoordinator.validateRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts: [], - redemptionTxFee: 0, - }) - ).to.be.revertedWith("Wallet is not in Live state") - }) - }) - }) - }) - - context("when wallet is Live", () => { - before(async () => { - await createSnapshot() - - bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ - ecdsaWalletID, - mainUtxoHash: HashZero, - pendingRedemptionsValue: 0, - createdAt: 0, - movingFundsRequestedAt: 0, - closingStartedAt: 0, - pendingMovedFundsSweepRequestsCount: 0, - state: walletState.Live, - movingFundsTargetWalletsCommitmentHash: HashZero, - }) - }) - - after(async () => { - bridge.wallets.reset() - - await restoreSnapshot() - }) - - context("when redemption is below the min size", () => { - it("should revert", async () => { - await expect( - walletCoordinator.validateRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts: [], // Set size to 0. - redemptionTxFee: 0, // Not relevant in this scenario. - }) - ).to.be.revertedWith("Redemption below the min size") - }) - }) - - context("when redemption is above the min size", () => { - context("when redemption exceeds the max size", () => { - it("should revert", async () => { - const maxSize = await walletCoordinator.redemptionMaxSize() - - // Pick more redemption requests than allowed. - const redeemersOutputScripts = new Array(maxSize + 1).fill( - createTestRedemptionRequest(walletPubKeyHash).key - .redeemerOutputScript - ) - - await expect( - walletCoordinator.validateRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts, - redemptionTxFee: 0, // Not relevant in this scenario. - }) - ).to.be.revertedWith("Redemption exceeds the max size") - }) - }) - - context("when redemption does not exceed the max size", () => { - context("when proposed redemption tx fee is invalid", () => { - context("when proposed redemption tx fee is zero", () => { - it("should revert", async () => { - await expect( - walletCoordinator.validateRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts: [ - createTestRedemptionRequest(walletPubKeyHash).key - .redeemerOutputScript, - ], - redemptionTxFee: 0, - }) - ).to.be.revertedWith("Proposed transaction fee cannot be zero") - }) - }) - - context( - "when proposed redemption tx fee is greater than the allowed total fee", - () => { - it("should revert", async () => { - await expect( - walletCoordinator.validateRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts: [ - createTestRedemptionRequest(walletPubKeyHash).key - .redeemerOutputScript, - ], - // Exceed the max per-request fee by one. - redemptionTxFee: bridgeRedemptionTxMaxTotalFee + 1, - }) - ).to.be.revertedWith("Proposed transaction fee is too high") - }) - } - ) - - // The context block covering the per-redemption fee checks is - // declared at the end of the `validateRedemptionProposal` test suite - // due to the actual order of checks performed by this function. - // See: "when there is a request that incurs an unacceptable tx fee share" - }) - - context("when proposed redemption tx fee is valid", () => { - const redemptionTxFee = 9000 - - context("when there is a non-pending request", () => { - let requestOne - let requestTwo - - before(async () => { - await createSnapshot() - - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 5000 // necessary to pass the fee share validation - ) - requestTwo = createTestRedemptionRequest(walletPubKeyHash) + it("should succeed", async () => { + const proposal = { + walletPubKeyHash, + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, + } - // Request one is a proper one. - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) - ) - .returns(requestOne.content) + const result = + await walletProposalValidator.validateRedemptionProposal( + proposal + ) - // Simulate the request two is non-pending. - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(result).to.be.true + }) + }) + } ) - ) - .returns({ - ...requestTwo.content, - requestedAt: 0, - }) + } + ) }) + }) + }) + }) + }) + }) + }) - after(async () => { - bridge.pendingRedemptions.reset() + describe("validateMovingFundsProposal", () => { + const movingFundsTxMaxTotalFee = 20000 + const movingFundsDustThreshold = 5000 + const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" + const targetWallets = [ + "0x84a70187011e156686788e0a2bc50944a4721e83", + "0xf64a45c07e3778b8ce58cb0058477c821c543aad", + "0xcaea95433d9bfa80bb8dc8819a48e2a9aa96147c", + ] + // Hash calculated from the above target wallets. + const targetWalletsHash = + "0x16311d424d513a1743fbc9c0e4fea5b70eddefd15f54613503e5cdfab24f8877" + const walletMainUtxo = { + txHash: + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + txOutputIndex: 0, + txOutputValue: movingFundsDustThreshold, + } + // Hash calculated from the above main UTXO. + const walletMainUtxoHash = + "0x4cfb92c890e30ff736656e519167cbfcacb408c730fd21bec415359b45769d20" - await restoreSnapshot() - }) + before(async () => { + await createSnapshot() - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee, - } + bridge.movingFundsParameters.returns([ + movingFundsTxMaxTotalFee, + movingFundsDustThreshold, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ]) + }) - await expect( - walletCoordinator.validateRedemptionProposal(proposal) - ).to.be.revertedWith("Not a pending redemption request") - }) - }) + after(async () => { + bridge.movingFundsParameters.reset() - context("when all requests are pending", () => { - context("when there is an immature request", () => { - let requestOne - let requestTwo + await restoreSnapshot() + }) - before(async () => { - await createSnapshot() + context("when wallet's state is not MovingFunds", () => { + const testData = [ + { + testName: "when wallet state is Unknown", + walletState: walletState.Unknown, + }, + { + testName: "when wallet state is Live", + walletState: walletState.Live, + }, + { + testName: "when wallet state is Closing", + walletState: walletState.Closing, + }, + { + testName: "when wallet state is Closed", + walletState: walletState.Closed, + }, + { + testName: "when wallet state is Terminated", + walletState: walletState.Terminated, + }, + ] - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 5000 // necessary to pass the fee share validation - ) - requestTwo = createTestRedemptionRequest(walletPubKeyHash) + testData.forEach((test) => { + context(test.testName, () => { + before(async () => { + await createSnapshot() - // Request one is a proper one. - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) - ) - .returns(requestOne.content) + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID: HashZero, + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: test.walletState, + movingFundsTargetWalletsCommitmentHash: HashZero, + }) + }) - // Simulate the request two has just been created thus not - // achieved the min age yet. - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript - ) - ) - .returns({ - ...requestTwo.content, - requestedAt: await lastBlockTime(), - }) - }) + after(async () => { + bridge.wallets.reset() - after(async () => { - bridge.pendingRedemptions.reset() + await restoreSnapshot() + }) - await restoreSnapshot() - }) + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovingFundsProposal( + { + walletPubKeyHash, + movingFundsTxFee: 0, + targetWallets: [], + }, + NO_MAIN_UTXO + ) + ).to.be.revertedWith("Source wallet is not in MovingFunds state") + }) + }) + }) + }) - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee, - } + context("when wallet's state is MovingFunds", () => { + context("when moving funds commitment has not been submitted", () => { + before(async () => { + await createSnapshot() - await expect( - walletCoordinator.validateRedemptionProposal(proposal) - ).to.be.revertedWith( - "Redemption request min age not achieved yet" - ) - }) - }) + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID: HashZero, + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.MovingFunds, + // Indicate the commitment has not been submitted. + movingFundsTargetWalletsCommitmentHash: HashZero, + }) + }) - context("when all requests achieved the min age", () => { - context( - "when there is a request that violates the timeout safety margin", - () => { - let requestOne - let requestTwo + after(async () => { + bridge.wallets.reset() - before(async () => { - await createSnapshot() + await restoreSnapshot() + }) - // Request one is a proper one. - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 5000 // necessary to pass the fee share validation - ) + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovingFundsProposal( + { + walletPubKeyHash, + movingFundsTxFee: 0, + targetWallets: [], + }, + NO_MAIN_UTXO + ) + ).to.be.revertedWith("Target wallets commitment is not submitted") + }) + }) - // Simulate that request two violates the timeout safety margin. - // In order to do so, we need to use `createTestRedemptionRequest` - // with a custom request creation time that will produce - // a timeout timestamp being closer to the current - // moment than allowed by the refund safety margin. - const safetyMarginViolatedAt = await lastBlockTime() - const requestTimedOutAt = - safetyMarginViolatedAt + - (await walletCoordinator.redemptionRequestTimeoutSafetyMargin()) - const requestCreatedAt = - requestTimedOutAt - bridgeRedemptionTimeout + context("when moving funds commitment has been submitted", () => { + context("when commitment hash does not match target wallets", () => { + before(async () => { + await createSnapshot() - requestTwo = createTestRedemptionRequest( - walletPubKeyHash, - 0, - requestCreatedAt - ) + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID: HashZero, + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.MovingFunds, + // Set a hash that does not match the target wallets. + movingFundsTargetWalletsCommitmentHash: + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }) + }) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) - ) - .returns(requestOne.content) + after(async () => { + bridge.wallets.reset() - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript - ) - ) - .returns(requestTwo.content) - }) + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovingFundsProposal( + { + walletPubKeyHash, + movingFundsTxFee: 0, + targetWallets, + }, + NO_MAIN_UTXO + ) + ).to.be.revertedWith( + "Target wallets do not match target wallets commitment hash" + ) + }) + }) - after(async () => { - bridge.pendingRedemptions.reset() + context("when commitment hash matches target wallets", () => { + context("when no main UTXO is passed", () => { + before(async () => { + await createSnapshot() + + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID: HashZero, + // Use zero hash so that the wallet's main UTXO is considered + // not set. This will be interpreted as the wallet having BTC + // balance of zero. + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.MovingFunds, + movingFundsTargetWalletsCommitmentHash: targetWalletsHash, + }) + }) - await restoreSnapshot() - }) + after(async () => { + bridge.wallets.reset() - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee, - } + await restoreSnapshot() + }) - await expect( - walletCoordinator.validateRedemptionProposal(proposal) - ).to.be.revertedWith( - "Redemption request timeout safety margin is not preserved" - ) - }) - } + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovingFundsProposal( + { + walletPubKeyHash, + movingFundsTxFee: 0, + targetWallets, + }, + NO_MAIN_UTXO ) + ).to.be.revertedWith( + "Source wallet BTC balance is below the moving funds dust threshold" + ) + }) + }) - context( - "when all requests preserve the timeout safety margin", - () => { - context( - "when there is a request that incurs an unacceptable tx fee share", - () => { - context("when there is no fee remainder", () => { - let requestOne - let requestTwo + context("when the passed main UTXO is incorrect", () => { + before(async () => { + await createSnapshot() + + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID: HashZero, + mainUtxoHash: + // Use any non-zero hash to indicate the wallet has a main UTXO. + "0x1111111111111111111111111111111111111111111111111111111111111111", + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.MovingFunds, + movingFundsTargetWalletsCommitmentHash: targetWalletsHash, + }) + }) - before(async () => { - await createSnapshot() + after(async () => { + bridge.wallets.reset() - // Request one is a proper one. - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 4500 // necessary to pass the fee share validation - ) + await restoreSnapshot() + }) - // Simulate that request two takes an unacceptable - // tx fee share. Because redemptionTxFee used - // in the proposal is 9000, the actual fee share - // per-request is 4500. In order to test this case - // the second request must allow for 4499 as allowed - // fee share at maximum. - requestTwo = createTestRedemptionRequest( - walletPubKeyHash, - 4499 - ) + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovingFundsProposal( + { + walletPubKeyHash, + movingFundsTxFee: 0, + targetWallets, + }, + { + txHash: + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + txOutputIndex: 0, + txOutputValue: 1000, + } + ) + ).to.be.revertedWith("Invalid wallet main UTXO data") + }) + }) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) - ) - .returns(requestOne.content) + context("when the passed main UTXO is correct", () => { + context( + "when source wallet BTC balance is below dust threshold", + () => { + before(async () => { + await createSnapshot() - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript - ) - ) - .returns(requestTwo.content) - }) + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID: HashZero, + mainUtxoHash: + "0x757a5ca2a1e5fff2f2a51c073cb88c097603285fcfa52cb58473704647fa7edb", + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.MovingFunds, + movingFundsTargetWalletsCommitmentHash: targetWalletsHash, + }) + }) - after(async () => { - bridge.pendingRedemptions.reset() + after(async () => { + bridge.wallets.reset() - await restoreSnapshot() - }) + await restoreSnapshot() + }) - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee, - } + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovingFundsProposal( + { + walletPubKeyHash, + movingFundsTxFee: 0, + targetWallets, + }, + { + txHash: + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + txOutputIndex: 0, + txOutputValue: movingFundsDustThreshold - 1, + } + ) + ).to.be.revertedWith( + "Source wallet BTC balance is below the moving funds dust threshold" + ) + }) + } + ) - await expect( - walletCoordinator.validateRedemptionProposal( - proposal - ) - ).to.be.revertedWith( - "Proposed transaction per-request fee share is too high" - ) - }) - }) + context( + "when source wallet BTC balance is equal to or greater that dust threshold", + () => { + before(async () => { + await createSnapshot() + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID: HashZero, + mainUtxoHash: walletMainUtxoHash, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: walletState.MovingFunds, + movingFundsTargetWalletsCommitmentHash: targetWalletsHash, + }) + }) - context("when there is a fee remainder", () => { - let requestOne - let requestTwo + after(async () => { + bridge.wallets.reset() - before(async () => { - await createSnapshot() + await restoreSnapshot() + }) - // Request one is a proper one. - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 4500 // necessary to pass the fee share validation - ) + context("when transaction fee is zero", () => { + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovingFundsProposal( + { + walletPubKeyHash, + movingFundsTxFee: 0, + targetWallets, + }, + walletMainUtxo + ) + ).to.be.revertedWith( + "Proposed transaction fee cannot be zero" + ) + }) + }) - // Simulate that request two takes an unacceptable - // tx fee share. Because redemptionTxFee used - // in the proposal is 9001, the actual fee share - // per-request is 4500 and 4501 for the last request - // which takes the remainder. In order to test this - // case the second (last) request must allow for - // 4500 as allowed fee share at maximum. - requestTwo = createTestRedemptionRequest( - walletPubKeyHash, - 4500 - ) + context("when transaction fee is too high", () => { + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovingFundsProposal( + { + walletPubKeyHash, + movingFundsTxFee: movingFundsTxMaxTotalFee + 1, + targetWallets, + }, + walletMainUtxo + ) + ).to.be.revertedWith("Proposed transaction fee is too high") + }) + }) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) - ) - .returns(requestOne.content) + context("when transaction fee is valid", () => { + it("should pass validation", async () => { + const result = + await walletProposalValidator.validateMovingFundsProposal( + { + walletPubKeyHash, + movingFundsTxFee: movingFundsTxMaxTotalFee, + targetWallets, + }, + walletMainUtxo + ) + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(result).to.be.true + }) + }) + } + ) + }) + }) + }) + }) + }) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript - ) - ) - .returns(requestTwo.content) - }) + describe("validateMovedFundsSweepProposal", () => { + const movedFundsSweepTxMaxTotalFee = 20000 + const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" - after(async () => { - bridge.pendingRedemptions.reset() + // Hash and index representing a pending moved funds sweep request. + const movingFundsTxHash = + "0xfedaee64895aebdd94d21d932960f96fcdb95d17ca64b25aee0450631a1291a9" + const movingFundsTxOutputIndex = 0 - await restoreSnapshot() - }) + before(async () => { + await createSnapshot() - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee: 9001, - } + bridge.movingFundsParameters.returns([ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + movedFundsSweepTxMaxTotalFee, + 0, + 0, + 0, + ]) + }) - await expect( - walletCoordinator.validateRedemptionProposal( - proposal - ) - ).to.be.revertedWith( - "Proposed transaction per-request fee share is too high" - ) - }) - }) - } - ) + after(async () => { + bridge.movingFundsParameters.reset() + + await restoreSnapshot() + }) + + context("when wallet's state is incorrect", () => { + const testData = [ + { + testName: "when wallet state is Unknown", + walletState: walletState.Unknown, + }, + { + testName: "when wallet state is Closing", + walletState: walletState.Closing, + }, + { + testName: "when wallet state is Closed", + walletState: walletState.Closed, + }, + { + testName: "when wallet state is Terminated", + walletState: walletState.Terminated, + }, + ] - context( - "when all requests incur an acceptable tx fee share", - () => { - context("when there are duplicated requests", () => { - let requestOne - let requestTwo - let requestThree + testData.forEach((test) => { + context(test.testName, () => { + before(async () => { + await createSnapshot() - before(async () => { - await createSnapshot() + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID: HashZero, + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: test.walletState, + movingFundsTargetWalletsCommitmentHash: HashZero, + }) + }) - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 2500 // necessary to pass the fee share validation - ) + after(async () => { + bridge.wallets.reset() - requestTwo = createTestRedemptionRequest( - walletPubKeyHash, - 2500 // necessary to pass the fee share validation - ) + await restoreSnapshot() + }) - requestThree = createTestRedemptionRequest( - walletPubKeyHash, - 2500 // necessary to pass the fee share validation - ) + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovedFundsSweepProposal({ + walletPubKeyHash, + movingFundsTxHash, + movingFundsTxOutputIndex, + movedFundsSweepTxFee: 0, + }) + ).to.be.revertedWith( + "Source wallet is not in Live or MovingFunds state" + ) + }) + }) + }) + }) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) - ) - .returns(requestOne.content) + context("when wallet's state is correct", () => { + const testData = [ + { + testName: "when wallet state is Live", + walletState: walletState.Live, + }, + { + testName: "when wallet state is MovingFunds", + walletState: walletState.MovingFunds, + }, + ] - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript - ) - ) - .returns(requestTwo.content) + testData.forEach((test) => { + context(test.testName, () => { + before(async () => { + await createSnapshot() - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestThree.key.walletPubKeyHash, - requestThree.key.redeemerOutputScript - ) - ) - .returns(requestThree.content) - }) + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID: HashZero, + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: test.walletState, + movingFundsTargetWalletsCommitmentHash: HashZero, + }) + }) - after(async () => { - bridge.pendingRedemptions.reset() + after(async () => { + bridge.wallets.reset() - await restoreSnapshot() - }) + await restoreSnapshot() + }) - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - requestThree.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, // duplicate - ], - redemptionTxFee, - } + context( + "when moved funds sweep request's state is not Pending", + () => { + it("should revert", async () => { + // Use hash and index without setting a sweep request in the Bridge. + await expect( + walletProposalValidator.validateMovedFundsSweepProposal({ + walletPubKeyHash, + movingFundsTxHash, + movingFundsTxOutputIndex, + movedFundsSweepTxFee: 0, + }) + ).to.be.revertedWith("Sweep request is not in Pending state") + }) + } + ) - await expect( - walletCoordinator.validateRedemptionProposal( - proposal - ) - ).to.be.revertedWith("Duplicated request") - }) - }) + context("when moved funds sweep request's state is Pending", () => { + context( + "when moved funds sweep request does not belong to the wallet", + () => { + before(async () => { + await createSnapshot() - context("when all requests are unique", () => { - let requestOne - let requestTwo + const requestKey = movedFundsSweepRequestKey( + movingFundsTxHash, + movingFundsTxOutputIndex + ) - before(async () => { - await createSnapshot() + bridge.movedFundsSweepRequests + .whenCalledWith(requestKey) + .returns({ + // Use random wallet public key hash. + walletPubKeyHash: + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + value: 0, + createdAt: 0, + state: movedFundsSweepRequestState.Pending, + }) + }) - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 5000 // necessary to pass the fee share validation - ) + after(async () => { + bridge.movedFundsSweepRequests.reset() - requestTwo = createTestRedemptionRequest( - walletPubKeyHash, - 5000 // necessary to pass the fee share validation - ) + await restoreSnapshot() + }) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) - ) - .returns(requestOne.content) + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovedFundsSweepProposal({ + walletPubKeyHash, + movingFundsTxHash, + movingFundsTxOutputIndex, + movedFundsSweepTxFee: 0, + }) + ).to.be.revertedWith( + "Sweep request does not belong to the wallet" + ) + }) + } + ) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript - ) - ) - .returns(requestTwo.content) - }) + context( + "when moved funds sweep request belongs to the wallet", + () => { + before(async () => { + await createSnapshot() - after(async () => { - bridge.pendingRedemptions.reset() + const requestKey = movedFundsSweepRequestKey( + movingFundsTxHash, + movingFundsTxOutputIndex + ) - await restoreSnapshot() - }) + bridge.movedFundsSweepRequests + .whenCalledWith(requestKey) + .returns({ + walletPubKeyHash, + value: 0, + createdAt: 0, + state: movedFundsSweepRequestState.Pending, + }) + }) - it("should succeed", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee, - } + after(async () => { + bridge.movedFundsSweepRequests.reset() - const result = - await walletCoordinator.validateRedemptionProposal( - proposal - ) + await restoreSnapshot() + }) - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - expect(result).to.be.true - }) - }) - } + context("when transaction fee is zero", () => { + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovedFundsSweepProposal({ + walletPubKeyHash, + movingFundsTxHash, + movingFundsTxOutputIndex, + movedFundsSweepTxFee: 0, + }) + ).to.be.revertedWith( + "Proposed transaction fee cannot be zero" ) - } - ) - }) - }) + }) + }) + + context("when transaction fee is too high", () => { + it("should revert", async () => { + await expect( + walletProposalValidator.validateMovedFundsSweepProposal({ + walletPubKeyHash, + movingFundsTxHash, + movingFundsTxOutputIndex, + movedFundsSweepTxFee: movedFundsSweepTxMaxTotalFee + 1, + }) + ).to.be.revertedWith("Proposed transaction fee is too high") + }) + }) + + context("when transaction fee is valid", () => { + it("should pass validation", async () => { + const result = + await walletProposalValidator.validateMovedFundsSweepProposal( + { + walletPubKeyHash, + movingFundsTxHash, + movingFundsTxOutputIndex, + movedFundsSweepTxFee: movedFundsSweepTxMaxTotalFee, + } + ) + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(result).to.be.true + }) + }) + } + ) + }) + }) + }) + }) + }) + + describe("validateHeartbeatProposal", () => { + context("when message is not valid", () => { + it("should revert", async () => { + await expect( + walletProposalValidator.validateHeartbeatProposal({ + walletPubKeyHash: AddressZero, + message: "0xfffffffffffffff21111111111111111", }) + ).to.be.revertedWith("Not a valid heartbeat message") + }) + }) + + context("when message is valid", () => { + it("should succeed", async () => { + const result = await walletProposalValidator.validateHeartbeatProposal({ + walletPubKeyHash: AddressZero, + message: "0xffffffffffffffff1111111111111111", }) + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(result).to.be.true }) }) }) @@ -3168,7 +2632,8 @@ const createTestDeposit = ( walletPubKeyHash: string, vault: string, witness = true, - revealedAt?: number + revealedAt?: number, + extraData?: string ) => { let resolvedRevealedAt = revealedAt @@ -3198,10 +2663,28 @@ const createTestDeposit = ( const blindingFactor = `0x${crypto.randomBytes(8).toString("hex")}` const refundPubKeyHash = `0x${crypto.randomBytes(20).toString("hex")}` - const depositScript = - `0x14${depositor.substring(2)}7508${blindingFactor.substring(2)}7576a914` + - `${walletPubKeyHash.substring(2)}8763ac6776a914` + - `${refundPubKeyHash.substring(2)}8804${refundLocktime.substring(2)}b175ac68` + let depositScript + + if (extraData) { + depositScript = + `0x14${depositor.substring(2)}75` + + `20${extraData.substring(2)}75` + + `08${blindingFactor.substring(2)}75` + + `76a914${walletPubKeyHash.substring(2)}87` + + "63ac67" + + `76a914${refundPubKeyHash.substring(2)}88` + + `04${refundLocktime.substring(2)}b175` + + "ac68" + } else { + depositScript = + `0x14${depositor.substring(2)}75` + + `08${blindingFactor.substring(2)}75` + + `76a914${walletPubKeyHash.substring(2)}87` + + "63ac67" + + `76a914${refundPubKeyHash.substring(2)}88` + + `04${refundLocktime.substring(2)}b175` + + "ac68" + } let depositScriptHash if (witness) { @@ -3245,6 +2728,7 @@ const createTestDeposit = ( vault, treasuryFee: 0, // not relevant sweptAt: 0, // important to pass the validation + extraData: extraData ?? ethers.constants.HashZero, }, extraInfo: { fundingTx, @@ -3303,3 +2787,12 @@ const createTestRedemptionRequest = ( }, } } + +const movedFundsSweepRequestKey = ( + movingFundsTxHash: BytesLike, + movingFundsTxOutputIndex: number +) => + ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [movingFundsTxHash, movingFundsTxOutputIndex] + ) diff --git a/solidity/test/vault/TBTCVault.OptimisticMinting.test.ts b/solidity/test/vault/TBTCVault.OptimisticMinting.test.ts index d09356777..717f1b8ef 100644 --- a/solidity/test/vault/TBTCVault.OptimisticMinting.test.ts +++ b/solidity/test/vault/TBTCVault.OptimisticMinting.test.ts @@ -1789,6 +1789,7 @@ describe("TBTCVault - OptimisticMinting", () => { vault: f.tbtcVault.address, treasuryFee: 10, sweptAt: 0, + extraData: ethers.constants.HashZero, }) f.mockBridge.deposits.whenCalledWith(secondDepositID).returns({ depositor: depositorAddress, @@ -1797,6 +1798,7 @@ describe("TBTCVault - OptimisticMinting", () => { vault: f.tbtcVault.address, treasuryFee: 15, sweptAt: 0, + extraData: ethers.constants.HashZero, }) await f.tbtcVault @@ -1895,6 +1897,7 @@ describe("TBTCVault - OptimisticMinting", () => { vault: f.tbtcVault.address, treasuryFee: 10, sweptAt: 0, + extraData: ethers.constants.HashZero, }) await f.tbtcVault