diff --git a/foundry.toml b/foundry.toml index 961a663..fa9f7ad 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,6 +2,7 @@ src = "src" out = "out" libs = ["lib"] +solc="0.8.30" optimizer = true optimizer_runs = 200 via_ir = true diff --git a/script/MuPay.s.sol b/script/MuPay.s.sol index 7f2159e..a3142e2 100644 --- a/script/MuPay.s.sol +++ b/script/MuPay.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; +pragma solidity ^0.8.28; import {Script} from "forge-std/Script.sol"; import {MuPay} from "../src/MuPay.sol"; diff --git a/src/MuPay.sol b/src/MuPay.sol index 4d1d559..f722589 100644 --- a/src/MuPay.sol +++ b/src/MuPay.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; +pragma solidity ^0.8.28; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; diff --git a/src/Multisig_2of2.sol b/src/Multisig_2of2.sol new file mode 100644 index 0000000..bc740b2 --- /dev/null +++ b/src/Multisig_2of2.sol @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract Multisig is ReentrancyGuard { + using ECDSA for bytes32; // for recover() + using MessageHashUtils for bytes32; // for toEthSignedMessageHash() + using SafeERC20 for IERC20; + + /** + * @dev Represents a payment channel between a payer and a merchant. + */ + struct Channel { + address token; // Token address, address(0) for native currency + uint256 amount; // Total deposit in the payment channel + uint64 expiration; // Block timestamp after which the channel expires and payer can reclaim amount + uint64 reclaimAfter; // After this time (or block), payer can reclaim funds + uint64 sessionId; // Unique identifier for the payment session + uint256 lastNonce; // Last used nonce to prevent replay attacks and ensure order + } + + // payer => payee => token => Channel + mapping(address => mapping(address => mapping(address => Channel))) public channels; + + /** + * @dev Custom errors to reduce contract size and improve clarity. + */ + error IncorrectAmount(uint256 sentAmount, uint256 expectedAmount); + error ChannelDoesNotExistOrWithdrawn(); + error ChannelExpired(uint64 expiration); + error PayerCannotRedeemChannelYet(uint256 blockNumber, uint256 reclaimAfter); + error ChannelAlreadyExist(address payer, address payee, address token, uint256 amount); + error NothingPayable(); + error FailedToSendEther(); + error ZeroTokensNotAllowed(); + error AddressIsNotContract(address token); + error AddressIsNotERC20(address token); + error InsufficientAllowance(uint256 required, uint256 actual); + error StaleNonce(uint256 supplied, uint256 current); + error InvalidChannelSignature(address recovered, address expected); + error ReclaimAfterMustBeAfterExpiration(uint64 expiration, uint64 reclaimAfter); + + /** + * @dev Events to log key contract actions. + */ + event ChannelCreated( + address indexed payer, + address indexed payee, + address indexed token, + uint256 amount, + uint64 expiration, + uint256 sessionId, + uint64 reclaimAfter + ); + event ChannelRedeemed( + address indexed payer, + address indexed payee, + address indexed token, + uint256 amount, + uint256 nonce, + uint256 sessionId + ); + event ChannelRefunded(address indexed payer, address indexed payee, address indexed token, uint256 refundAmount); + event ChannelReclaimed( + address indexed payer, address indexed payee, address indexed token, uint256 reclaimedAmount + ); + + /** + * @dev Creates a new payment channel between a payer and a payee. + * @param payee The address receiving payments. + * @param token The ERC-20 token address used for payments, or address(0) to use the native currency. + * @param amount The total deposit amount for the channel. + * @param duration The channel lifetime in blocks (from current block). + */ + function createChannel(address payee, address token, uint256 amount, uint64 duration, uint64 reclaimDelay) + external + payable + nonReentrant + { + // Validate payee address + require(payee != address(0), "Invalid address"); + require( + duration < reclaimDelay, + ReclaimAfterMustBeAfterExpiration( + uint64(block.timestamp) + duration, uint64(block.timestamp) + reclaimDelay + ) + ); + + // Dispatch to the correct internal handler based on token type + if (token == address(0)) { + _createNativeChannel(payee, amount, duration, reclaimDelay); + } else { + _createERC20Channel(payee, token, amount, duration, reclaimDelay); + } + } + + /** + * @dev Handles channel creation when using native currency (ETH). + * @param payee The address receiving ETH payments. + * @param amount The exact ETH amount to lock in the channel. + * @param duration Lifetime of the channel in blocks. + */ + function _createNativeChannel(address payee, uint256 amount, uint64 duration, uint64 reclaimDelay) internal { + // Ensure the ETH sent matches the declared deposit + if (msg.value != amount) revert IncorrectAmount(msg.value, amount); + + // Initialize and record the channel + _initChannel(msg.sender, payee, address(0), amount, duration, reclaimDelay); + } + + /** + * @dev Handles channel creation when using an ERC-20 token. + * @param payee The address receiving token payments. + * @param token The ERC-20 token contract address. + * @param amount The token amount to lock in the channel. + * @param duration Lifetime of the channel in blocks. + */ + function _createERC20Channel(address payee, address token, uint256 amount, uint64 duration, uint64 reclaimDelay) + internal + { + // Ensure no ETH was sent for token-based payments + if (msg.value != 0) revert IncorrectAmount(msg.value, 0); + + // Validate that the token address is a deployed contract + if (token.code.length == 0) revert AddressIsNotContract(token); + + // Try calling a common ERC20 function to verify interface compliance + // Using totalSupply() as a lightweight sanity check for ERC20 compatibility + try IERC20(token).totalSupply() returns (uint256) { + // Call succeeded — it's likely an ERC20 token. + } catch { + revert AddressIsNotERC20(token); + } + + // Check that the contract has been approved to spend the specified token amount + uint256 allowance = IERC20(token).allowance(msg.sender, address(this)); + if (allowance < amount) revert InsufficientAllowance(amount, allowance); + + // Pull tokens from payer into this contract + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + + // Initialize and record the channel + _initChannel(msg.sender, payee, token, amount, duration, reclaimDelay); + } + + /** + * @dev Initializes the channel record and prevents duplicates. + * @param payer The address opening the channel. + * @param payee The address receiving payments. + * @param token The token used (address(0) for ETH). + * @param amount The locked deposit amount. + * @param duration Number of blocks until expiration. + */ + function _initChannel( + address payer, + address payee, + address token, + uint256 amount, + uint64 duration, + uint64 reclaimDelay + ) private { + // Channel initialization + Channel storage channel = channels[payer][payee][token]; + + // Prevent channel overwrite + if (channel.amount != 0) { + revert ChannelAlreadyExist(payer, payee, token, channel.amount); + } + + channel.token = token; + channel.amount = amount; + channel.expiration = uint64(block.timestamp) + duration; + channel.reclaimAfter = uint64(block.timestamp) + reclaimDelay; + channel.sessionId += 1; + channel.lastNonce += 1; + + emit ChannelCreated(payer, payee, token, amount, channel.expiration, channel.sessionId, channel.reclaimAfter); + } + + /** + * @dev Redeems a payment channel by verifying a final signature. + * @param payer The address of the payer. + * @param token The ERC-20 token address used for payments, or address(0) to use the native currency. + * @param amount The amount the payee is claiming from the channel. + * @param nonce A strictly increasing number to prevent replay of old vouchers. + * @param signature The payer’s EIP-191 signature over the channel settlement parameters. + */ + function redeemChannel(address payer, address token, uint256 amount, uint256 nonce, bytes calldata signature) + external + nonReentrant + { + // Validate, mark consumed and compute refund + (uint256 refund, uint64 sessionId) = + _validateAndConsumeChannel(payer, msg.sender, token, amount, nonce, signature); + + // Dispatch the two transfers via _transfer helper function + _transfer(msg.sender, token, amount); + _transfer(payer, token, refund); + + // Emit both events + emit ChannelRedeemed(payer, msg.sender, token, amount, nonce, sessionId); + emit ChannelRefunded(payer, msg.sender, token, refund); + } + + function _validateAndConsumeChannel( + address payer, + address payee, + address token, + uint256 amount, + uint256 nonce, + bytes calldata signature + ) internal returns (uint256 refund, uint64 sessionId) { + Channel storage channel = channels[payer][payee][token]; + if (channel.amount == 0) revert ChannelDoesNotExistOrWithdrawn(); + if (block.timestamp > channel.expiration) revert ChannelExpired(channel.expiration); + if (amount > channel.amount) revert IncorrectAmount(amount, channel.amount); + if (nonce <= channel.lastNonce) revert StaleNonce(nonce, channel.lastNonce); + + // recreate EIP-191 hash + bytes32 hash = keccak256( + abi.encodePacked(address(this), payer, payee, channel.token, amount, nonce, channel.sessionId) + ).toEthSignedMessageHash(); + + // signature check + address signer = hash.recover(signature); + if (signer != payer) revert InvalidChannelSignature(signer, payer); + + // compute refund before zeroing out amount + refund = channel.amount - amount; + sessionId = channel.sessionId; + + // mark nonce used and clear channel + channel.lastNonce = nonce; + channel.amount = 0; + } + + function _transfer(address recipient, address token, uint256 amount) internal { + if (token == address(0)) { + (bool ok,) = payable(recipient).call{value: amount}(""); + if (!ok) revert FailedToSendEther(); + } else { + IERC20(token).safeTransfer(recipient, amount); + } + } + + /** + * @dev Allows the payer to reclaim their deposit after the reclaim delay expires. + * @param payee The address of the merchant or recipient. + * @param token The ERC-20 token address used for payments, or address(0) for native currency. + */ + function reclaimChannel(address payee, address token) external nonReentrant { + require(payee != address(0), "Invalid payee address"); + + Channel storage channel = channels[msg.sender][payee][token]; + + if (channel.amount == 0) { + revert ChannelDoesNotExistOrWithdrawn(); + } + + if (block.timestamp < channel.reclaimAfter) { + revert PayerCannotRedeemChannelYet(block.timestamp, channel.reclaimAfter); + } + + uint256 amountToReclaim = channel.amount; + + // Clean up storage + delete channels[msg.sender][payee][token]; + + // Send funds + _transfer(msg.sender, token, amountToReclaim); + + emit ChannelReclaimed(msg.sender, payee, token, amountToReclaim); + } +} diff --git a/test/CreateChannel.t.sol b/test/CreateChannel.t.sol index bc2a1af..60fde1b 100644 --- a/test/CreateChannel.t.sol +++ b/test/CreateChannel.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; +pragma solidity ^0.8.28; import {Test, console} from "forge-std/Test.sol"; import {MuPay} from "../src/MuPay.sol"; diff --git a/test/CreateChannelERC20.t.sol b/test/CreateChannelERC20.t.sol index daf885e..6126c4a 100644 --- a/test/CreateChannelERC20.t.sol +++ b/test/CreateChannelERC20.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; +pragma solidity ^0.8.28; import {Test, console} from "forge-std/Test.sol"; import {MuPay} from "../src/MuPay.sol"; diff --git a/test/ReclaimChannel.t.sol b/test/ReclaimChannel.t.sol index a7f9faa..2a99530 100644 --- a/test/ReclaimChannel.t.sol +++ b/test/ReclaimChannel.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; +pragma solidity ^0.8.28; import {Test, console} from "forge-std/Test.sol"; import {MuPay} from "../src/MuPay.sol"; diff --git a/test/ReclaimChannelERC20.t.sol b/test/ReclaimChannelERC20.t.sol index bb2e3d2..04479bc 100644 --- a/test/ReclaimChannelERC20.t.sol +++ b/test/ReclaimChannelERC20.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; +pragma solidity ^0.8.28; import {Test, console} from "forge-std/Test.sol"; import {MuPay} from "../src/MuPay.sol"; diff --git a/test/RedeemChannel.t.sol b/test/RedeemChannel.t.sol index 7884abd..4ddb292 100644 --- a/test/RedeemChannel.t.sol +++ b/test/RedeemChannel.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; +pragma solidity ^0.8.28; import {Test, console} from "forge-std/Test.sol"; import {MuPay} from "../src/MuPay.sol"; diff --git a/test/RedeemChannelERC20.t.sol b/test/RedeemChannelERC20.t.sol index 65fb4af..4560b34 100644 --- a/test/RedeemChannelERC20.t.sol +++ b/test/RedeemChannelERC20.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; +pragma solidity ^0.8.28; import {Test, console} from "forge-std/Test.sol"; import {MuPay} from "../src/MuPay.sol"; diff --git a/test/VerifyHashchain.t.sol b/test/VerifyHashchain.t.sol index 95e5b71..c83d8f8 100644 --- a/test/VerifyHashchain.t.sol +++ b/test/VerifyHashchain.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; +pragma solidity ^0.8.28; import {Test, console} from "forge-std/Test.sol"; import {MuPay} from "../src/MuPay.sol"; @@ -33,7 +33,7 @@ contract VerifyHashchainTest is Test { assertEq(false, isValid, "Hashchain with wrong trust anchor should be invalid"); } - function testInvalidHashchainWrongDepth() public { + function testInvalidHashchainWrongDepth() public view { bytes32 finalHash = keccak256(abi.encode("seed")); uint16 depth = 100; bytes32 trustAnchor = finalHash; diff --git a/test/helper/BaseTestHelper.sol b/test/helper/BaseTestHelper.sol new file mode 100644 index 0000000..5f26794 --- /dev/null +++ b/test/helper/BaseTestHelper.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test, console} from "forge-std/Test.sol"; + +contract BaseTestHelper is Test { + uint256 PAYER1PK = 1; + uint256 PAYER2PK = 2; + uint256 PAYEE1PK = 3; + uint256 PAYEE2PK = 4; + uint256 OWNERPK = 5; + + address public immutable PAYER = vm.addr(PAYER1PK); + address public immutable PAYER2 = vm.addr(PAYER2PK); + address public immutable PAYEE = vm.addr(PAYEE1PK); + address public immutable PAYEE2 = vm.addr(PAYEE2PK); + address public immutable OWNER = vm.addr(OWNERPK); + + address public NATIVE_TOKEN = address(0); + uint64 public constant DURATION = 100; + uint64 public constant RECLAIM_DELAY = 1000; + uint256 public constant INITIAL_BALANCE = 100 ether; + uint256 public constant DEPOSIT_AMOUNT = 10 ether; +} diff --git a/test/multisig/CreateChannel.t.sol b/test/multisig/CreateChannel.t.sol new file mode 100644 index 0000000..64e7fe7 --- /dev/null +++ b/test/multisig/CreateChannel.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test, console} from "forge-std/Test.sol"; +import {Multisig} from "../../src/Multisig_2of2.sol"; +import {BaseTestHelper} from "../helper/BaseTestHelper.sol"; + +contract CreateChannelTest is Test { + Multisig multisig; + address payer = address(0x1); + address payee = address(0x2); + address token = address(0x3); + uint256 amount = 1000; + uint64 duration = 100; + uint64 reclaimDelay = 200; + + function setUp() public { + multisig = new Multisig(); + vm.deal(payer, 10 * amount); + } + + function testMultisigCreateChannelWithNative() public { + vm.startPrank(payer); + multisig.createChannel{value: amount}(payee, address(0), amount, duration, reclaimDelay); + vm.stopPrank(); + + // Verify channel creation + ( + address channelToken, + uint256 channelAmount, + uint64 channelExpiration, + uint64 reclaimAfter, + uint256 sessionId, + uint256 lastNounce + ) = multisig.channels(payer, payee, address(0)); + assertEq(channelToken, address(0)); + assertEq(channelAmount, amount); + assertEq(channelExpiration, uint64(block.timestamp) + duration); + assertEq(sessionId, 1); + assertEq(reclaimAfter, uint64(block.timestamp) + reclaimDelay); + assertEq(lastNounce, 1); + } + + function testMultisigCreateChannelFailsIfReclaimTooSoon() public { + vm.startPrank(payer); + vm.expectRevert( + abi.encodeWithSignature( + "ReclaimAfterMustBeAfterExpiration(uint64,uint64)", block.timestamp + duration, block.timestamp + 0 + ) + ); + multisig.createChannel{value: amount}(payee, address(0), amount, duration, 0); + vm.stopPrank(); + + _assertChannelNotCreated(); + } + + function testMultisigCreateChannelFailsIfAmountIncorrect() public { + vm.startPrank(payer); + vm.expectRevert(abi.encodeWithSignature("IncorrectAmount(uint256,uint256)", amount, amount + 1)); + multisig.createChannel{value: amount}(payee, address(0), amount + 1, duration, reclaimDelay); + vm.stopPrank(); + + _assertChannelNotCreated(); + } + + function testMultisigCreateChannelsFailsIfDuplicate() public { + vm.startPrank(payer); + multisig.createChannel{value: amount}(payee, address(0), amount, duration, reclaimDelay); + vm.expectRevert( + abi.encodeWithSignature( + "ChannelAlreadyExist(address,address,address,uint256)", payer, payee, address(0), amount + ) + ); + amount += 1; // Change amount to trigger revert + multisig.createChannel{value: amount}(payee, address(0), amount, duration, reclaimDelay); + amount -= 1; // Reset amount for next tests + vm.stopPrank(); + + // Verify channel have initial values (amount) + ( + address channelToken, + uint256 channelAmount, + uint64 channelExpiration, + uint64 reclaimAfter, + uint256 sessionId, + uint256 lastNounce + ) = multisig.channels(payer, payee, address(0)); + assertEq(channelToken, address(0), "Channel should use native ETH (zero address)"); + assertEq(channelAmount, amount, "Channel amount should match the deposit"); + assertEq(channelExpiration, uint64(block.timestamp) + duration, "Expiration not set correctly"); + assertEq(reclaimAfter, uint64(block.timestamp) + reclaimDelay, "ReclaimAfter not set correctly"); + assertEq(sessionId, 1, "SessionId should start at 1"); + assertEq(lastNounce, 1, "LastNonce should start at 1"); + } + + // Helper function to check channel not created + function _assertChannelNotCreated() private view { + ( + address channelToken, + uint256 channelAmount, + uint64 channelExpiration, + uint64 reclaimAfter, + uint256 sessionId, + uint256 lastNounce + ) = multisig.channels(payer, payee, address(0)); + + assertEq(channelToken, address(0), "default address should be zero address"); + assertEq(channelAmount, 0, "default amount should be zero"); + assertEq(channelExpiration, 0, "default expiration should be zero"); + assertEq(sessionId, 0, "default sessionId should be zero"); + assertEq(reclaimAfter, 0, "default reclaimAfter should be zero"); + assertEq(lastNounce, 0, "default lastNounce should be zero"); + } +} diff --git a/test/multisig/ReclaimChannel.t.sol b/test/multisig/ReclaimChannel.t.sol new file mode 100644 index 0000000..ae56bf6 --- /dev/null +++ b/test/multisig/ReclaimChannel.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {Test, console} from "forge-std/Test.sol"; +import {Multisig} from "../../src/Multisig_2of2.sol"; +import {BaseTestHelper} from "../helper/BaseTestHelper.sol"; + +contract ReclaimChannelTest is Test, BaseTestHelper { + Multisig public multisig; + + function setUp() public { + setUpNativeTokenWithCreateChannel(); + } + + function setUpNativeToken() public { + vm.startPrank(OWNER); + multisig = new Multisig(); + vm.stopPrank(); + vm.deal(PAYER, INITIAL_BALANCE); + vm.deal(PAYER2, INITIAL_BALANCE); + } + + function setUpNativeTokenWithCreateChannel() public { + setUpNativeToken(); + vm.startPrank(PAYER); + multisig.createChannel{value: DEPOSIT_AMOUNT}(PAYEE, NATIVE_TOKEN, DEPOSIT_AMOUNT, DURATION, RECLAIM_DELAY); + vm.stopPrank(); + } + + function testMultisigReclaimChannelWithNativeToken() public { + uint256 contractBalanceBefore = address(multisig).balance; + uint256 payerBalanceBefore = PAYER.balance; + vm.warp(block.timestamp + RECLAIM_DELAY + 1); // Move time forward to allow reclaim + vm.startPrank(PAYER); + multisig.reclaimChannel(PAYEE, NATIVE_TOKEN); + vm.stopPrank(); + + uint256 contractBalanceAfter = address(multisig).balance; + uint256 payerBalanceAfter = PAYER.balance; + + assertEq( + contractBalanceAfter, contractBalanceBefore - DEPOSIT_AMOUNT, "Incorrect contract balance after reclaim" + ); + assertEq(payerBalanceAfter - payerBalanceBefore, DEPOSIT_AMOUNT, "Incorrect amount sent to payer"); + } + + function testMultipleReclaimChannelTooEarlyRevert() public { + uint256 contractBalanceBefore = address(multisig).balance; + uint256 payerBalanceBefore = PAYER.balance; + vm.warp(block.timestamp + RECLAIM_DELAY - 10); // Move time forward but not enough to allow reclaim + vm.startPrank(PAYER); + vm.expectRevert( + abi.encodeWithSignature("PayerCannotRedeemChannelYet(uint256,uint256)", block.timestamp, RECLAIM_DELAY + 1) + ); + multisig.reclaimChannel(PAYEE, NATIVE_TOKEN); + vm.stopPrank(); + + _assertChannelStateWhenFailedReclaim( + contractBalanceBefore, payerBalanceBefore, address(multisig).balance, PAYER.balance + ); + } + + function testMultipleNonExistentChannelReclaim() public { + uint256 contractBalanceBefore = address(multisig).balance; + uint256 payerBalanceBefore = PAYER.balance; + vm.startPrank(PAYER); + vm.expectRevert(abi.encodeWithSignature("ChannelDoesNotExistOrWithdrawn()")); + multisig.reclaimChannel(PAYEE2, NATIVE_TOKEN); // PAYEE2 has no channel with PAYER + vm.stopPrank(); + + _assertChannelStateWhenFailedReclaim( + contractBalanceBefore, payerBalanceBefore, address(multisig).balance, PAYER.balance + ); + } + + function _assertChannelStateWhenFailedReclaim( + uint256 contractBalanceBefore, + uint256 payerBalanceBefore, + uint256 contractBalanceAfter, + uint256 payerBalanceAfter + ) internal pure { + assertEq( + contractBalanceAfter, contractBalanceBefore, "contract balance should remain unchanged after failed redeem" + ); + assertEq(payerBalanceAfter, payerBalanceBefore, "payer balance should remain unchanged after failed redeem"); + } +} diff --git a/test/multisig/RedeemChannel.t.sol b/test/multisig/RedeemChannel.t.sol new file mode 100644 index 0000000..9637dd1 --- /dev/null +++ b/test/multisig/RedeemChannel.t.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test, console} from "forge-std/Test.sol"; +import {Multisig} from "../../src/Multisig_2of2.sol"; +import {BaseTestHelper} from "../helper/BaseTestHelper.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +contract RedeemChannelTest is Test, BaseTestHelper { + Multisig public multisig; + + using MessageHashUtils for bytes32; + + function setUp() public { + setUpNativeTokenWithCreateChannel(); + } + + function setUpNativeToken() public { + vm.startPrank(OWNER); + multisig = new Multisig(); + vm.stopPrank(); + vm.deal(PAYER, INITIAL_BALANCE); + vm.deal(PAYER2, INITIAL_BALANCE); + } + + function setUpNativeTokenWithCreateChannel() public { + setUpNativeToken(); + vm.startPrank(PAYER); + multisig.createChannel{value: DEPOSIT_AMOUNT}(PAYEE, NATIVE_TOKEN, DEPOSIT_AMOUNT, DURATION, RECLAIM_DELAY); + vm.stopPrank(); + } + + function getSignature( + address contractAddress, + uint256 payerPk, + address payee, + address depositToken, + uint256 amount, + uint256 nonce, + uint64 sessionId + ) public pure returns (bytes memory signature) { + address payer = vm.addr(payerPk); + // Create the hash to sign + bytes32 hash = + keccak256(abi.encodePacked(contractAddress, payer, payee, depositToken, amount, nonce, sessionId)); + bytes32 messageHash = hash.toEthSignedMessageHash(); + // Sign the hash with the payer's private key + (uint8 v, bytes32 r, bytes32 s) = vm.sign(payerPk, messageHash); + signature = abi.encodePacked(r, s, v); + } + + function testMultisigRedeemChannelWithNativeToken() public { + uint256 contractBalanceBefore = address(multisig).balance; + uint256 payeeBalanceBefore = PAYEE.balance; + vm.startPrank(PAYEE); + (,,,, uint64 sessionId, uint256 lastNonce) = multisig.channels(PAYER, PAYEE, NATIVE_TOKEN); + bytes memory signature = + getSignature(address(multisig), PAYER1PK, PAYEE, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce + 1, sessionId); + + multisig.redeemChannel(PAYER, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce + 1, signature); + vm.stopPrank(); + + // Verify channel redeemed + (address channelToken, uint256 channelAmount,,,,) = multisig.channels(PAYER, PAYEE, NATIVE_TOKEN); + assertEq(channelToken, NATIVE_TOKEN, "channel token should be zero address for native token"); + assertEq(channelAmount, 0, "channel amount should be zero after redeem"); + assertEq( + address(multisig).balance, + contractBalanceBefore - DEPOSIT_AMOUNT, + "contract balance should decrease by deposit amount" + ); + assertEq(PAYEE.balance, payeeBalanceBefore + DEPOSIT_AMOUNT, "payee balance should increase by deposit amount"); + } + + function testMultisigCannotRedeemWithInvalidSignature() public { + uint256 contractBalanceBefore = address(multisig).balance; + uint256 payeeBalanceBefore = PAYEE.balance; + vm.startPrank(PAYEE); + (,,,, uint64 sessionId, uint256 lastNonce) = multisig.channels(PAYER, PAYEE, NATIVE_TOKEN); + bytes memory signature = + getSignature(address(multisig), PAYER2PK, PAYEE, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce + 1, sessionId); + + // Attempt to redeem with an invalid signature + // Expect revert with InvalidChannelSignature error + vm.expectRevert(); + multisig.redeemChannel(PAYER, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce + 1, signature); + vm.stopPrank(); + + // Verify channel state remains unchanged + _assertChannelStateWhenFailedRedeem( + contractBalanceBefore, payeeBalanceBefore, address(multisig).balance, PAYEE.balance + ); + } + + function testMultisigCannotRedeemAfterExpiration() public { + (,,,, uint64 sessionId, uint256 lastNonce) = multisig.channels(PAYER, PAYEE, NATIVE_TOKEN); + bytes memory signature = + getSignature(address(multisig), PAYER1PK, PAYEE, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce + 1, sessionId); + (,, uint64 expiration,,,) = multisig.channels(PAYER, PAYEE, NATIVE_TOKEN); + vm.warp(expiration + 1); // Move time past expiration + + vm.startPrank(PAYEE); + // Attempt to redeem after expiration + vm.expectRevert(abi.encodeWithSignature("ChannelExpired(uint64)", expiration)); + multisig.redeemChannel(PAYER, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce + 1, signature); + vm.stopPrank(); + } + + function testMultisigCannotRedeemWithIncorrectAmountHigher() public { + uint256 contractBalanceBefore = address(multisig).balance; + uint256 payeeBalanceBefore = PAYEE.balance; + vm.startPrank(PAYEE); + (,,,, uint64 sessionId, uint256 lastNonce) = multisig.channels(PAYER, PAYEE, NATIVE_TOKEN); + bytes memory signature = + getSignature(address(multisig), PAYER1PK, PAYEE, NATIVE_TOKEN, DEPOSIT_AMOUNT + 1, lastNonce + 1, sessionId); + + // Attempt to redeem with an incorrect amount + vm.expectRevert(abi.encodeWithSignature("IncorrectAmount(uint256,uint256)", DEPOSIT_AMOUNT + 1, DEPOSIT_AMOUNT)); + multisig.redeemChannel(PAYER, NATIVE_TOKEN, DEPOSIT_AMOUNT + 1, lastNonce + 1, signature); + vm.stopPrank(); + + // Verify channel state remains unchanged + _assertChannelStateWhenFailedRedeem( + contractBalanceBefore, payeeBalanceBefore, address(multisig).balance, PAYEE.balance + ); + } + + function testMultisigCannotRedeemWIthStaleNonce() public { + uint256 contractBalanceBefore = address(multisig).balance; + uint256 payeeBalanceBefore = PAYEE.balance; + vm.startPrank(PAYEE); + (,,,, uint64 sessionId, uint256 lastNonce) = multisig.channels(PAYER, PAYEE, NATIVE_TOKEN); + bytes memory signature = + getSignature(address(multisig), PAYER1PK, PAYEE, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce - 1, sessionId); + + // Attempt to redeem with a stale nonce + vm.expectRevert(abi.encodeWithSignature("StaleNonce(uint256,uint256)", lastNonce - 1, lastNonce)); + multisig.redeemChannel(PAYER, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce - 1, signature); + vm.stopPrank(); + + // Verify channel state remains unchanged + _assertChannelStateWhenFailedRedeem( + contractBalanceBefore, payeeBalanceBefore, address(multisig).balance, PAYEE.balance + ); + } + + function testMultisigCannotRedeemChannelDoesNotExist() public { + uint256 contractBalanceBefore = address(multisig).balance; + uint256 payeeBalanceBefore = PAYEE.balance; + vm.startPrank(PAYEE); + bytes memory signature = getSignature(address(multisig), PAYER2PK, PAYEE, NATIVE_TOKEN, DEPOSIT_AMOUNT, 1, 1); + + // Attempt to redeem a channel that does not exist + vm.expectRevert(abi.encodeWithSignature("ChannelDoesNotExistOrWithdrawn()")); + multisig.redeemChannel(PAYER2, NATIVE_TOKEN, DEPOSIT_AMOUNT, 1, signature); + vm.stopPrank(); + + // Verify channel state remains unchanged + _assertChannelStateWhenFailedRedeem( + contractBalanceBefore, payeeBalanceBefore, address(multisig).balance, PAYEE.balance + ); + } + + function testMultisigCannotRedeemFromWrongPayee() public { + uint256 contractBalanceBefore = address(multisig).balance; + uint256 payeeBalanceBefore = PAYEE.balance; + vm.startPrank(PAYER2); // PAYER2 tries to redeem + (,,,, uint64 sessionId, uint256 lastNonce) = multisig.channels(PAYER, PAYEE, NATIVE_TOKEN); + bytes memory signature = + getSignature(address(multisig), PAYER1PK, PAYEE, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce + 1, sessionId); + + // Attempt to redeem with the wrong payee + // Revert with ChannelDoesNotExistOrWithdrawn error + // since channel between PAYEE2 and PAYER does not exist + vm.expectRevert(abi.encodeWithSignature("ChannelDoesNotExistOrWithdrawn()")); + multisig.redeemChannel(PAYER, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce + 1, signature); + vm.stopPrank(); + + // Verify channel state remains unchanged + _assertChannelStateWhenFailedRedeem( + contractBalanceBefore, payeeBalanceBefore, address(multisig).balance, PAYEE.balance + ); + } + + function _assertChannelStateWhenFailedRedeem( + uint256 contractBalanceBefore, + uint256 payeeBalanceBefore, + uint256 contractBalanceAfter, + uint256 payeeBalanceAfter + ) internal pure { + assertEq( + contractBalanceAfter, contractBalanceBefore, "contract balance should remain unchanged after failed redeem" + ); + assertEq(payeeBalanceAfter, payeeBalanceBefore, "payee balance should remain unchanged after failed redeem"); + } +}