Skip to content

Commit

Permalink
feat: cooperative refunds in EVM contracts (#116)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael1011 committed Feb 6, 2024
1 parent 16c5402 commit 6ecdac7
Show file tree
Hide file tree
Showing 5 changed files with 336 additions and 38 deletions.
108 changes: 92 additions & 16 deletions contracts/ERC20Swap.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import "./TransferHelper.sol";

// @title Hash timelock contract for ERC20 tokens
contract ERC20Swap {
// State variables
// Constants

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

bytes32 immutable public DOMAIN_SEPARATOR;
bytes32 immutable public TYPEHASH_REFUND;

// State variables

/// @dev Mapping between value hashes of swaps and whether they have Ether locked in the contract
mapping (bytes32 => bool) public swaps;
Expand All @@ -30,6 +35,21 @@ contract ERC20Swap {

// Functions

constructor() {
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("ERC20Swap"),
keccak256("3"),
block.chainid,
address(this)
)
);
TYPEHASH_REFUND = keccak256(
"Refund(bytes32 preimageHash,uint256 amount,address tokenAddress,address claimAddress,uint256 timeout)"
);
}

// External functions

/// Locks tokens for a swap in the contract and forwards a specified amount of Ether to the claim address
Expand Down Expand Up @@ -115,7 +135,7 @@ contract ERC20Swap {
TransferHelper.safeTransferToken(tokenAddress, claimAddress, amount);
}

/// Refunds tokens locked in the contract
/// Refunds tokens locked in the contract after the timeout
/// @dev To query the arguments of this function, get the "Lockup" event logs for your refund address and the preimage hash if you have it
/// @dev For further explanations and reasoning behind the statements in this function, check the "claim" function
/// @param preimageHash Preimage hash of the swap
Expand All @@ -133,22 +153,54 @@ contract ERC20Swap {
// Make sure the timelock has expired already
// If the timelock is wrong, so will be the value hash of the swap which results in no swap being found
require(timelock <= block.number, "ERC20Swap: swap has not timed out yet");
refundInternal(preimageHash, amount, tokenAddress, claimAddress, timelock);
}

bytes32 hash = hashValues(
preimageHash,
amount,
tokenAddress,
claimAddress,
msg.sender,
timelock
/// Refunds tokens locked in the contract with an EIP-712 signature of the claimAddress
/// @dev To query the arguments of this function, get the "Lockup" event logs for your refund address and the preimage hash if you have it
/// @dev For further explanations and reasoning behind the statements in this function, check the "claim" function
/// @param preimageHash Preimage hash 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 locked for the swap
/// @param claimAddress Address that that was destined to claim the funds
/// @param timelock Block height after which the locked Ether can be refunded
/// @param v final byte of the signature
/// @param r second 32 bytes of the signature
/// @param r first 32 bytes of the signature
function refundCooperative(
bytes32 preimageHash,
uint amount,
address tokenAddress,
address claimAddress,
uint timelock,
uint8 v,
bytes32 r,
bytes32 s
) external {
address recoveredAddress = ecrecover(
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(
abi.encode(
TYPEHASH_REFUND,
preimageHash,
amount,
tokenAddress,
claimAddress,
timelock
)
)
)
),
v,
r,
s
);
require(recoveredAddress != address(0) && recoveredAddress == claimAddress, "ERC20Swap: invalid signature");

checkSwapIsLocked(hash);
delete swaps[hash];

emit Refund(preimageHash);

TransferHelper.safeTransferToken(tokenAddress, msg.sender, amount);
refundInternal(preimageHash, amount, tokenAddress, claimAddress, timelock);
}

// Public functions
Expand Down Expand Up @@ -222,6 +274,30 @@ contract ERC20Swap {

// Private functions

function refundInternal(
bytes32 preimageHash,
uint amount,
address tokenAddress,
address claimAddress,
uint timelock
) private {
bytes32 hash = hashValues(
preimageHash,
amount,
tokenAddress,
claimAddress,
msg.sender,
timelock
);

checkSwapIsLocked(hash);
delete swaps[hash];

emit Refund(preimageHash);

TransferHelper.safeTransferToken(tokenAddress, msg.sender, amount);
}

/// Checks whether a swap has tokens locked in the contract
/// @dev This function reverts if the swap has no tokens locked in the contract
/// @param hash Value hash of the swap
Expand Down
97 changes: 82 additions & 15 deletions contracts/EtherSwap.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import "./TransferHelper.sol";

// @title Hash timelock contract for Ether
contract EtherSwap {
// State variables
// Constants

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

bytes32 immutable public DOMAIN_SEPARATOR;
bytes32 immutable public TYPEHASH_REFUND;

// State variables

/// @dev Mapping between value hashes of swaps and whether they have Ether locked in the contract
mapping (bytes32 => bool) public swaps;
Expand All @@ -29,6 +34,21 @@ contract EtherSwap {

// Functions

constructor() {
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("EtherSwap"),
keccak256("3"),
block.chainid,
address(this)
)
);
TYPEHASH_REFUND = keccak256(
"Refund(bytes32 preimageHash,uint256 amount,address claimAddress,uint256 timeout)"
);
}

// External functions

/// Locks Ether for a swap in the contract
Expand Down Expand Up @@ -122,7 +142,7 @@ contract EtherSwap {
TransferHelper.transferEther(payable(claimAddress), amount);
}

/// Refunds Ether locked in the contract
/// Refunds Ether locked in the contract after the timeout
/// @dev To query the arguments of this function, get the "Lockup" event logs for your refund address and the preimage hash if you have it
/// @dev For further explanations and reasoning behind the statements in this function, check the "claim" function
/// @param preimageHash Preimage hash of the swap
Expand All @@ -138,21 +158,51 @@ contract EtherSwap {
// Make sure the timelock has expired already
// If the timelock is wrong, so will be the value hash of the swap which results in no swap being found
require(timelock <= block.number, "EtherSwap: swap has not timed out yet");
refundInternal(preimageHash, amount, claimAddress, timelock);
}

bytes32 hash = hashValues(
preimageHash,
amount,
claimAddress,
msg.sender,
timelock
/// Refunds Ether locked in the contract with an EIP-712 signature of the claimAddress
/// @dev To query the arguments of this function, get the "Lockup" event logs for your refund address and the preimage hash if you have it
/// @dev For further explanations and reasoning behind the statements in this function, check the "claim" function
/// @param preimageHash Preimage hash 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 timelock Block height after which the locked Ether can be refunded
/// @param v final byte of the signature
/// @param r second 32 bytes of the signature
/// @param r first 32 bytes of the signature
function refundCooperative(
bytes32 preimageHash,
uint amount,
address claimAddress,
uint timelock,
uint8 v,
bytes32 r,
bytes32 s
) external {
address recoveredAddress = ecrecover(
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(
abi.encode(
TYPEHASH_REFUND,
preimageHash,
amount,
claimAddress,
timelock
)
)
)
),
v,
r,
s
);
require(recoveredAddress != address(0) && recoveredAddress == claimAddress, "EtherSwap: invalid signature");

checkSwapIsLocked(hash);
delete swaps[hash];

emit Refund(preimageHash);

TransferHelper.transferEther(payable(msg.sender), amount);
refundInternal(preimageHash, amount, claimAddress, timelock);
}

