diff --git a/solidity/contracts/bridge/WalletProposalValidator.sol b/solidity/contracts/bridge/WalletProposalValidator.sol index 4a0dddb3d..5095094f0 100644 --- a/solidity/contracts/bridge/WalletProposalValidator.sol +++ b/solidity/contracts/bridge/WalletProposalValidator.sol @@ -94,6 +94,16 @@ contract WalletProposalValidator { uint256 redemptionTxFee; } + /// @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 Helper structure representing a heartbeat proposal. struct HeartbeatProposal { // 20-byte public key hash of the target wallet. @@ -587,6 +597,124 @@ contract WalletProposalValidator { return true; } + 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 eligible for moving funds. + require( + sourceWallet.state == Wallets.WalletState.MovingFunds, + "Source wallet is not in MovingFunds state" + ); + + require( + sourceWallet.pendingRedemptionsValue == 0, + "Source wallet has pending redemptions" + ); + + require( + sourceWallet.pendingMovedFundsSweepRequestsCount == 0, + "Source wallet has pending moved funds sweep requests" + ); + + // Make sure the number of target wallets is correct. + uint64 sourceWalletBtcBalance = getWalletBtcBalance( + sourceWallet.mainUtxoHash, + walletMainUtxo + ); + + require( + sourceWalletBtcBalance > 0, + "Source wallet BTC balance is zero" + ); + + uint32 liveWalletsCount = bridge.liveWalletsCount(); + + (, , , , , uint64 walletMaxBtcTransfer, ) = bridge.walletParameters(); + + uint256 expectedTargetWalletsCount = Math.min( + liveWalletsCount, + Math.ceilDiv(sourceWalletBtcBalance, walletMaxBtcTransfer) + ); + + require(expectedTargetWalletsCount > 0, "No target wallets available"); + + require( + proposal.targetWallets.length == expectedTargetWalletsCount, + "Submitted target wallets count is other than expected" + ); + + // Make sure the target wallets are Live and are ordered correctly. + uint160 lastProcessedTargetWallet = 0; + + for (uint256 i = 0; i < proposal.targetWallets.length; i++) { + bytes20 targetWallet = proposal.targetWallets[i]; + + require( + targetWallet != proposal.walletPubKeyHash, + "Target wallet is equal to source wallet" + ); + + require( + uint160(targetWallet) > lastProcessedTargetWallet, + "Target wallet order is incorrect" + ); + + // slither-disable-next-line calls-loop + require( + bridge.wallets(targetWallet).state == Wallets.WalletState.Live, + "Target wallet is not in Live state" + ); + + lastProcessedTargetWallet = uint160(targetWallet); + } + + // Make sure the proposed fee does not exceed the total fee limit. + (uint64 movingFundsTxMaxTotalFee, , , , , , , , , , ) = bridge + .movingFundsParameters(); + + require( + proposal.movingFundsTxFee > 0, + "Proposed transaction fee cannot be zero" + ); + + require( + proposal.movingFundsTxFee <= movingFundsTxMaxTotalFee, + "Proposed transaction fee is too high" + ); + + return true; + } + + 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 heartbeat /// proposal. This function is meant to facilitate the off-chain /// validation of the incoming proposals. Thanks to it, most diff --git a/solidity/test/bridge/WalletProposalValidator.test.ts b/solidity/test/bridge/WalletProposalValidator.test.ts index 8755e057d..6c1fbf3e0 100644 --- a/solidity/test/bridge/WalletProposalValidator.test.ts +++ b/solidity/test/bridge/WalletProposalValidator.test.ts @@ -1894,6 +1894,91 @@ describe("WalletProposalValidator", () => { }) }) + describe("validateMovingFundsProposal", () => { + const walletPubKeyHash = "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726" + const ecdsaWalletID = + "0x4ad6b3ccbca81645865d8d0d575797a15528e98ced22f29a6f906d3259569863" + + before(async () => { + await createSnapshot() + + // TODO: Fill with appropriate parameters. + bridge.movingFundsParameters.returns([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + }) + + after(async () => { + bridge.movingFundsParameters.reset() + + await restoreSnapshot() + }) + + 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, + }, + ] + + 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. + walletProposalValidator.validateMovingFundsProposal({ + walletPubKeyHash, + movingFundsTxFee: 0, + targetWallets: [], + }) + ).to.be.revertedWith("Source wallet is not in MovingFunds state") + }) + }) + }) + }) + + context("when wallet's state is MovingFunds", () => { + // TODO: Implement + }) + }) + describe("validateHeartbeatProposal", () => { context("when message is not valid", () => { it("should revert", async () => {