Skip to content

Commit

Permalink
Merge pull request #127 from BoltzExchange/solidity-batch-claim
Browse files Browse the repository at this point in the history
Batch claim from Solidity contracts
  • Loading branch information
michael1011 authored Dec 3, 2024
2 parents af7a6f9 + bdb0074 commit 83926a3
Show file tree
Hide file tree
Showing 7 changed files with 453 additions and 32 deletions.
80 changes: 65 additions & 15 deletions contracts/ERC20Swap.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ contract ERC20Swap {
// Constants

/// @dev Version of the contract used for compatibility checks
uint8 public constant version = 3;
uint8 public constant version = 4;

bytes32 public immutable DOMAIN_SEPARATOR;
bytes32 public immutable TYPEHASH_REFUND;
Expand Down Expand Up @@ -40,7 +40,7 @@ contract ERC20Swap {
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("ERC20Swap"),
keccak256("3"),
keccak256("4"),
block.chainid,
address(this)
)
Expand Down Expand Up @@ -104,24 +104,41 @@ contract ERC20Swap {
address refundAddress,
uint256 timelock
) public {
// If the preimage is wrong, so will be its hash which will result in a wrong value hash and no swap being found
bytes32 preimageHash = sha256(abi.encodePacked(preimage));
prepareClaim(preimage, amount, tokenAddress, claimAddress, refundAddress, timelock);

bytes32 hash = hashValues(preimageHash, amount, tokenAddress, claimAddress, refundAddress, timelock);
// Transfer the tokens to the claim address
TransferHelper.safeTransferToken(tokenAddress, claimAddress, amount);
}

// Make sure that the swap to be claimed has tokens locked
checkSwapIsLocked(hash);
/// Claims multiple swaps
/// @dev All swaps that are claimed have to have "msg.sender" as "claimAddress" and the same token address
/// @param tokenAddress Address of the token of the swap
/// @param preimages Preimages of the swaps
/// @param amounts Amounts that are locked in the contract for the swap in WEI
/// @param refundAddresses Addresses that locked the Ether in the contract
/// @param timelocks Block heights after which the locked Ether can be refunded
function claimBatch(
address tokenAddress,
bytes32[] calldata preimages,
uint256[] calldata amounts,
address[] calldata refundAddresses,
uint256[] calldata timelocks
) external {
uint256 toSend = 0;
uint256 swapAmount = 0;

// Delete the swap from the mapping to ensure that it cannot be claimed or refunded anymore
// This *HAS* to be done before actually sending the tokens to avoid reentrancy
// Reentrancy is a bigger problem when sending Ether but there is no real downside to deleting from the mapping first
delete swaps[hash];
unchecked {
for (uint256 i = 0; i < preimages.length; i++) {
swapAmount = amounts[i];
prepareClaim(preimages[i], swapAmount, tokenAddress, msg.sender, refundAddresses[i], timelocks[i]);

// Emit the "Claim" event
emit Claim(preimageHash, preimage);
// For the "prepareClaim" function to not revert, the amount has to have been locked
// in the contract which means this addition cannot overflow in realistic scenarios
toSend += swapAmount;
}
}

// Transfer the tokens to the claim address
TransferHelper.safeTransferToken(tokenAddress, claimAddress, amount);
TransferHelper.safeTransferToken(tokenAddress, payable(msg.sender), toSend);
}

/// Refunds tokens locked in the contract after the timeout
Expand Down Expand Up @@ -232,6 +249,39 @@ contract ERC20Swap {

// Private functions

/// Prepares a claim by checking if funds were locked, deleting the swap from storage
/// and emitting an event but ***does not*** transfer
/// @param preimage Preimage of the swap
/// @param amount Amount locked in the contract for the swap in the smallest denomination of the token
/// @param tokenAddress Address of the token of the swap
/// @param claimAddress Address that that was destined to claim the funds
/// @param refundAddress Address that locked the Ether and can refund them
/// @param timelock Block height after which the locked Ether can be refunded
function prepareClaim(
bytes32 preimage,
uint256 amount,
address tokenAddress,
address claimAddress,
address refundAddress,
uint256 timelock
) private {
// If the preimage is wrong, so will be its hash which will result in a wrong value hash and no swap being found
bytes32 preimageHash = sha256(abi.encodePacked(preimage));

bytes32 hash = hashValues(preimageHash, amount, tokenAddress, claimAddress, refundAddress, timelock);

// Make sure that the swap to be claimed has tokens locked
checkSwapIsLocked(hash);

// Delete the swap from the mapping to ensure that it cannot be claimed or refunded anymore
// This *HAS* to be done before actually sending the tokens to avoid reentrancy
// Reentrancy is a bigger problem when sending Ether but there is no real downside to deleting from the mapping first
delete swaps[hash];

// Emit the "Claim" event
emit Claim(preimageHash, preimage);
}

function refundInternal(
bytes32 preimageHash,
uint256 amount,
Expand Down
74 changes: 60 additions & 14 deletions contracts/EtherSwap.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ contract EtherSwap {
// Constants

/// @dev Version of the contract used for compatibility checks
uint8 public constant version = 3;
uint8 public constant version = 4;

bytes32 public immutable DOMAIN_SEPARATOR;
bytes32 public immutable TYPEHASH_REFUND;
Expand Down Expand Up @@ -39,7 +39,7 @@ contract EtherSwap {
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("EtherSwap"),
keccak256("3"),
keccak256("4"),
block.chainid,
address(this)
)
Expand Down Expand Up @@ -103,22 +103,39 @@ contract EtherSwap {
function claim(bytes32 preimage, uint256 amount, address claimAddress, address refundAddress, uint256 timelock)
public
{
// If the preimage is wrong, so will be its hash which will result in a wrong value hash and no swap being found
bytes32 preimageHash = sha256(abi.encodePacked(preimage));
prepareClaim(preimage, amount, claimAddress, refundAddress, timelock);

bytes32 hash = hashValues(preimageHash, amount, claimAddress, refundAddress, timelock);
// Transfer the Ether to the claim address
TransferHelper.transferEther(payable(claimAddress), amount);
}

// Make sure that the swap to be claimed has Ether locked
checkSwapIsLocked(hash);
// Delete the swap from the mapping to ensure that it cannot be claimed or refunded anymore
// This *HAS* to be done before actually sending the Ether to avoid reentrancy
delete swaps[hash];
/// Claims multiple swaps
/// @dev All swaps that are claimed have to have "msg.sender" as "claimAddress"
/// @param preimages Preimages of the swaps
/// @param amounts Amounts that are locked in the contract for the swap in WEI
/// @param refundAddresses Addresses that locked the Ether in the contract
/// @param timelocks Block heights after which the locked Ether can be refunded
function claimBatch(
bytes32[] calldata preimages,
uint256[] calldata amounts,
address[] calldata refundAddresses,
uint256[] calldata timelocks
) external {
uint256 toSend = 0;
uint256 swapAmount = 0;

// Emit the claim event
emit Claim(preimageHash, preimage);
unchecked {
for (uint256 i = 0; i < preimages.length; i++) {
swapAmount = amounts[i];
prepareClaim(preimages[i], swapAmount, msg.sender, refundAddresses[i], timelocks[i]);

// Transfer the Ether to the claim address
TransferHelper.transferEther(payable(claimAddress), amount);
// For the "prepareClaim" function to not revert, the amount has to have been locked
// in the contract which means this addition cannot overflow in realistic scenarios
toSend += swapAmount;
}
}

TransferHelper.transferEther(payable(msg.sender), toSend);
}

/// Refunds Ether locked in the contract after the timeout
Expand Down Expand Up @@ -192,6 +209,35 @@ contract EtherSwap {

// Private functions

/// Prepares a claim by checking if funds were locked, deleting the swap from storage
/// and emitting an event but ***does not*** transfer
/// @param preimage Preimage of the swap
/// @param amount Amount locked in the contract for the swap in WEI
/// @param claimAddress Address that that was destined to claim the funds
/// @param refundAddress Address that locked the Ether and can refund them
/// @param timelock Block height after which the locked Ether can be refunded
function prepareClaim(
bytes32 preimage,
uint256 amount,
address claimAddress,
address refundAddress,
uint256 timelock
) private {
// If the preimage is wrong, so will be its hash which will result in a wrong value hash and no swap being found
bytes32 preimageHash = sha256(abi.encodePacked(preimage));
bytes32 hash = hashValues(preimageHash, amount, claimAddress, refundAddress, timelock);

// Make sure that the swap to be claimed has Ether locked
checkSwapIsLocked(hash);

// Delete the swap from the mapping to ensure that it cannot be claimed or refunded anymore
// This *HAS* to be done before actually sending the Ether to avoid reentrancy
delete swaps[hash];

// Emit the claim event
emit Claim(preimageHash, preimage);
}

/// Locks Ether in the contract
/// @notice The refund address is the sender of the transaction
/// @param preimageHash Preimage hash of the swap
Expand Down
144 changes: 143 additions & 1 deletion contracts/test/ERC20SwapTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ contract ERC20SwapTest is Test {
}

function testCorrectVersion() external view {
assertEq(swap.version(), 3);
assertEq(swap.version(), 4);
}

function testShouldNotAcceptEther() external {
Expand Down Expand Up @@ -147,6 +147,116 @@ contract ERC20SwapTest is Test {
assertFalse(querySwap(timelock));
}

function testClaimBatchTwo() external {
uint256 timelock = block.number;
uint256 balanceBeforeClaim = token.balanceOf(claimAddress);

token.approve(address(swap), lockupAmount);
swap.lock(preimageHash, lockupAmount, address(token), claimAddress, timelock);

bytes32 preimageSecond = sha256("2");
bytes32 preimageHashSecond = sha256(abi.encodePacked(preimageSecond));

uint256 lockupAmountSecond = 123;
uint256 timelockSecond = block.number + 21;

token.approve(address(swap), lockupAmount);
swap.lock(preimageHashSecond, lockupAmountSecond, address(token), claimAddress, timelockSecond);

bytes32[] memory preimages = new bytes32[](2);
preimages[0] = preimage;
preimages[1] = preimageSecond;

uint256[] memory amounts = new uint256[](2);
amounts[0] = lockupAmount;
amounts[1] = lockupAmountSecond;

address[] memory refundAddresses = new address[](2);
refundAddresses[0] = address(this);
refundAddresses[1] = address(this);

uint256[] memory timelocks = new uint256[](2);
timelocks[0] = timelock;
timelocks[1] = timelockSecond;

vm.prank(claimAddress);

vm.expectEmit(true, false, false, true, address(swap));
emit Claim(preimageHash, preimage);

vm.expectEmit(true, false, false, true, address(swap));
emit Claim(preimageHashSecond, preimageSecond);

swap.claimBatch(address(token), preimages, amounts, refundAddresses, timelocks);

assertEq(address(swap).balance, 0);
assertEq(token.balanceOf(claimAddress) - balanceBeforeClaim, lockupAmount + lockupAmountSecond);
}

function testClaimBatchThree() external {
uint256 timelock = block.number;
uint256 balanceBeforeClaim = token.balanceOf(claimAddress);

token.approve(address(swap), lockupAmount);
swap.lock(preimageHash, lockupAmount, address(token), claimAddress, timelock);

bytes32 preimageSecond = sha256("2");
bytes32 preimageHashSecond = sha256(abi.encodePacked(preimageSecond));

uint256 lockupAmountSecond = 123;
uint256 timelockSecond = block.number + 21;

token.approve(address(swap), lockupAmountSecond);
swap.lock(preimageHashSecond, lockupAmountSecond, address(token), claimAddress, timelockSecond);

bytes32 preimageThird = sha256("3");
bytes32 preimageHashThird = sha256(abi.encodePacked(preimageThird));

uint256 lockupAmountThird = 321;
uint256 timelockThird = block.number + 42;

token.approve(address(swap), lockupAmountThird);
swap.lock(preimageHashThird, lockupAmountThird, address(token), claimAddress, timelockThird);

bytes32[] memory preimages = new bytes32[](3);
preimages[0] = preimage;
preimages[1] = preimageSecond;
preimages[2] = preimageThird;

uint256[] memory amounts = new uint256[](3);
amounts[0] = lockupAmount;
amounts[1] = lockupAmountSecond;
amounts[2] = lockupAmountThird;

address[] memory refundAddresses = new address[](3);
refundAddresses[0] = address(this);
refundAddresses[1] = address(this);
refundAddresses[2] = address(this);

uint256[] memory timelocks = new uint256[](3);
timelocks[0] = timelock;
timelocks[1] = timelockSecond;
timelocks[2] = timelockThird;

vm.prank(claimAddress);

vm.expectEmit(true, false, false, true, address(swap));
emit Claim(preimageHash, preimage);

vm.expectEmit(true, false, false, true, address(swap));
emit Claim(preimageHashSecond, preimageSecond);

vm.expectEmit(true, false, false, true, address(swap));
emit Claim(preimageHashThird, preimageThird);

swap.claimBatch(address(token), preimages, amounts, refundAddresses, timelocks);

assertEq(address(swap).balance, 0);
assertEq(
token.balanceOf(claimAddress) - balanceBeforeClaim, lockupAmount + lockupAmountSecond + lockupAmountThird
);
}

function testClaimTwiceFail() external {
uint256 timelock = block.number;

Expand All @@ -165,6 +275,38 @@ contract ERC20SwapTest is Test {
}
}

function testClaimBatchTwiceFail() external {
uint256 timelock = block.number;

token.approve(address(swap), lockupAmount);
lock(timelock);

bytes32[] memory preimages = new bytes32[](2);
preimages[0] = preimage;
preimages[1] = preimage;

uint256[] memory amounts = new uint256[](2);
amounts[0] = lockupAmount;
amounts[1] = lockupAmount;

address[] memory refundAddresses = new address[](2);
refundAddresses[0] = address(this);
refundAddresses[1] = address(this);

uint256[] memory timelocks = new uint256[](2);
timelocks[0] = timelock;
timelocks[1] = timelock;

vm.prank(claimAddress);
try swap.claimBatch(address(token), preimages, amounts, refundAddresses, timelocks) {
fail();
} catch Error(string memory exception) {
assertEq(string(exception), "ERC20Swap: swap has no tokens locked in the contract");
} catch (bytes memory) {
fail();
}
}

function testRefund() external {
uint256 timelock = block.number;

Expand Down
Loading

0 comments on commit 83926a3

Please sign in to comment.