From bcef0ec4fd00db26f717fbc3f8bf7cf7eb9cdb99 Mon Sep 17 00:00:00 2001 From: Tomasz Slabon Date: Thu, 7 Mar 2024 12:35:13 +0100 Subject: [PATCH] Updated deposit sweep and redemption proposal validation --- .../bridge/WalletProposalValidator.sol | 24 +- .../bridge/WalletProposalValidator.test.ts | 3069 +++++++++-------- 2 files changed, 1589 insertions(+), 1504 deletions(-) diff --git a/solidity/contracts/bridge/WalletProposalValidator.sol b/solidity/contracts/bridge/WalletProposalValidator.sol index d2e5305b1..6f94df0b5 100644 --- a/solidity/contracts/bridge/WalletProposalValidator.sol +++ b/solidity/contracts/bridge/WalletProposalValidator.sol @@ -216,7 +216,7 @@ contract WalletProposalValidator { /// @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 target wallet must be in the Live or MovingFunds state, /// - The number of deposits included in the sweep must be in /// the range [1, `DEPOSIT_SWEEP_MAX_SIZE`], /// - The length of `depositsExtraInfo` array must be equal to the @@ -242,10 +242,14 @@ contract WalletProposalValidator { DepositSweepProposal calldata proposal, DepositExtraInfo[] calldata depositsExtraInfo ) external view returns (bool) { + Wallets.Wallet memory wallet = bridge.wallets( + proposal.walletPubKeyHash + ); + require( - bridge.wallets(proposal.walletPubKeyHash).state == - Wallets.WalletState.Live, - "Wallet is not in Live state" + wallet.state == Wallets.WalletState.Live || + wallet.state == Wallets.WalletState.MovingFunds, + "Wallet is not in Live or MovingFunds state" ); require(proposal.depositsKeys.length > 0, "Sweep below the min size"); @@ -517,7 +521,7 @@ contract WalletProposalValidator { /// @param proposal The redemption proposal to validate. /// @return True if the proposal is valid. Reverts otherwise. /// @dev Requirements: - /// - The target wallet must be in the Live state, + /// - The target wallet must be in the Live or MovingFunds state, /// - The number of redemption requests included in the redemption /// proposal must be in the range [1, `redemptionMaxSize`], /// - The proposed redemption tx fee must be grater than zero, @@ -539,10 +543,14 @@ contract WalletProposalValidator { view returns (bool) { + Wallets.Wallet memory wallet = bridge.wallets( + proposal.walletPubKeyHash + ); + require( - bridge.wallets(proposal.walletPubKeyHash).state == - Wallets.WalletState.Live, - "Wallet is not in Live state" + wallet.state == Wallets.WalletState.Live || + wallet.state == Wallets.WalletState.MovingFunds, + "Wallet is not in Live or MovingFunds state" ); uint256 requestsCount = proposal.redeemersOutputScripts.length; diff --git a/solidity/test/bridge/WalletProposalValidator.test.ts b/solidity/test/bridge/WalletProposalValidator.test.ts index 8b29944e8..e977f22b6 100644 --- a/solidity/test/bridge/WalletProposalValidator.test.ts +++ b/solidity/test/bridge/WalletProposalValidator.test.ts @@ -70,16 +70,12 @@ describe("WalletProposalValidator", () => { await restoreSnapshot() }) - context("when wallet is not Live", () => { + context("when wallet is incorrect state", () => { 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, @@ -130,152 +126,106 @@ describe("WalletProposalValidator", () => { }, [] ) - ).to.be.revertedWith("Wallet is not in Live state") + ).to.be.revertedWith("Wallet is not in Live or MovingFunds 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 wallet is correct state", () => { + const testData = [ + { + testName: "when wallet state is Live", + walletState: walletState.Live, + }, + { + testName: "when wallet state is MovingFunds", + walletState: walletState.MovingFunds, + }, + ] - context("when sweep is below the min size", () => { - it("should revert", async () => { - await expect( - 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") - }) - }) + testData.forEach((test) => { + context(test.testName, () => { + before(async () => { + await createSnapshot() - 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() + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID, + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: test.walletState, + movingFundsTargetWalletsCommitmentHash: HashZero, + }) + }) - // Pick more deposits than allowed. - const depositsKeys = new Array(maxSize + 1).fill( - createTestDeposit(walletPubKeyHash, vault).key - ) + after(async () => { + bridge.wallets.reset() - 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") + await restoreSnapshot() }) - }) - context("when sweep does not exceed the max size", () => { - context("when deposit extra info length does not match", () => { + context("when sweep is below the min size", () => { 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 info array contains two items. - const depositsExtraInfo = [ - emptyDepositExtraInfo, - emptyDepositExtraInfo, - ] - await expect( walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo + { + 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( - "Each deposit key must have matching extra info" - ) + ).to.be.revertedWith("Sweep below the min size") }) }) - 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 - - 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) - }) + 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() - after(async () => { - bridge.deposits.reset() + // Pick more deposits than allowed. + const depositsKeys = new Array(maxSize + 1).fill( + createTestDeposit(walletPubKeyHash, vault).key + ) - 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") + }) + }) + 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: [depositOne.key, depositTwo.key], - sweepTxFee: 0, + depositsKeys: [ + createTestDeposit(walletPubKeyHash, vault).key, + ], + sweepTxFee: 0, // Not relevant in this scenario. depositsRevealBlocks: [], // Not relevant in this scenario. } + // The extra info array contains two items. const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, + emptyDepositExtraInfo, + emptyDepositExtraInfo, ] await expect( @@ -284,220 +234,156 @@ describe("WalletProposalValidator", () => { depositsExtraInfo ) ).to.be.revertedWith( - "Proposed transaction fee cannot be zero" + "Each deposit key must have matching extra info" ) }) }) - context( - "when proposed sweep tx fee is greater than the allowed", - () => { - let depositOne - let depositTwo - - 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 - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true - ) - depositTwo = createTestDeposit( - walletPubKeyHash, - vault, - false - ) + before(async () => { + await createSnapshot() - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex - ) + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true ) - .returns(depositOne.request) - - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex - ) + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false ) - .returns(depositTwo.request) - }) - - after(async () => { - bridge.deposits.reset() - - 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. - } - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - await expect( - walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith("Proposed transaction fee is too high") - }) - } - ) - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns(depositTwo.request) + }) - context("when proposed sweep tx fee is valid", () => { - const sweepTxFee = 5000 + after(async () => { + bridge.deposits.reset() - context("when there is a non-revealed deposit", () => { - let depositOne - let depositTwo + await restoreSnapshot() + }) - before(async () => { - await createSnapshot() + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [depositOne.key, depositTwo.key], + sweepTxFee: 0, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - depositOne = createTestDeposit(walletPubKeyHash, vault, true) - depositTwo = createTestDeposit(walletPubKeyHash, vault, false) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - // Deposit one is a proper one. - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex - ) - ) - .returns(depositOne.request) - - // Simulate the deposit two is not revealed. - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Proposed transaction fee cannot be zero" ) - ) - .returns({ - ...depositTwo.request, - revealedAt: 0, }) - }) + }) - after(async () => { - bridge.deposits.reset() + context( + "when proposed sweep tx fee is greater than the allowed", + () => { + let depositOne + let depositTwo - 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. - } + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false + ) - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex + ) + ) + .returns(depositOne.request) - await expect( - walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith("Deposit not revealed") - }) - }) + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) + ) + .returns(depositTwo.request) + }) - context("when all deposits are revealed", () => { - context("when there is an immature deposit", () => { - let depositOne - let depositTwo + after(async () => { + bridge.deposits.reset() - before(async () => { - await createSnapshot() + await restoreSnapshot() + }) - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true - ) - depositTwo = createTestDeposit( - walletPubKeyHash, - vault, - false - ) + 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. + } - // Deposit one is a proper one. - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex - ) - ) - .returns(depositOne.request) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - // 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 + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Proposed transaction fee is too high" ) - ) - .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( - walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).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", () => { + context("when proposed sweep tx fee is valid", () => { + const sweepTxFee = 5000 + + context("when there is a non-revealed deposit", () => { let depositOne let depositTwo @@ -525,7 +411,7 @@ describe("WalletProposalValidator", () => { ) .returns(depositOne.request) - // Simulate the deposit two has already been swept. + // Simulate the deposit two is not revealed. bridge.deposits .whenCalledWith( depositKey( @@ -535,7 +421,7 @@ describe("WalletProposalValidator", () => { ) .returns({ ...depositTwo.request, - sweptAt: await lastBlockTime(), + revealedAt: 0, }) }) @@ -563,661 +449,828 @@ describe("WalletProposalValidator", () => { proposal, depositsExtraInfo ) - ).to.be.revertedWith("Deposit already swept") + ).to.be.revertedWith("Deposit not revealed") }) }) - 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 + context("when all deposits are revealed", () => { + context("when there is an immature deposit", () => { + let depositOne + let depositTwo - before(async () => { - await createSnapshot() + before(async () => { + await createSnapshot() - deposit = createTestDeposit( - walletPubKeyHash, - vault, - true - ) + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false + ) - bridge.deposits - .whenCalledWith( - depositKey( - deposit.key.fundingTxHash, - deposit.key.fundingOutputIndex - ) - ) - .returns(deposit.request) + // 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() + after(async () => { + bridge.deposits.reset() - await restoreSnapshot() - }) + await restoreSnapshot() + }) - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [deposit.key], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } - - // 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", - }, - }, - ] + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [depositOne.key, depositTwo.key], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. + } - await expect( - walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Extra info funding tx hash does not match" - ) - }) - }) - - context( - "when 20-byte funding output hash does not match", - () => { - let deposit - - before(async () => { - await createSnapshot() - - deposit = createTestDeposit( - walletPubKeyHash, - vault, - false // Produce a non-witness deposit with 20-byte script - ) - - bridge.deposits - .whenCalledWith( - depositKey( - deposit.key.fundingTxHash, - deposit.key.fundingOutputIndex - ) - ) - .returns(deposit.request) - }) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - after(async () => { - bridge.deposits.reset() + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith("Deposit min age not achieved yet") + }) + }) - 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 proposal = { - walletPubKeyHash, - depositsKeys: [deposit.key], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } + 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")}`, - }, - ] + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false + ) - await expect( - walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Extra info funding output script does not match" + // Deposit one is a proper one. + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex ) - }) - } - ) - - 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(depositOne.request) + + // Simulate the deposit two has already been swept. + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex ) - - bridge.deposits - .whenCalledWith( - depositKey( - deposit.key.fundingTxHash, - deposit.key.fundingOutputIndex - ) - ) - .returns(deposit.request) - }) - - after(async () => { - bridge.deposits.reset() - - await restoreSnapshot() + ) + .returns({ + ...depositTwo.request, + sweptAt: await lastBlockTime(), }) + }) - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [deposit.key], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } + after(async () => { + bridge.deposits.reset() - // 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")}`, - }, - ] + await restoreSnapshot() + }) - await expect( - walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Extra info funding output script does not match" - ) - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + depositsKeys: [depositOne.key, depositTwo.key], + sweepTxFee, + depositsRevealBlocks: [], // Not relevant in this scenario. } - ) - } - ) - 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() - - // Deposit one is a proper one. - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true - ) + const depositsExtraInfo = [ + depositOne.extraInfo, + depositTwo.extraInfo, + ] - // 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 - - depositTwo = createTestDeposit( - walletPubKeyHash, - vault, - false, - depositRevealedAt + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo ) + ).to.be.revertedWith("Deposit already swept") + }) + }) - 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 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 + + before(async () => { + await createSnapshot() + + deposit = createTestDeposit( + walletPubKeyHash, + vault, + true + ) - 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( - walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Deposit refund safety margin is not preserved" + bridge.deposits + .whenCalledWith( + depositKey( + deposit.key.fundingTxHash, + deposit.key.fundingOutputIndex + ) + ) + .returns(deposit.request) + }) + + 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 info by setting a different + // version than 0x01000000 used to produce the hash. + const depositsExtraInfo = [ + { + ...deposit.extraInfo, + fundingTx: { + ...deposit.extraInfo.fundingTx, + version: "0x02000000", + }, + }, + ] + + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Extra info funding tx hash does not match" + ) + }) + } ) - }) - } - ) - - context( - "when all deposits preserve the refund safety margin", - () => { - context( - "when there is a deposit controlled by a different wallet", - () => { - let depositOne - let depositTwo - before(async () => { - await createSnapshot() + context( + "when 20-byte funding output hash does not match", + () => { + let deposit - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true - ) + before(async () => { + await createSnapshot() - // Deposit two uses a different wallet than deposit - // one. - depositTwo = createTestDeposit( - `0x${Buffer.from( - walletPubKeyHash.substring(2), - "hex" + deposit = createTestDeposit( + walletPubKeyHash, + vault, + false // Produce a non-witness deposit with 20-byte script ) - .reverse() - .toString("hex")}`, - vault, - false - ) - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex + bridge.deposits + .whenCalledWith( + depositKey( + deposit.key.fundingTxHash, + deposit.key.fundingOutputIndex + ) ) - ) - .returns(depositOne.request) + .returns(deposit.request) + }) + + 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 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")}`, + }, + ] - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo ) + ).to.be.revertedWith( + "Extra info funding output script does not match" ) - .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. - } + context( + "when 32-byte funding output hash does not match", + () => { + let deposit - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] + before(async () => { + await createSnapshot() - await expect( - walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo + deposit = createTestDeposit( + walletPubKeyHash, + vault, + true // Produce a witness deposit with 32-byte script ) - ).to.be.revertedWith( - "Deposit controlled by different wallet" - ) - }) - } - ) - - 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 + bridge.deposits + .whenCalledWith( + depositKey( + deposit.key.fundingTxHash, + deposit.key.fundingOutputIndex + ) ) - - // Deposit two uses a different vault than deposit - // one. - depositTwo = createTestDeposit( - walletPubKeyHash, - `0x${Buffer.from( - vault.substring(2), + .returns(deposit.request) + }) + + 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 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")}`, - 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() - }) + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Extra info funding output script does not match" + ) + }) + } + ) + } + ) - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - depositsKeys: [ - depositOne.key, - depositTwo.key, - ], - sweepTxFee, - depositsRevealBlocks: [], // Not relevant in this scenario. - } + 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() + + // Deposit one is a proper one. + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) - const depositsExtraInfo = [ - depositOne.extraInfo, - depositTwo.extraInfo, - ] + // 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 + + depositTwo = createTestDeposit( + walletPubKeyHash, + vault, + false, + depositRevealedAt + ) - await expect( - walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key.fundingOutputIndex ) - ).to.be.revertedWith( - "Deposit targets different vault" ) - }) - } - ) + .returns(depositOne.request) - 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 - ) + 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( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Deposit refund safety margin is not preserved" + ) + }) + } + ) - depositTwo = createTestDeposit( - walletPubKeyHash, - vault, - false - ) + context( + "when all deposits preserve the refund safety margin", + () => { + context( + "when there is a deposit controlled by a different wallet", + () => { + let depositOne + let depositTwo + + before(async () => { + await createSnapshot() + + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) - depositThree = createTestDeposit( - walletPubKeyHash, - vault, - false + // 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) - - bridge.deposits - .whenCalledWith( - depositKey( - depositThree.key.fundingTxHash, - depositThree.key - .fundingOutputIndex - ) + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.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 - ] - - await expect( - walletProposalValidator.validateDepositSweepProposal( - proposal, - depositsExtraInfo - ) - ).to.be.revertedWith( - "Duplicated deposit" ) - }) - } - ) - - context( - "when all deposits are unique", - () => { - let depositOne - let depositTwo - let depositThree + .returns(depositOne.request) - before(async () => { - await createSnapshot() - - depositOne = createTestDeposit( - walletPubKeyHash, - vault, - true + bridge.deposits + .whenCalledWith( + depositKey( + depositTwo.key.fundingTxHash, + depositTwo.key.fundingOutputIndex + ) ) - - depositTwo = createTestDeposit( - walletPubKeyHash, - vault, - false + .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( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo ) + ).to.be.revertedWith( + "Deposit controlled by different wallet" + ) + }) + } + ) - // Use a deposit with embedded 32-byte extra data - // to make sure validation handles them correctly. - depositThree = createTestDeposit( - walletPubKeyHash, - vault, - true, - undefined, - "0xa9b38ea6435c8941d6eda6a46b68e3e2117196995bd154ab55196396b03d9bda" - ) + 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 + ) - bridge.deposits - .whenCalledWith( - depositKey( - depositOne.key.fundingTxHash, - depositOne.key.fundingOutputIndex + // Deposit two uses a different vault than deposit + // one. + depositTwo = createTestDeposit( + walletPubKeyHash, + `0x${Buffer.from( + vault.substring(2), + "hex" ) + .reverse() + .toString("hex")}`, + false ) - .returns(depositOne.request) - bridge.deposits - .whenCalledWith( - depositKey( - depositTwo.key.fundingTxHash, - depositTwo.key.fundingOutputIndex + bridge.deposits + .whenCalledWith( + depositKey( + depositOne.key.fundingTxHash, + depositOne.key + .fundingOutputIndex + ) ) - ) - .returns(depositTwo.request) - - bridge.deposits - .whenCalledWith( - depositKey( - depositThree.key.fundingTxHash, - depositThree.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( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Deposit targets different vault" ) - .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 - }) - } - ) - } - ) - } - ) - } - ) + 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 + ] + + await expect( + walletProposalValidator.validateDepositSweepProposal( + proposal, + depositsExtraInfo + ) + ).to.be.revertedWith( + "Duplicated deposit" + ) + }) + } + ) + + context( + "when all deposits are unique", + () => { + let depositOne + let depositTwo + let depositThree + + before(async () => { + await createSnapshot() + + depositOne = createTestDeposit( + walletPubKeyHash, + vault, + true + ) + + 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 + }) + } + ) + } + ) + } + ) + } + ) + } + ) + }) }) }) }) @@ -1257,16 +1310,12 @@ describe("WalletProposalValidator", () => { await restoreSnapshot() }) - context("when wallet is not Live", () => { + context("when wallet is in incorrect state", () => { 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, @@ -1313,682 +1362,544 @@ describe("WalletProposalValidator", () => { redeemersOutputScripts: [], redemptionTxFee: 0, }) - ).to.be.revertedWith("Wallet is not in Live state") + ).to.be.revertedWith("Wallet is not in Live or MovingFunds 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() + context("when wallet is in correct state", () => { + const testData = [ + { + testName: "when wallet state is Live", + walletState: walletState.Live, + }, + { + testName: "when wallet state is MovingFunds", + walletState: walletState.MovingFunds, + }, + ] - await restoreSnapshot() - }) + testData.forEach((test) => { + context(test.testName, () => { + before(async () => { + await createSnapshot() - context("when redemption is below the min size", () => { - it("should revert", async () => { - await expect( - walletProposalValidator.validateRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts: [], // Set size to 0. - redemptionTxFee: 0, // Not relevant in this scenario. + bridge.wallets.whenCalledWith(walletPubKeyHash).returns({ + ecdsaWalletID, + mainUtxoHash: HashZero, + pendingRedemptionsValue: 0, + createdAt: 0, + movingFundsRequestedAt: 0, + closingStartedAt: 0, + pendingMovedFundsSweepRequestsCount: 0, + state: test.walletState, + movingFundsTargetWalletsCommitmentHash: HashZero, }) - ).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 walletProposalValidator.REDEMPTION_MAX_SIZE() + after(async () => { + bridge.wallets.reset() - // Pick more redemption requests than allowed. - const redeemersOutputScripts = new Array(maxSize + 1).fill( - createTestRedemptionRequest(walletPubKeyHash).key - .redeemerOutputScript - ) + await restoreSnapshot() + }) - await expect( - walletProposalValidator.validateRedemptionProposal({ - walletPubKeyHash, - redeemersOutputScripts, - redemptionTxFee: 0, // Not relevant in this scenario. - }) - ).to.be.revertedWith("Redemption exceeds the max size") + context("when redemption is below the min size", () => { + it("should revert", async () => { + await expect( + 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 redemption does not exceed the max size", () => { - context("when proposed redemption tx fee is invalid", () => { - context("when proposed redemption tx fee is zero", () => { + context("when redemption is above the min size", () => { + context("when redemption exceeds the max size", () => { it("should revert", async () => { + const maxSize = + await walletProposalValidator.REDEMPTION_MAX_SIZE() + + // Pick more redemption requests than allowed. + const redeemersOutputScripts = new Array(maxSize + 1).fill( + createTestRedemptionRequest(walletPubKeyHash).key + .redeemerOutputScript + ) + await expect( walletProposalValidator.validateRedemptionProposal({ walletPubKeyHash, - redeemersOutputScripts: [ - createTestRedemptionRequest(walletPubKeyHash).key - .redeemerOutputScript, - ], - redemptionTxFee: 0, + redeemersOutputScripts, + redemptionTxFee: 0, // Not relevant in this scenario. }) - ).to.be.revertedWith("Proposed transaction fee cannot be zero") + ).to.be.revertedWith("Redemption exceeds the max size") }) }) - context( - "when proposed redemption tx fee is greater than the allowed total fee", - () => { - it("should revert", async () => { - await expect( - 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") - }) - } - ) - - // 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) - - // Request one is a proper one. - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) - ) - .returns(requestOne.content) - - // Simulate the request two is non-pending. - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript + 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" ) - ) - .returns({ - ...requestTwo.content, - requestedAt: 0, }) - }) - - after(async () => { - bridge.pendingRedemptions.reset() - - await restoreSnapshot() - }) - - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee, - } - - await expect( - walletProposalValidator.validateRedemptionProposal(proposal) - ).to.be.revertedWith("Not a pending redemption request") - }) - }) + }) - context("when all requests are pending", () => { - context("when there is an immature request", () => { context( - "when immaturity is caused by REDEMPTION_REQUEST_MIN_AGE violation", + "when proposed redemption tx fee is greater than the allowed total fee", () => { - let requestOne - let requestTwo - - before(async () => { - await createSnapshot() - - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 5000 // necessary to pass the fee share validation - ) - requestTwo = createTestRedemptionRequest(walletPubKeyHash) - - // Request one is a proper one. - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) - ) - .returns(requestOne.content) - - // 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.pendingRedemptions.reset() - - await restoreSnapshot() - }) - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee, - } - await expect( - walletProposalValidator.validateRedemptionProposal( - proposal - ) + walletProposalValidator.validateRedemptionProposal({ + walletPubKeyHash, + redeemersOutputScripts: [ + createTestRedemptionRequest(walletPubKeyHash).key + .redeemerOutputScript, + ], + // Exceed the max per-request fee by one. + redemptionTxFee: bridgeRedemptionTxMaxTotalFee + 1, + }) ).to.be.revertedWith( - "Redemption request min age not achieved yet" + "Proposed transaction fee is too high" ) }) } ) - context( - "when immaturity is caused by watchtower's delay violation", - () => { - let watchtower: FakeContract - let requestOne - let requestTwo - - before(async () => { - await createSnapshot() + // 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" + }) - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 5000, // necessary to pass the fee share validation - await lastBlockTime() - ) - requestTwo = createTestRedemptionRequest( - walletPubKeyHash, - 5000, // necessary to pass the fee share validation - await lastBlockTime() - ) + context("when proposed redemption tx fee is valid", () => { + const redemptionTxFee = 9000 - // Request one is a proper one. - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) - ) - .returns(requestOne.content) + context("when there is a non-pending request", () => { + let requestOne + let requestTwo - // 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) + before(async () => { + await createSnapshot() - watchtower = await smock.fake( - "IRedemptionWatchtower" - ) - bridge.getRedemptionWatchtower.returns(watchtower.address) + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 5000 // necessary to pass the fee share validation + ) + requestTwo = createTestRedemptionRequest(walletPubKeyHash) - const redemptionOneDelay = 3600 - watchtower.getRedemptionDelay - .whenCalledWith( - buildRedemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) + // Request one is a proper one. + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript ) - .returns(redemptionOneDelay) + ) + .returns(requestOne.content) - const redemptionTwoDelay = 7200 - watchtower.getRedemptionDelay - .whenCalledWith( - buildRedemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript - ) + // Simulate the request two is non-pending. + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript ) - .returns(redemptionTwoDelay) - - // Increase time to a point when delay for redemption - // one was elapsed but not for redemption two. - await increaseTime(redemptionTwoDelay) - }) + ) + .returns({ + ...requestTwo.content, + requestedAt: 0, + }) + }) - after(async () => { - bridge.getRedemptionWatchtower.reset() - watchtower.getRedemptionDelay.reset() - bridge.pendingRedemptions.reset() + after(async () => { + bridge.pendingRedemptions.reset() - await restoreSnapshot() - }) + await restoreSnapshot() + }) - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee, - } + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, + } - await expect( - walletProposalValidator.validateRedemptionProposal( - proposal - ) - ).to.be.revertedWith( - "Redemption request min age not achieved yet" + await expect( + walletProposalValidator.validateRedemptionProposal( + proposal ) - }) - } - ) - }) - - 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() + ).to.be.revertedWith("Not a pending redemption request") + }) + }) - // Request one is a proper one. - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 5000 // necessary to pass the fee share validation - ) + context("when all requests are pending", () => { + context("when there is an immature request", () => { + context( + "when immaturity is caused by REDEMPTION_REQUEST_MIN_AGE violation", + () => { + let requestOne + let requestTwo - // 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 - - requestTwo = createTestRedemptionRequest( - walletPubKeyHash, - 0, - requestCreatedAt - ) + before(async () => { + await createSnapshot() - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 5000 // necessary to pass the fee share validation ) - ) - .returns(requestOne.content) + requestTwo = + createTestRedemptionRequest(walletPubKeyHash) + + // Request one is a proper one. + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript + ) + ) + .returns(requestOne.content) + + // 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(), + }) + }) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript - ) - ) - .returns(requestTwo.content) - }) + after(async () => { + bridge.pendingRedemptions.reset() - after(async () => { - bridge.pendingRedemptions.reset() + await restoreSnapshot() + }) - await restoreSnapshot() - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, + } - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee, + await expect( + walletProposalValidator.validateRedemptionProposal( + proposal + ) + ).to.be.revertedWith( + "Redemption request min age not achieved yet" + ) + }) } + ) - await expect( - walletProposalValidator.validateRedemptionProposal( - proposal - ) - ).to.be.revertedWith( - "Redemption request timeout safety margin is not preserved" - ) - }) - } - ) - - context( - "when all requests preserve the timeout safety margin", - () => { context( - "when there is a request that incurs an unacceptable tx fee share", + "when immaturity is caused by watchtower's delay violation", () => { - context("when there is no fee remainder", () => { - let requestOne - let requestTwo + let watchtower: FakeContract + let requestOne + let requestTwo - before(async () => { - await createSnapshot() + before(async () => { + await createSnapshot() - // Request one is a proper one. - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 4500 // necessary to pass the fee share validation - ) + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 5000, // necessary to pass the fee share validation + await lastBlockTime() + ) + requestTwo = createTestRedemptionRequest( + walletPubKeyHash, + 5000, // necessary to pass the fee share validation + await lastBlockTime() + ) - // 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 + // Request one is a proper one. + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript + ) ) - - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) + .returns(requestOne.content) + + // 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(requestOne.content) + ) + .returns(requestTwo.content) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript - ) + watchtower = await smock.fake( + "IRedemptionWatchtower" + ) + bridge.getRedemptionWatchtower.returns( + watchtower.address + ) + + const redemptionOneDelay = 3600 + watchtower.getRedemptionDelay + .whenCalledWith( + buildRedemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript ) - .returns(requestTwo.content) - }) + ) + .returns(redemptionOneDelay) + + const redemptionTwoDelay = 7200 + watchtower.getRedemptionDelay + .whenCalledWith( + buildRedemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript + ) + ) + .returns(redemptionTwoDelay) - after(async () => { - bridge.pendingRedemptions.reset() + // Increase time to a point when delay for redemption + // one was elapsed but not for redemption two. + await increaseTime(redemptionTwoDelay) + }) - await restoreSnapshot() - }) + after(async () => { + bridge.getRedemptionWatchtower.reset() + watchtower.getRedemptionDelay.reset() + bridge.pendingRedemptions.reset() - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee, - } - - await expect( - walletProposalValidator.validateRedemptionProposal( - proposal - ) - ).to.be.revertedWith( - "Proposed transaction per-request fee share is too high" + await restoreSnapshot() + }) + + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, + } + + await expect( + walletProposalValidator.validateRedemptionProposal( + proposal ) - }) + ).to.be.revertedWith( + "Redemption request min age not achieved yet" + ) }) + } + ) + }) - context("when there is a fee remainder", () => { - let requestOne - let requestTwo + 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() - // Request one is a proper one. - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 4500 // necessary to pass the fee share validation - ) + // Request one is a proper one. + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 5000 // necessary to pass the fee share validation + ) - // 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 - ) + // 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 + + requestTwo = createTestRedemptionRequest( + walletPubKeyHash, + 0, + requestCreatedAt + ) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript - ) + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript ) - .returns(requestOne.content) + ) + .returns(requestOne.content) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript - ) + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript ) - .returns(requestTwo.content) - }) + ) + .returns(requestTwo.content) + }) - after(async () => { - bridge.pendingRedemptions.reset() + after(async () => { + bridge.pendingRedemptions.reset() - await restoreSnapshot() - }) + await restoreSnapshot() + }) - it("should revert", async () => { - const proposal = { - walletPubKeyHash, - redeemersOutputScripts: [ - requestOne.key.redeemerOutputScript, - requestTwo.key.redeemerOutputScript, - ], - redemptionTxFee: 9001, - } - - await expect( - walletProposalValidator.validateRedemptionProposal( - proposal - ) - ).to.be.revertedWith( - "Proposed transaction per-request fee share is too high" + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, + } + + await expect( + walletProposalValidator.validateRedemptionProposal( + proposal ) - }) + ).to.be.revertedWith( + "Redemption request timeout safety margin is not preserved" + ) }) } ) context( - "when all requests incur an acceptable tx fee share", + "when all requests preserve the timeout safety margin", () => { - context("when there are duplicated requests", () => { - let requestOne - let requestTwo - let requestThree - - before(async () => { - await createSnapshot() - - requestOne = createTestRedemptionRequest( - walletPubKeyHash, - 2500 // necessary to pass the fee share validation - ) - - requestTwo = createTestRedemptionRequest( - walletPubKeyHash, - 2500 // necessary to pass the fee share validation - ) + context( + "when there is a request that incurs an unacceptable tx fee share", + () => { + context("when there is no fee remainder", () => { + let requestOne + let requestTwo - requestThree = createTestRedemptionRequest( - walletPubKeyHash, - 2500 // necessary to pass the fee share validation - ) + before(async () => { + await createSnapshot() - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestOne.key.walletPubKeyHash, - requestOne.key.redeemerOutputScript + // Request one is a proper one. + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 4500 // necessary to pass the fee share validation ) - ) - .returns(requestOne.content) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestTwo.key.walletPubKeyHash, - requestTwo.key.redeemerOutputScript + // 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 ) - ) - .returns(requestTwo.content) - bridge.pendingRedemptions - .whenCalledWith( - redemptionKey( - requestThree.key.walletPubKeyHash, - requestThree.key.redeemerOutputScript - ) - ) - .returns(requestThree.content) - }) + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript + ) + ) + .returns(requestOne.content) + + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript + ) + ) + .returns(requestTwo.content) + }) - after(async () => { - bridge.pendingRedemptions.reset() + after(async () => { + bridge.pendingRedemptions.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, - } - - await expect( - walletProposalValidator.validateRedemptionProposal( - proposal - ) - ).to.be.revertedWith("Duplicated request") - }) - }) + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, + } - context("when all requests are unique", () => { - const testData: { - testName: string - watchtower: boolean - }[] = [ - { - testName: "when watchtower is not set", - watchtower: false, - }, - { - testName: "when watchtower is set", - watchtower: true, - }, - ] + await expect( + walletProposalValidator.validateRedemptionProposal( + proposal + ) + ).to.be.revertedWith( + "Proposed transaction per-request fee share is too high" + ) + }) + }) - testData.forEach((test) => { - context(test.testName, () => { - let watchtower: FakeContract + context("when there is a fee remainder", () => { let requestOne let requestTwo before(async () => { await createSnapshot() + // Request one is a proper one. requestOne = createTestRedemptionRequest( walletPubKeyHash, - 5000 // necessary to pass the fee share validation + 4500 // necessary to pass the fee share validation ) + // 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, - 5000 // necessary to pass the fee share validation + 4500 ) bridge.pendingRedemptions @@ -2008,67 +1919,233 @@ describe("WalletProposalValidator", () => { ) ) .returns(requestTwo.content) - - if (test.watchtower) { - watchtower = - await smock.fake( - "IRedemptionWatchtower" - ) - - bridge.getRedemptionWatchtower.returns( - watchtower.address - ) - - // All requests created by createTestRedemptionRequest - // are requested at `now - 1 day` by default. - // To test the watchtower delay path, we need - // to use a delay that is greater than - // `REDEMPTION_REQUEST_MIN_AGE` (10 min) and - // ensure that the delay is preserved - // at the moment of the proposal validation. - // A value of 2 hours will be a good fit. - watchtower.getRedemptionDelay.returns( - 7200 // 2 hours - ) - } }) after(async () => { - if (test.watchtower) { - bridge.getRedemptionWatchtower.reset() - watchtower.getRedemptionDelay.reset() - } - bridge.pendingRedemptions.reset() await restoreSnapshot() }) - it("should succeed", async () => { + it("should revert", async () => { const proposal = { walletPubKeyHash, redeemersOutputScripts: [ requestOne.key.redeemerOutputScript, requestTwo.key.redeemerOutputScript, ], - redemptionTxFee, + redemptionTxFee: 9001, } - const result = - await walletProposalValidator.validateRedemptionProposal( + await expect( + walletProposalValidator.validateRedemptionProposal( proposal ) + ).to.be.revertedWith( + "Proposed transaction per-request fee share is too high" + ) + }) + }) + } + ) + + 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() + + requestOne = createTestRedemptionRequest( + walletPubKeyHash, + 2500 // necessary to pass the fee share validation + ) + + requestTwo = createTestRedemptionRequest( + walletPubKeyHash, + 2500 // necessary to pass the fee share validation + ) + + requestThree = createTestRedemptionRequest( + walletPubKeyHash, + 2500 // necessary to pass the fee share validation + ) + + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestOne.key.walletPubKeyHash, + requestOne.key.redeemerOutputScript + ) + ) + .returns(requestOne.content) + + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript + ) + ) + .returns(requestTwo.content) + + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestThree.key.walletPubKeyHash, + requestThree.key.redeemerOutputScript + ) + ) + .returns(requestThree.content) + }) + + after(async () => { + bridge.pendingRedemptions.reset() + + await restoreSnapshot() + }) + + it("should revert", async () => { + const proposal = { + walletPubKeyHash, + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + requestThree.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, // duplicate + ], + redemptionTxFee, + } + + await expect( + walletProposalValidator.validateRedemptionProposal( + proposal + ) + ).to.be.revertedWith("Duplicated request") + }) + } + ) + + context("when all requests are unique", () => { + const testData: { + testName: string + watchtower: boolean + }[] = [ + { + testName: "when watchtower is not set", + watchtower: false, + }, + { + testName: "when watchtower is set", + watchtower: true, + }, + ] + + testData.forEach((test) => { + context(test.testName, () => { + let watchtower: FakeContract + let requestOne + let requestTwo - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - expect(result).to.be.true + before(async () => { + await createSnapshot() + + 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) + + bridge.pendingRedemptions + .whenCalledWith( + redemptionKey( + requestTwo.key.walletPubKeyHash, + requestTwo.key.redeemerOutputScript + ) + ) + .returns(requestTwo.content) + + if (test.watchtower) { + watchtower = + await smock.fake( + "IRedemptionWatchtower" + ) + + bridge.getRedemptionWatchtower.returns( + watchtower.address + ) + + // All requests created by createTestRedemptionRequest + // are requested at `now - 1 day` by default. + // To test the watchtower delay path, we need + // to use a delay that is greater than + // `REDEMPTION_REQUEST_MIN_AGE` (10 min) and + // ensure that the delay is preserved + // at the moment of the proposal validation. + // A value of 2 hours will be a good fit. + watchtower.getRedemptionDelay.returns( + 7200 // 2 hours + ) + } + }) + + after(async () => { + if (test.watchtower) { + bridge.getRedemptionWatchtower.reset() + watchtower.getRedemptionDelay.reset() + } + + bridge.pendingRedemptions.reset() + + await restoreSnapshot() + }) + + it("should succeed", async () => { + const proposal = { + walletPubKeyHash, + redeemersOutputScripts: [ + requestOne.key.redeemerOutputScript, + requestTwo.key.redeemerOutputScript, + ], + redemptionTxFee, + } + + const result = + await walletProposalValidator.validateRedemptionProposal( + proposal + ) + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(result).to.be.true + }) + }) }) }) - }) - }) + } + ) } ) - } - ) + }) + }) }) }) })