// Public functions
Expand Down Expand Up @@ -211,6 +261,23 @@ contract EtherSwap {
emit Lockup(preimageHash, amount, claimAddress, msg.sender, timelock);
}

function refundInternal(bytes32 preimageHash, uint amount, address claimAddress, uint timelock) private {
bytes32 hash = hashValues(
preimageHash,
amount,
claimAddress,
msg.sender,
timelock
);

checkSwapIsLocked(hash);
delete swaps[hash];

emit Refund(preimageHash);

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

/// Checks whether a swap has Ether locked in the contract
/// @dev This function reverts if the swap has no Ether locked in the contract
/// @param hash Value hash of the swap
Expand Down
57 changes: 53 additions & 4 deletions contracts/test/ERC20SwapTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "./SigUtils.sol";
import "../BadERC20.sol";
import "../ERC20Swap.sol";
import "../TestERC20.sol";
import "forge-std/Test.sol";

contract ERC20SwapTest is Test {
event Lockup(
Expand All @@ -25,14 +26,22 @@ contract ERC20SwapTest is Test {
bytes32 internal preimage = sha256("");
bytes32 internal preimageHash = sha256(abi.encodePacked(preimage));
uint256 internal lockupAmount = 1 ether;
address payable internal claimAddress = payable(0xc6A63431BB6838289a41047602902381f14fa9c9);
uint256 internal claimAddressKey = 0xA11CE;
address internal claimAddress;

uint256 internal mintAmount = lockupAmount * 2;

IERC20 internal token = new TestERC20("TestERC20", "TRC", 18, mintAmount);

SigUtils internal sigUtils;

function setUp() public {
claimAddress = vm.addr(claimAddressKey);
sigUtils = new SigUtils(swap.DOMAIN_SEPARATOR(), swap.TYPEHASH_REFUND());
}

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

function testShouldNotAcceptEther() external {
Expand Down Expand Up @@ -195,6 +204,46 @@ contract ERC20SwapTest is Test {
swap.refund(preimageHash, lockupAmount, address(token), claimAddress, timelock);
}

function testRefundCooperativeFail() external {
uint256 timelock = block.number + 21;

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

uint256 balanceBeforeRefund = token.balanceOf(address(this));

(uint8 v, bytes32 r, bytes32 s) = vm.sign(
claimAddressKey,
sigUtils.getTypedDataHash(
sigUtils.hashERC20SwapRefund(preimageHash, lockupAmount, address(token), claimAddress, timelock)
)
);

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

swap.refundCooperative(preimageHash, lockupAmount, address(token), claimAddress, timelock, v, r, s);

assertFalse(querySwap(timelock));

assertEq(token.balanceOf(address(swap)), 0);
assertEq(token.balanceOf(address(this)) - balanceBeforeRefund, lockupAmount);
}

function testRefundCooperativeInvalidSigFail() external {
uint256 timelock = block.number + 21;

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

uint8 v = 1;
bytes32 r = keccak256("invalid");
bytes32 s = keccak256("sig");

vm.expectRevert("ERC20Swap: invalid signature");
swap.refundCooperative(preimageHash, lockupAmount, address(token), claimAddress, timelock, v, r, s);
}

function testBadERC20Token() external {
BadERC20 badToken = new BadERC20("TestERC20", "TRC", 18, mintAmount);
uint256 timelock = block.number;
Expand Down Expand Up @@ -229,7 +278,7 @@ contract ERC20SwapTest is Test {
vm.expectEmit(true, true, false, true, address(swap));
emit Lockup(preimageHash, lockupAmount, address(token), claimAddress, address(this), timelock);

swap.lockPrepayMinerfee{ value: prepayAmount }(preimageHash, lockupAmount, address(token), claimAddress, timelock);
swap.lockPrepayMinerfee{ value: prepayAmount }(preimageHash, lockupAmount, address(token), payable(claimAddress), timelock);

assertTrue(querySwap(timelock));
assertEq(claimAddress.balance, claimEthBalanceBefore + prepayAmount);
Expand Down

0 comments on commit 6ecdac7

Please sign in to comment.