From c9f1bf890df1ea24d78e575f917f87a820bf0022 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Wed, 28 May 2025 12:55:02 +0530 Subject: [PATCH 01/10] feat: add Multisig.sol implementing createChannel and redeemChannel functions for native/ERC20 payment channels --- src/Multisig_2of2.sol | 218 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 src/Multisig_2of2.sol diff --git a/src/Multisig_2of2.sol b/src/Multisig_2of2.sol new file mode 100644 index 0000000..f0ec2dc --- /dev/null +++ b/src/Multisig_2of2.sol @@ -0,0 +1,218 @@ +// 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 sessionId; // Unique identifier for the payment session + uint256 lastNounce; // 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); + 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); + + /** + * @dev Events to log key contract actions. + */ + event ChannelCreated( + address indexed payer, + address indexed payee, + address indexed token, + uint256 amount, + uint64 expiration, + uint256 sessionId + ); + event ChannelRedeemed( + address indexed payer, + address indexed payee, + address indexed token, + uint256 amount, + uint256 nounce, + 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) external payable { + // Validate payee address + require(payee != address(0), "Invalid address"); + + // Dispatch to the correct internal handler based on token type + if (token == address(0)) { + _createNativeChannel(payee, amount, duration); + } else { + _createERC20Channel(payee, token, amount, duration); + } + } + + /** + * @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) 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); + } + + /** + * @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) 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); + } + + /** + * @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) 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.number) + duration; + channel.sessionId += 1; + channel.lastNounce = 0; + + emit ChannelCreated(payer, payee, token, amount, channel.expiration, channel.sessionId); + } + + function redeemChannel(address payer, address token, uint256 amount, uint256 nounce, bytes calldata signature) + external + nonReentrant + { + // Validate, mark consumed and compute refund + (uint256 refund, uint64 sessionId) = _validateAndConsume(payer, msg.sender, token, amount, nounce, 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, nounce, sessionId); + emit ChannelRefunded(payer, msg.sender, token, refund); + } + + function _validateAndConsume( + address payer, + address payee, + address token, + uint256 amount, + uint256 nounce, + 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 (nounce <= channel.lastNounce) revert StaleNonce(nounce, channel.lastNounce); + + // recreate EIP-191 hash + bytes32 hash = keccak256( + abi.encodePacked(address(this), payer, payee, channel.token, amount, nounce, 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 nounce used and clear channel + channel.lastNounce = nounce; + 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); + } + } +} From bbc8de31ba464b59bcedcfebc11adc6c61836b7b Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Wed, 28 May 2025 13:02:33 +0530 Subject: [PATCH 02/10] docs(multisig): add NatSpec comments to redeemChannel function --- src/Multisig_2of2.sol | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Multisig_2of2.sol b/src/Multisig_2of2.sol index f0ec2dc..051e044 100644 --- a/src/Multisig_2of2.sol +++ b/src/Multisig_2of2.sol @@ -159,6 +159,14 @@ contract Multisig is ReentrancyGuard { emit ChannelCreated(payer, payee, token, amount, channel.expiration, channel.sessionId); } + /** + * @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 nounce 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 nounce, bytes calldata signature) external nonReentrant From 3fac4409a65d879f11cccece40801c3a7bdfabc8 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Fri, 4 Jul 2025 20:59:34 +0530 Subject: [PATCH 03/10] test: add test cases for create channel multisig --- src/Multisig_2of2.sol | 51 ++++++++++---- test/multisig/CreateChannel.t.sol | 113 ++++++++++++++++++++++++++++++ test/multisig/RedeemChannel.t.sol | 25 +++++++ 3 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 test/multisig/CreateChannel.t.sol create mode 100644 test/multisig/RedeemChannel.t.sol diff --git a/src/Multisig_2of2.sol b/src/Multisig_2of2.sol index 051e044..89e6818 100644 --- a/src/Multisig_2of2.sol +++ b/src/Multisig_2of2.sol @@ -19,6 +19,7 @@ contract Multisig is ReentrancyGuard { 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 lastNounce; // Last used nonce to prevent replay attacks and ensure order } @@ -42,6 +43,7 @@ contract Multisig is ReentrancyGuard { 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. @@ -52,7 +54,8 @@ contract Multisig is ReentrancyGuard { address indexed token, uint256 amount, uint64 expiration, - uint256 sessionId + uint256 sessionId, + uint64 reclaimAfter ); event ChannelRedeemed( address indexed payer, @@ -74,15 +77,24 @@ contract Multisig is ReentrancyGuard { * @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) external payable { + function createChannel(address payee, address token, uint256 amount, uint64 duration, uint64 reclaimDelay) + external + payable + { // 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); + _createNativeChannel(payee, amount, duration, reclaimDelay); } else { - _createERC20Channel(payee, token, amount, duration); + _createERC20Channel(payee, token, amount, duration, reclaimDelay); } } @@ -92,12 +104,12 @@ contract Multisig is ReentrancyGuard { * @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) internal { + 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); + _initChannel(msg.sender, payee, address(0), amount, duration, reclaimDelay); } /** @@ -107,7 +119,9 @@ contract Multisig is ReentrancyGuard { * @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) internal { + 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); @@ -130,7 +144,7 @@ contract Multisig is ReentrancyGuard { IERC20(token).safeTransferFrom(msg.sender, address(this), amount); // Initialize and record the channel - _initChannel(msg.sender, payee, token, amount, duration); + _initChannel(msg.sender, payee, token, amount, duration, reclaimDelay); } /** @@ -141,7 +155,14 @@ contract Multisig is ReentrancyGuard { * @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) private { + function _initChannel( + address payer, + address payee, + address token, + uint256 amount, + uint64 duration, + uint64 reclaimDelay + ) private { // Channel initialization Channel storage channel = channels[payer][payee][token]; @@ -152,11 +173,12 @@ contract Multisig is ReentrancyGuard { channel.token = token; channel.amount = amount; - channel.expiration = uint64(block.number) + duration; + channel.expiration = uint64(block.timestamp) + duration; + channel.reclaimAfter = uint64(block.timestamp) + reclaimDelay; channel.sessionId += 1; - channel.lastNounce = 0; + channel.lastNounce += 1; - emit ChannelCreated(payer, payee, token, amount, channel.expiration, channel.sessionId); + emit ChannelCreated(payer, payee, token, amount, channel.expiration, channel.sessionId, channel.reclaimAfter); } /** @@ -172,7 +194,8 @@ contract Multisig is ReentrancyGuard { nonReentrant { // Validate, mark consumed and compute refund - (uint256 refund, uint64 sessionId) = _validateAndConsume(payer, msg.sender, token, amount, nounce, signature); + (uint256 refund, uint64 sessionId) = + _validateAndConsumeChannel(payer, msg.sender, token, amount, nounce, signature); // Dispatch the two transfers via _transfer helper function _transfer(msg.sender, token, amount); @@ -183,7 +206,7 @@ contract Multisig is ReentrancyGuard { emit ChannelRefunded(payer, msg.sender, token, refund); } - function _validateAndConsume( + function _validateAndConsumeChannel( address payer, address payee, address token, diff --git a/test/multisig/CreateChannel.t.sol b/test/multisig/CreateChannel.t.sol new file mode 100644 index 0000000..3921561 --- /dev/null +++ b/test/multisig/CreateChannel.t.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {Test, console} from "forge-std/Test.sol"; +import {Multisig} from "../../src/Multisig_2of2.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 { + ( + address channelToken, + uint256 channelAmount, + uint64 channelExpiration, + uint64 reclaimAfter, + uint256 sessionId, + uint256 lastNounce + ) = multisig.channels(payer, payee, address(0)); + + assertEq(channelToken, address(0)); + assertEq(channelAmount, 0); + assertEq(channelExpiration, 0); + assertEq(sessionId, 0); + assertEq(reclaimAfter, 0); + assertEq(lastNounce, 0); + } +} diff --git a/test/multisig/RedeemChannel.t.sol b/test/multisig/RedeemChannel.t.sol new file mode 100644 index 0000000..1390104 --- /dev/null +++ b/test/multisig/RedeemChannel.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {Test, console} from "forge-std/Test.sol"; +import {Multisig} from "../../src/Multisig_2of2.sol"; + +contract RedeemChannelTest is Test { + Multisig multisig; + address payer = address(0x1); + address payee = address(0x2); + address token = address(0); // native ETH + uint256 amount = 1000; + uint64 duration = 100; + uint64 reclaimDelay = 200; + + function setUp() public { + multisig = new Multisig(); + vm.deal(payer, 10 * amount); + + // Payer creates channel + vm.startPrank(payer); + multisig.createChannel{value: amount}(payee, address(0), amount, duration, reclaimDelay); + vm.stopPrank(); + } +} From 0b94f1038415598bae5f7b9be0867a2f5ba52437 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 5 Jul 2025 16:46:38 +0530 Subject: [PATCH 04/10] test: add tests for create and redeem channel for multisig scheme --- test/multisig/CreateChannel.t.sol | 17 +-- test/multisig/RedeemChannel.t.sol | 198 ++++++++++++++++++++++++++++-- 2 files changed, 194 insertions(+), 21 deletions(-) diff --git a/test/multisig/CreateChannel.t.sol b/test/multisig/CreateChannel.t.sol index 3921561..64e7fe7 100644 --- a/test/multisig/CreateChannel.t.sol +++ b/test/multisig/CreateChannel.t.sol @@ -1,8 +1,9 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.28; +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; @@ -93,7 +94,7 @@ contract CreateChannelTest is Test { } // Helper function to check channel not created - function _assertChannelNotCreated() private { + function _assertChannelNotCreated() private view { ( address channelToken, uint256 channelAmount, @@ -103,11 +104,11 @@ contract CreateChannelTest is Test { uint256 lastNounce ) = multisig.channels(payer, payee, address(0)); - assertEq(channelToken, address(0)); - assertEq(channelAmount, 0); - assertEq(channelExpiration, 0); - assertEq(sessionId, 0); - assertEq(reclaimAfter, 0); - assertEq(lastNounce, 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/RedeemChannel.t.sol b/test/multisig/RedeemChannel.t.sol index 1390104..b8802a4 100644 --- a/test/multisig/RedeemChannel.t.sol +++ b/test/multisig/RedeemChannel.t.sol @@ -1,25 +1,197 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.28; +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 { - Multisig multisig; - address payer = address(0x1); - address payee = address(0x2); - address token = address(0); // native ETH - uint256 amount = 1000; - uint64 duration = 100; - uint64 reclaimDelay = 200; +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.deal(payer, 10 * amount); + 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); - // Payer creates channel - vm.startPrank(payer); - multisig.createChannel{value: amount}(payee, address(0), amount, duration, reclaimDelay); + 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 testsMultisigCannotRedeemWithIncorrectAmountHigher() 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"); } } From 3435d6ea47a7e9c44915f7b212456f17785abeab Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 5 Jul 2025 16:51:45 +0530 Subject: [PATCH 05/10] chore: update pragma solidity 0.8.28 to ^0.8.28 in all contracts --- src/MuPay.sol | 2 +- src/Multisig_2of2.sol | 3 ++- test/CreateChannel.t.sol | 2 +- test/CreateChannelERC20.t.sol | 2 +- test/ReclaimChannel.t.sol | 2 +- test/ReclaimChannelERC20.t.sol | 2 +- test/RedeemChannel.t.sol | 2 +- test/RedeemChannelERC20.t.sol | 2 +- test/VerifyHashchain.t.sol | 4 ++-- 9 files changed, 11 insertions(+), 10 deletions(-) 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 index 89e6818..ae0add3 100644 --- a/src/Multisig_2of2.sol +++ b/src/Multisig_2of2.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.28; +pragma solidity ^0.8.28; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; @@ -80,6 +80,7 @@ contract Multisig is ReentrancyGuard { function createChannel(address payee, address token, uint256 amount, uint64 duration, uint64 reclaimDelay) external payable + nonReentrant { // Validate payee address require(payee != address(0), "Invalid address"); 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; From 533b609f70cb2e733c3976519fa49563644b6321 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 5 Jul 2025 17:25:42 +0530 Subject: [PATCH 06/10] chore: fix formating and updated solc version in foundry.toml --- foundry.toml | 1 + test/multisig/RedeemChannel.t.sol | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) 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/test/multisig/RedeemChannel.t.sol b/test/multisig/RedeemChannel.t.sol index b8802a4..283ee09 100644 --- a/test/multisig/RedeemChannel.t.sol +++ b/test/multisig/RedeemChannel.t.sol @@ -96,7 +96,7 @@ contract RedeemChannelTest is Test, BaseTestHelper { (,,,, 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); + (,, uint64 expiration,,,) = multisig.channels(PAYER, PAYEE, NATIVE_TOKEN); vm.warp(expiration + 1); // Move time past expiration vm.startPrank(PAYEE); @@ -148,8 +148,7 @@ contract RedeemChannelTest is Test, BaseTestHelper { 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); + 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()")); @@ -171,7 +170,7 @@ contract RedeemChannelTest is Test, BaseTestHelper { getSignature(address(multisig), PAYER1PK, PAYEE, NATIVE_TOKEN, DEPOSIT_AMOUNT, lastNonce + 1, sessionId); // Attempt to redeem with the wrong payee - // Revert with ChannelDoesNotExistOrWithdrawn error + // 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); From 402799a986794625f9aae9bb26374c5d48e69a7b Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 5 Jul 2025 17:31:15 +0530 Subject: [PATCH 07/10] chore: update solidity version in script --- script/MuPay.s.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"; From 28a9e915030ae757549caf9194efbf63e3cf6cfa Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 5 Jul 2025 18:06:07 +0530 Subject: [PATCH 08/10] test: add base test helper --- test/helper/BaseTestHelper.sol | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 test/helper/BaseTestHelper.sol 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; +} From 49a09599c36b385a7afc4ef3f4e0ebe13da61b67 Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 5 Jul 2025 18:38:33 +0530 Subject: [PATCH 09/10] feat: add reclaim function and test cases --- src/Multisig_2of2.sol | 31 ++++++++++- test/multisig/ReclaimChannel.t.sol | 87 ++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 test/multisig/ReclaimChannel.t.sol diff --git a/src/Multisig_2of2.sol b/src/Multisig_2of2.sol index ae0add3..7c099b4 100644 --- a/src/Multisig_2of2.sol +++ b/src/Multisig_2of2.sol @@ -33,7 +33,7 @@ contract Multisig is ReentrancyGuard { error IncorrectAmount(uint256 sentAmount, uint256 expectedAmount); error ChannelDoesNotExistOrWithdrawn(); error ChannelExpired(uint64 expiration); - error PayerCannotRedeemChannelYet(uint256 blockNumber); + error PayerCannotRedeemChannelYet(uint256 blockNumber, uint256 reclaimAfter); error ChannelAlreadyExist(address payer, address payee, address token, uint256 amount); error NothingPayable(); error FailedToSendEther(); @@ -247,4 +247,33 @@ contract Multisig is ReentrancyGuard { 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/multisig/ReclaimChannel.t.sol b/test/multisig/ReclaimChannel.t.sol new file mode 100644 index 0000000..8e68c0a --- /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 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"); + } +} From f73fe8df9de8d5c41e4b35025de75ae4678604ef Mon Sep 17 00:00:00 2001 From: Aashish Paliwal Date: Sat, 5 Jul 2025 19:27:59 +0530 Subject: [PATCH 10/10] fix: spelling mistakes --- src/Multisig_2of2.sol | 24 ++++++++++++------------ test/multisig/ReclaimChannel.t.sol | 6 +++--- test/multisig/RedeemChannel.t.sol | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Multisig_2of2.sol b/src/Multisig_2of2.sol index 7c099b4..bc740b2 100644 --- a/src/Multisig_2of2.sol +++ b/src/Multisig_2of2.sol @@ -21,7 +21,7 @@ contract Multisig is ReentrancyGuard { 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 lastNounce; // Last used nonce to prevent replay attacks and ensure order + uint256 lastNonce; // Last used nonce to prevent replay attacks and ensure order } // payer => payee => token => Channel @@ -62,7 +62,7 @@ contract Multisig is ReentrancyGuard { address indexed payee, address indexed token, uint256 amount, - uint256 nounce, + uint256 nonce, uint256 sessionId ); event ChannelRefunded(address indexed payer, address indexed payee, address indexed token, uint256 refundAmount); @@ -177,7 +177,7 @@ contract Multisig is ReentrancyGuard { channel.expiration = uint64(block.timestamp) + duration; channel.reclaimAfter = uint64(block.timestamp) + reclaimDelay; channel.sessionId += 1; - channel.lastNounce += 1; + channel.lastNonce += 1; emit ChannelCreated(payer, payee, token, amount, channel.expiration, channel.sessionId, channel.reclaimAfter); } @@ -187,23 +187,23 @@ contract Multisig is ReentrancyGuard { * @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 nounce A strictly increasing number to prevent replay of old vouchers. + * @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 nounce, bytes calldata signature) + 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, nounce, signature); + _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, nounce, sessionId); + emit ChannelRedeemed(payer, msg.sender, token, amount, nonce, sessionId); emit ChannelRefunded(payer, msg.sender, token, refund); } @@ -212,18 +212,18 @@ contract Multisig is ReentrancyGuard { address payee, address token, uint256 amount, - uint256 nounce, + 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 (nounce <= channel.lastNounce) revert StaleNonce(nounce, channel.lastNounce); + 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, nounce, channel.sessionId) + abi.encodePacked(address(this), payer, payee, channel.token, amount, nonce, channel.sessionId) ).toEthSignedMessageHash(); // signature check @@ -234,8 +234,8 @@ contract Multisig is ReentrancyGuard { refund = channel.amount - amount; sessionId = channel.sessionId; - // mark nounce used and clear channel - channel.lastNounce = nounce; + // mark nonce used and clear channel + channel.lastNonce = nonce; channel.amount = 0; } diff --git a/test/multisig/ReclaimChannel.t.sol b/test/multisig/ReclaimChannel.t.sol index 8e68c0a..ae56bf6 100644 --- a/test/multisig/ReclaimChannel.t.sol +++ b/test/multisig/ReclaimChannel.t.sol @@ -75,13 +75,13 @@ contract ReclaimChannelTest is Test, BaseTestHelper { function _assertChannelStateWhenFailedReclaim( uint256 contractBalanceBefore, - uint256 payeeBalanceBefore, + uint256 payerBalanceBefore, uint256 contractBalanceAfter, - uint256 payeeBalanceAfter + uint256 payerBalanceAfter ) internal pure { assertEq( contractBalanceAfter, contractBalanceBefore, "contract balance should remain unchanged after failed redeem" ); - assertEq(payeeBalanceAfter, payeeBalanceBefore, "payee 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 index 283ee09..9637dd1 100644 --- a/test/multisig/RedeemChannel.t.sol +++ b/test/multisig/RedeemChannel.t.sol @@ -106,7 +106,7 @@ contract RedeemChannelTest is Test, BaseTestHelper { vm.stopPrank(); } - function testsMultisigCannotRedeemWithIncorrectAmountHigher() public { + function testMultisigCannotRedeemWithIncorrectAmountHigher() public { uint256 contractBalanceBefore = address(multisig).balance; uint256 payeeBalanceBefore = PAYEE.balance; vm.startPrank(PAYEE);