diff --git a/contracts/EtomicSwapMakerNftV2.sol b/contracts/EtomicSwapMakerNftV2.sol new file mode 100644 index 0000000..e19ff33 --- /dev/null +++ b/contracts/EtomicSwapMakerNftV2.sol @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import "@openzeppelin/contracts/utils/introspection/ERC165.sol"; + +contract EtomicSwapMakerNftV2 is ERC165, IERC1155Receiver, IERC721Receiver { + enum MakerPaymentState { + Uninitialized, + PaymentSent, + TakerSpent, + MakerRefunded + } + + struct MakerPayment { + bytes20 paymentHash; + uint32 paymentLockTime; + MakerPaymentState state; + } + + event MakerPaymentSent(bytes32 id); + event MakerPaymentSpent(bytes32 id); + event MakerPaymentRefundedTimelock(bytes32 id); + event MakerPaymentRefundedSecret(bytes32 id); + + mapping(bytes32 => MakerPayment) public makerPayments; + + function spendErc721MakerPayment( + bytes32 id, + address maker, + bytes32 takerSecretHash, + bytes32 makerSecret, + address tokenAddress, + uint256 tokenId + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + // Check if the function caller is an externally owned account (EOA) + require(msg.sender == tx.origin, "Caller must be an EOA"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + msg.sender, + maker, + takerSecretHash, + sha256(abi.encodePacked(makerSecret)), + tokenAddress, + tokenId + ) + ); + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + // Effects + makerPayments[id].state = MakerPaymentState.TakerSpent; + + // Event Emission + emit MakerPaymentSpent(id); + + // Interactions + IERC721 token = IERC721(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId); + } + + function spendErc1155MakerPayment( + bytes32 id, + address maker, + bytes32 takerSecretHash, + bytes32 makerSecret, + address tokenAddress, + uint256 tokenId, + uint256 amount + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + // Check if the function caller is an externally owned account (EOA) + require(msg.sender == tx.origin, "Caller must be an EOA"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + msg.sender, + maker, + takerSecretHash, + sha256(abi.encodePacked(makerSecret)), + tokenAddress, + tokenId, + amount + ) + ); + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + // Effects + makerPayments[id].state = MakerPaymentState.TakerSpent; + + // Event Emission + emit MakerPaymentSpent(id); + + // Interactions + IERC1155 token = IERC1155(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId, amount, ""); + } + + function refundErc721MakerPaymentTimelock( + bytes32 id, + address taker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + address tokenAddress, + uint256 tokenId + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + taker, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress, + tokenId + ) + ); + + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + require( + block.timestamp >= makerPayments[id].paymentLockTime, + "Current timestamp didn't exceed payment refund lock time" + ); + + makerPayments[id].state = MakerPaymentState.MakerRefunded; + + emit MakerPaymentRefundedTimelock(id); + + IERC721 token = IERC721(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId); + } + + function refundErc1155MakerPaymentTimelock( + bytes32 id, + address taker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + address tokenAddress, + uint256 tokenId, + uint256 amount + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + taker, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress, + tokenId, + amount + ) + ); + + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + require( + block.timestamp >= makerPayments[id].paymentLockTime, + "Current timestamp didn't exceed payment refund lock time" + ); + + makerPayments[id].state = MakerPaymentState.MakerRefunded; + + emit MakerPaymentRefundedTimelock(id); + + // Interactions + IERC1155 token = IERC1155(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId, amount, ""); + } + + function refundErc721MakerPaymentSecret( + bytes32 id, + address taker, + bytes32 takerSecret, + bytes32 makerSecretHash, + address tokenAddress, + uint256 tokenId + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + taker, + msg.sender, + sha256(abi.encodePacked(takerSecret)), + makerSecretHash, + tokenAddress, + tokenId + ) + ); + + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + makerPayments[id].state = MakerPaymentState.MakerRefunded; + + emit MakerPaymentRefundedSecret(id); + + IERC721 token = IERC721(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId); + } + + function refundErc1155MakerPaymentSecret( + bytes32 id, + address taker, + bytes32 takerSecret, + bytes32 makerSecretHash, + address tokenAddress, + uint256 tokenId, + uint256 amount + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + taker, + msg.sender, + sha256(abi.encodePacked(takerSecret)), + makerSecretHash, + tokenAddress, + tokenId, + amount + ) + ); + + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + makerPayments[id].state = MakerPaymentState.MakerRefunded; + + emit MakerPaymentRefundedSecret(id); + + IERC1155 token = IERC1155(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId, amount, ""); + } + + struct HTLCParams { + bytes32 id; + address taker; + address tokenAddress; + bytes32 takerSecretHash; + bytes32 makerSecretHash; + uint32 paymentLockTime; + } + + function onERC1155Received( + address operator, + address from, + uint256 tokenId, + uint256 value, + bytes calldata data + ) external override returns (bytes4) { + // Decode the data to extract HTLC parameters + HTLCParams memory params = abi.decode(data, (HTLCParams)); + + require( + makerPayments[params.id].state == MakerPaymentState.Uninitialized, + "Maker ERC1155 payment must be Uninitialized" + ); + require(params.taker != address(0), "Taker must not be zero address"); + require( + params.tokenAddress != address(0), + "Token must not be zero address" + ); + require( + msg.sender == params.tokenAddress, + "Token address does not match sender" + ); + require(operator == from, "Operator must be the sender"); + require(value > 0, "Value must be greater than 0"); + require(!isContract(params.taker), "Taker cannot be a contract"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + params.taker, + from, + params.takerSecretHash, + params.makerSecretHash, + params.tokenAddress, + tokenId, + value + ) + ); + + makerPayments[params.id] = MakerPayment( + paymentHash, + params.paymentLockTime, + MakerPaymentState.PaymentSent + ); + emit MakerPaymentSent(params.id); + + // Return this magic value to confirm receipt of ERC1155 token + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, /* operator */ + address, /* from */ + uint256[] calldata, /* ids */ + uint256[] calldata, /* values */ + bytes calldata /* data */ + ) external pure override returns (bytes4) { + revert("Batch transfers not supported"); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC165, IERC165) + returns (bool) + { + return + interfaceId == type(IERC1155Receiver).interfaceId || + interfaceId == type(IERC721Receiver).interfaceId || + super.supportsInterface(interfaceId); + } + + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external override returns (bytes4) { + // Decode the data to extract HTLC parameters + HTLCParams memory params = abi.decode(data, (HTLCParams)); + + require( + makerPayments[params.id].state == MakerPaymentState.Uninitialized, + "Maker ERC721 payment must be Uninitialized" + ); + require(params.taker != address(0), "Taker must not be zero address"); + require( + params.tokenAddress != address(0), + "Token must not be zero address" + ); + require( + msg.sender == params.tokenAddress, + "Token address does not match sender" + ); + require(operator == from, "Operator must be the sender"); + require(!isContract(params.taker), "Taker cannot be a contract"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + params.taker, + from, + params.takerSecretHash, + params.makerSecretHash, + params.tokenAddress, + tokenId + ) + ); + + makerPayments[params.id] = MakerPayment( + paymentHash, + params.paymentLockTime, + MakerPaymentState.PaymentSent + ); + emit MakerPaymentSent(params.id); + + // Return this magic value to confirm receipt of ERC721 token + return this.onERC721Received.selector; + } + + function isContract(address account) internal view returns (bool) { + uint256 size; + assembly { + size := extcodesize(account) + } + return size > 0; + } +} diff --git a/contracts/EtomicSwapMakerV2.sol b/contracts/EtomicSwapMakerV2.sol new file mode 100644 index 0000000..4b9755a --- /dev/null +++ b/contracts/EtomicSwapMakerV2.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract EtomicSwapMakerV2 { + using SafeERC20 for IERC20; + + enum MakerPaymentState { + Uninitialized, + PaymentSent, + TakerSpent, + MakerRefunded + } + + struct MakerPayment { + bytes20 paymentHash; + uint32 paymentLockTime; + MakerPaymentState state; + } + + event MakerPaymentSent(bytes32 id); + event MakerPaymentSpent(bytes32 id); + event MakerPaymentRefundedTimelock(bytes32 id); + event MakerPaymentRefundedSecret(bytes32 id); + + mapping(bytes32 => MakerPayment) public makerPayments; + + function ethMakerPayment( + bytes32 id, + address taker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + uint32 paymentLockTime + ) external payable { + require( + makerPayments[id].state == MakerPaymentState.Uninitialized, + "Maker payment is already initialized" + ); + require(taker != address(0), "Taker must not be zero address"); + require(msg.value > 0, "ETH value must be greater than zero"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + msg.value, + taker, + msg.sender, + takerSecretHash, + makerSecretHash, + address(0) + ) + ); + + makerPayments[id] = MakerPayment( + paymentHash, + paymentLockTime, + MakerPaymentState.PaymentSent + ); + + emit MakerPaymentSent(id); + } + + function erc20MakerPayment( + bytes32 id, + uint256 amount, + address tokenAddress, + address taker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + uint32 paymentLockTime + ) external { + require( + makerPayments[id].state == MakerPaymentState.Uninitialized, + "Maker payment is already initialized" + ); + require(amount > 0, "Amount must not be zero"); + require(taker != address(0), "Taker must not be zero address"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + taker, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress + ) + ); + + makerPayments[id] = MakerPayment( + paymentHash, + paymentLockTime, + MakerPaymentState.PaymentSent + ); + + emit MakerPaymentSent(id); + + // Now performing the external interaction + IERC20 token = IERC20(tokenAddress); + token.safeTransferFrom(msg.sender, address(this), amount); + } + + function spendMakerPayment( + bytes32 id, + uint256 amount, + address maker, + bytes32 takerSecretHash, + bytes32 makerSecret, + address tokenAddress + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + msg.sender, + maker, + takerSecretHash, + sha256(abi.encodePacked(makerSecret)), + tokenAddress + ) + ); + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + makerPayments[id].state = MakerPaymentState.TakerSpent; + + emit MakerPaymentSpent(id); + + if (tokenAddress == address(0)) { + payable(msg.sender).transfer(amount); + } else { + IERC20 token = IERC20(tokenAddress); + token.safeTransfer(msg.sender, amount); + } + } + + function refundMakerPaymentTimelock( + bytes32 id, + uint256 amount, + address taker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + address tokenAddress + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + taker, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress + ) + ); + + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + require( + block.timestamp >= makerPayments[id].paymentLockTime, + "Current timestamp didn't exceed payment refund lock time" + ); + + makerPayments[id].state = MakerPaymentState.MakerRefunded; + + emit MakerPaymentRefundedTimelock(id); + + if (tokenAddress == address(0)) { + payable(msg.sender).transfer(amount); + } else { + IERC20 token = IERC20(tokenAddress); + token.safeTransfer(msg.sender, amount); + } + } + + function refundMakerPaymentSecret( + bytes32 id, + uint256 amount, + address taker, + bytes32 takerSecret, + bytes32 makerSecretHash, + address tokenAddress + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + taker, + msg.sender, + sha256(abi.encodePacked(takerSecret)), + makerSecretHash, + tokenAddress + ) + ); + + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + makerPayments[id].state = MakerPaymentState.MakerRefunded; + + emit MakerPaymentRefundedSecret(id); + + if (tokenAddress == address(0)) { + payable(msg.sender).transfer(amount); + } else { + IERC20 token = IERC20(tokenAddress); + token.safeTransfer(msg.sender, amount); + } + } +} diff --git a/contracts/EtomicSwapNft.sol b/contracts/EtomicSwapNft.sol new file mode 100644 index 0000000..2229ddd --- /dev/null +++ b/contracts/EtomicSwapNft.sol @@ -0,0 +1,720 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import "@openzeppelin/contracts/utils/introspection/ERC165.sol"; + +contract EtomicSwapNft is ERC165, IERC1155Receiver, IERC721Receiver { + using SafeERC20 for IERC20; + + enum MakerPaymentState { + Uninitialized, + PaymentSent, + TakerSpent, + MakerRefunded + } + + struct MakerPayment { + bytes20 paymentHash; + uint32 paymentLockTime; + MakerPaymentState state; + } + + event MakerPaymentSent(bytes32 id); + event MakerPaymentSpent(bytes32 id); + event MakerPaymentRefundedTimelock(bytes32 id); + event MakerPaymentRefundedSecret(bytes32 id); + + mapping(bytes32 => MakerPayment) public makerPayments; + + enum TakerPaymentState { + Uninitialized, + PaymentSent, + TakerApproved, + MakerSpent, + TakerRefunded + } + + struct TakerPayment { + bytes20 paymentHash; + uint32 preApproveLockTime; + uint32 paymentLockTime; + TakerPaymentState state; + } + + event TakerPaymentSent(bytes32 id); + event TakerPaymentApproved(bytes32 id); + event TakerPaymentSpent(bytes32 id, bytes32 secret); + event TakerPaymentRefundedSecret(bytes32 id, bytes32 secret); + event TakerPaymentRefundedTimelock(bytes32 id); + + mapping(bytes32 => TakerPayment) public takerPayments; + + address public immutable dexFeeAddress; + + constructor(address feeAddress) { + require( + feeAddress != address(0), + "feeAddress must not be zero address" + ); + + dexFeeAddress = feeAddress; + } + + function spendErc721MakerPayment( + bytes32 id, + address maker, + bytes32 takerSecretHash, + bytes32 makerSecret, + address tokenAddress, + uint256 tokenId + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + // Check if the function caller is an externally owned account (EOA) + require(msg.sender == tx.origin, "Caller must be an EOA"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + msg.sender, + maker, + takerSecretHash, + sha256(abi.encodePacked(makerSecret)), + tokenAddress, + tokenId + ) + ); + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + // Effects + makerPayments[id].state = MakerPaymentState.TakerSpent; + + // Event Emission + emit MakerPaymentSpent(id); + + // Interactions + IERC721 token = IERC721(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId); + } + + function spendErc1155MakerPayment( + bytes32 id, + address maker, + bytes32 takerSecretHash, + bytes32 makerSecret, + address tokenAddress, + uint256 tokenId, + uint256 amount + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + // Check if the function caller is an externally owned account (EOA) + require(msg.sender == tx.origin, "Caller must be an EOA"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + msg.sender, + maker, + takerSecretHash, + sha256(abi.encodePacked(makerSecret)), + tokenAddress, + tokenId, + amount + ) + ); + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + // Effects + makerPayments[id].state = MakerPaymentState.TakerSpent; + + // Event Emission + emit MakerPaymentSpent(id); + + // Interactions + IERC1155 token = IERC1155(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId, amount, ""); + } + + function refundErc721MakerPaymentTimelock( + bytes32 id, + address taker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + address tokenAddress, + uint256 tokenId + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + taker, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress, + tokenId + ) + ); + + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + require( + block.timestamp >= makerPayments[id].paymentLockTime, + "Current timestamp didn't exceed payment refund lock time" + ); + + makerPayments[id].state = MakerPaymentState.MakerRefunded; + + emit MakerPaymentRefundedTimelock(id); + + IERC721 token = IERC721(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId); + } + + function refundErc1155MakerPaymentTimelock( + bytes32 id, + address taker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + address tokenAddress, + uint256 tokenId, + uint256 amount + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + taker, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress, + tokenId, + amount + ) + ); + + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + require( + block.timestamp >= makerPayments[id].paymentLockTime, + "Current timestamp didn't exceed payment refund lock time" + ); + + makerPayments[id].state = MakerPaymentState.MakerRefunded; + + emit MakerPaymentRefundedTimelock(id); + + // Interactions + IERC1155 token = IERC1155(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId, amount, ""); + } + + function refundErc721MakerPaymentSecret( + bytes32 id, + address taker, + bytes32 takerSecret, + bytes32 makerSecretHash, + address tokenAddress, + uint256 tokenId + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + taker, + msg.sender, + sha256(abi.encodePacked(takerSecret)), + makerSecretHash, + tokenAddress, + tokenId + ) + ); + + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + makerPayments[id].state = MakerPaymentState.MakerRefunded; + + emit MakerPaymentRefundedSecret(id); + + IERC721 token = IERC721(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId); + } + + function refundErc1155MakerPaymentSecret( + bytes32 id, + address taker, + bytes32 takerSecret, + bytes32 makerSecretHash, + address tokenAddress, + uint256 tokenId, + uint256 amount + ) external { + require( + makerPayments[id].state == MakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + taker, + msg.sender, + sha256(abi.encodePacked(takerSecret)), + makerSecretHash, + tokenAddress, + tokenId, + amount + ) + ); + + require( + paymentHash == makerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + makerPayments[id].state = MakerPaymentState.MakerRefunded; + + emit MakerPaymentRefundedSecret(id); + + IERC1155 token = IERC1155(tokenAddress); + token.safeTransferFrom(address(this), msg.sender, tokenId, amount, ""); + } + + function ethTakerPayment( + bytes32 id, + uint256 dexFee, + address receiver, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + uint32 preApproveLockTime, + uint32 paymentLockTime + ) external payable { + require( + takerPayments[id].state == TakerPaymentState.Uninitialized, + "Taker payment is already initialized" + ); + require(receiver != address(0), "Receiver must not be zero address"); + require(msg.value > 0, "ETH value must be greater than zero"); + require(msg.value > dexFee, "ETH value must be greater than dex fee"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + msg.value - dexFee, + dexFee, + receiver, + msg.sender, + takerSecretHash, + makerSecretHash, + address(0) + ) + ); + + takerPayments[id] = TakerPayment( + paymentHash, + preApproveLockTime, + paymentLockTime, + TakerPaymentState.PaymentSent + ); + + emit TakerPaymentSent(id); + } + + function erc20TakerPayment( + bytes32 id, + uint256 amount, + uint256 dexFee, + address tokenAddress, + address receiver, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + uint32 preApproveLockTime, + uint32 paymentLockTime + ) external { + require( + takerPayments[id].state == TakerPaymentState.Uninitialized, + "ERC20 v2 payment is already initialized" + ); + require(amount > 0, "Amount must not be zero"); + require(dexFee > 0, "Dex fee must not be zero"); + require(receiver != address(0), "Receiver must not be zero address"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + dexFee, + receiver, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress + ) + ); + + takerPayments[id] = TakerPayment( + paymentHash, + preApproveLockTime, + paymentLockTime, + TakerPaymentState.PaymentSent + ); + + emit TakerPaymentSent(id); + + // Now performing the external interaction + IERC20 token = IERC20(tokenAddress); + token.safeTransferFrom(msg.sender, address(this), amount + dexFee); + } + + function takerPaymentApprove( + bytes32 id, + uint256 amount, + uint256 dexFee, + address maker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + address tokenAddress + ) external { + require( + takerPayments[id].state == TakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + dexFee, + maker, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress + ) + ); + + require( + paymentHash == takerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + takerPayments[id].state = TakerPaymentState.TakerApproved; + + emit TakerPaymentApproved(id); + } + + function spendTakerPayment( + bytes32 id, + uint256 amount, + uint256 dexFee, + address taker, + bytes32 takerSecretHash, + bytes32 makerSecret, + address tokenAddress + ) external { + require( + takerPayments[id].state == TakerPaymentState.TakerApproved, + "Invalid payment state. Must be TakerApproved" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + dexFee, + msg.sender, + taker, + takerSecretHash, + sha256(abi.encodePacked(makerSecret)), + tokenAddress + ) + ); + require( + paymentHash == takerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + takerPayments[id].state = TakerPaymentState.MakerSpent; + + emit TakerPaymentSpent(id, makerSecret); + + if (tokenAddress == address(0)) { + payable(msg.sender).transfer(amount); + payable(dexFeeAddress).transfer(dexFee); + } else { + IERC20 token = IERC20(tokenAddress); + token.safeTransfer(msg.sender, amount); + token.safeTransfer(dexFeeAddress, dexFee); + } + } + + function refundTakerPaymentTimelock( + bytes32 id, + uint256 amount, + uint256 dexFee, + address maker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + address tokenAddress + ) external { + require( + takerPayments[id].state == TakerPaymentState.PaymentSent || + takerPayments[id].state == TakerPaymentState.TakerApproved, + "Invalid payment state. Must be PaymentSent or TakerApproved" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + dexFee, + maker, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress + ) + ); + + require( + paymentHash == takerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + if (takerPayments[id].state == TakerPaymentState.TakerApproved) { + require( + block.timestamp >= takerPayments[id].paymentLockTime, + "Current timestamp didn't exceed payment refund lock time" + ); + } + + if (takerPayments[id].state == TakerPaymentState.PaymentSent) { + require( + block.timestamp >= takerPayments[id].preApproveLockTime, + "Current timestamp didn't exceed payment pre-approve lock time" + ); + } + + takerPayments[id].state = TakerPaymentState.TakerRefunded; + + emit TakerPaymentRefundedTimelock(id); + + uint256 total_amount = amount + dexFee; + if (tokenAddress == address(0)) { + payable(msg.sender).transfer(total_amount); + } else { + IERC20 token = IERC20(tokenAddress); + token.safeTransfer(msg.sender, total_amount); + } + } + + function refundTakerPaymentSecret( + bytes32 id, + uint256 amount, + uint256 dexFee, + address maker, + bytes32 takerSecret, + bytes32 makerSecretHash, + address tokenAddress + ) external { + require( + takerPayments[id].state == TakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + dexFee, + maker, + msg.sender, + sha256(abi.encodePacked(takerSecret)), + makerSecretHash, + tokenAddress + ) + ); + + require( + paymentHash == takerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + takerPayments[id].state = TakerPaymentState.TakerRefunded; + + emit TakerPaymentRefundedSecret(id, takerSecret); + + uint256 total_amount = amount + dexFee; + if (tokenAddress == address(0)) { + payable(msg.sender).transfer(total_amount); + } else { + IERC20 token = IERC20(tokenAddress); + token.safeTransfer(msg.sender, total_amount); + } + } + + struct HTLCParams { + bytes32 id; + address taker; + address tokenAddress; + bytes32 takerSecretHash; + bytes32 makerSecretHash; + uint32 paymentLockTime; + } + + function onERC1155Received( + address operator, + address from, + uint256 tokenId, + uint256 value, + bytes calldata data + ) external override returns (bytes4) { + // Decode the data to extract HTLC parameters + HTLCParams memory params = abi.decode(data, (HTLCParams)); + + require( + makerPayments[params.id].state == MakerPaymentState.Uninitialized, + "Maker ERC1155 payment must be Uninitialized" + ); + require(params.taker != address(0), "Taker must not be zero address"); + require( + params.tokenAddress != address(0), + "Token must not be zero address" + ); + require( + msg.sender == params.tokenAddress, + "Token address does not match sender" + ); + require(operator == from, "Operator must be the sender"); + require(value > 0, "Value must be greater than 0"); + require(!isContract(params.taker), "Taker cannot be a contract"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + params.taker, + from, + params.takerSecretHash, + params.makerSecretHash, + params.tokenAddress, + tokenId, + value + ) + ); + + makerPayments[params.id] = MakerPayment( + paymentHash, + params.paymentLockTime, + MakerPaymentState.PaymentSent + ); + emit MakerPaymentSent(params.id); + + // Return this magic value to confirm receipt of ERC1155 token + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, /* operator */ + address, /* from */ + uint256[] calldata, /* ids */ + uint256[] calldata, /* values */ + bytes calldata /* data */ + ) external pure override returns (bytes4) { + revert("Batch transfers not supported"); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC165, IERC165) + returns (bool) + { + return + interfaceId == type(IERC1155Receiver).interfaceId || + interfaceId == type(IERC721Receiver).interfaceId || + super.supportsInterface(interfaceId); + } + + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external override returns (bytes4) { + // Decode the data to extract HTLC parameters + HTLCParams memory params = abi.decode(data, (HTLCParams)); + + require( + makerPayments[params.id].state == MakerPaymentState.Uninitialized, + "Maker ERC721 payment must be Uninitialized" + ); + require(params.taker != address(0), "Taker must not be zero address"); + require( + params.tokenAddress != address(0), + "Token must not be zero address" + ); + require( + msg.sender == params.tokenAddress, + "Token address does not match sender" + ); + require(operator == from, "Operator must be the sender"); + require(!isContract(params.taker), "Taker cannot be a contract"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + params.taker, + from, + params.takerSecretHash, + params.makerSecretHash, + params.tokenAddress, + tokenId + ) + ); + + makerPayments[params.id] = MakerPayment( + paymentHash, + params.paymentLockTime, + MakerPaymentState.PaymentSent + ); + emit MakerPaymentSent(params.id); + + // Return this magic value to confirm receipt of ERC721 token + return this.onERC721Received.selector; + } + + function isContract(address account) internal view returns (bool) { + uint256 size; + assembly { + size := extcodesize(account) + } + return size > 0; + } +} diff --git a/contracts/EtomicSwapTakerV2.sol b/contracts/EtomicSwapTakerV2.sol new file mode 100644 index 0000000..71e8f3b --- /dev/null +++ b/contracts/EtomicSwapTakerV2.sol @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract EtomicSwapTakerV2 { + using SafeERC20 for IERC20; + + enum TakerPaymentState { + Uninitialized, + PaymentSent, + TakerApproved, + MakerSpent, + TakerRefunded + } + + struct TakerPayment { + bytes20 paymentHash; + uint32 preApproveLockTime; + uint32 paymentLockTime; + TakerPaymentState state; + } + + event TakerPaymentSent(bytes32 id); + event TakerPaymentApproved(bytes32 id); + event TakerPaymentSpent(bytes32 id, bytes32 secret); + event TakerPaymentRefundedSecret(bytes32 id, bytes32 secret); + event TakerPaymentRefundedTimelock(bytes32 id); + + mapping(bytes32 => TakerPayment) public takerPayments; + + address public immutable dexFeeAddress; + + constructor(address feeAddress) { + require( + feeAddress != address(0), + "feeAddress must not be zero address" + ); + + dexFeeAddress = feeAddress; + } + + function ethTakerPayment( + bytes32 id, + uint256 dexFee, + address receiver, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + uint32 preApproveLockTime, + uint32 paymentLockTime + ) external payable { + require( + takerPayments[id].state == TakerPaymentState.Uninitialized, + "Taker payment is already initialized" + ); + require(receiver != address(0), "Receiver must not be zero address"); + require(msg.value > 0, "ETH value must be greater than zero"); + require(msg.value > dexFee, "ETH value must be greater than dex fee"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + msg.value - dexFee, + dexFee, + receiver, + msg.sender, + takerSecretHash, + makerSecretHash, + address(0) + ) + ); + + takerPayments[id] = TakerPayment( + paymentHash, + preApproveLockTime, + paymentLockTime, + TakerPaymentState.PaymentSent + ); + + emit TakerPaymentSent(id); + } + + function erc20TakerPayment( + bytes32 id, + uint256 amount, + uint256 dexFee, + address tokenAddress, + address receiver, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + uint32 preApproveLockTime, + uint32 paymentLockTime + ) external { + require( + takerPayments[id].state == TakerPaymentState.Uninitialized, + "ERC20 v2 payment is already initialized" + ); + require(amount > 0, "Amount must not be zero"); + require(dexFee > 0, "Dex fee must not be zero"); + require(receiver != address(0), "Receiver must not be zero address"); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + dexFee, + receiver, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress + ) + ); + + takerPayments[id] = TakerPayment( + paymentHash, + preApproveLockTime, + paymentLockTime, + TakerPaymentState.PaymentSent + ); + + emit TakerPaymentSent(id); + + // Now performing the external interaction + IERC20 token = IERC20(tokenAddress); + token.safeTransferFrom(msg.sender, address(this), amount + dexFee); + } + + function takerPaymentApprove( + bytes32 id, + uint256 amount, + uint256 dexFee, + address maker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + address tokenAddress + ) external { + require( + takerPayments[id].state == TakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + dexFee, + maker, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress + ) + ); + + require( + paymentHash == takerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + takerPayments[id].state = TakerPaymentState.TakerApproved; + + emit TakerPaymentApproved(id); + } + + function spendTakerPayment( + bytes32 id, + uint256 amount, + uint256 dexFee, + address taker, + bytes32 takerSecretHash, + bytes32 makerSecret, + address tokenAddress + ) external { + require( + takerPayments[id].state == TakerPaymentState.TakerApproved, + "Invalid payment state. Must be TakerApproved" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + dexFee, + msg.sender, + taker, + takerSecretHash, + sha256(abi.encodePacked(makerSecret)), + tokenAddress + ) + ); + require( + paymentHash == takerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + takerPayments[id].state = TakerPaymentState.MakerSpent; + + emit TakerPaymentSpent(id, makerSecret); + + if (tokenAddress == address(0)) { + payable(msg.sender).transfer(amount); + payable(dexFeeAddress).transfer(dexFee); + } else { + IERC20 token = IERC20(tokenAddress); + token.safeTransfer(msg.sender, amount); + token.safeTransfer(dexFeeAddress, dexFee); + } + } + + function refundTakerPaymentTimelock( + bytes32 id, + uint256 amount, + uint256 dexFee, + address maker, + bytes32 takerSecretHash, + bytes32 makerSecretHash, + address tokenAddress + ) external { + require( + takerPayments[id].state == TakerPaymentState.PaymentSent || + takerPayments[id].state == TakerPaymentState.TakerApproved, + "Invalid payment state. Must be PaymentSent or TakerApproved" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + dexFee, + maker, + msg.sender, + takerSecretHash, + makerSecretHash, + tokenAddress + ) + ); + + require( + paymentHash == takerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + if (takerPayments[id].state == TakerPaymentState.TakerApproved) { + require( + block.timestamp >= takerPayments[id].paymentLockTime, + "Current timestamp didn't exceed payment refund lock time" + ); + } + + if (takerPayments[id].state == TakerPaymentState.PaymentSent) { + require( + block.timestamp >= takerPayments[id].preApproveLockTime, + "Current timestamp didn't exceed payment pre-approve lock time" + ); + } + + takerPayments[id].state = TakerPaymentState.TakerRefunded; + + emit TakerPaymentRefundedTimelock(id); + + uint256 total_amount = amount + dexFee; + if (tokenAddress == address(0)) { + payable(msg.sender).transfer(total_amount); + } else { + IERC20 token = IERC20(tokenAddress); + token.safeTransfer(msg.sender, total_amount); + } + } + + function refundTakerPaymentSecret( + bytes32 id, + uint256 amount, + uint256 dexFee, + address maker, + bytes32 takerSecret, + bytes32 makerSecretHash, + address tokenAddress + ) external { + require( + takerPayments[id].state == TakerPaymentState.PaymentSent, + "Invalid payment state. Must be PaymentSent" + ); + + bytes20 paymentHash = ripemd160( + abi.encodePacked( + amount, + dexFee, + maker, + msg.sender, + sha256(abi.encodePacked(takerSecret)), + makerSecretHash, + tokenAddress + ) + ); + + require( + paymentHash == takerPayments[id].paymentHash, + "Invalid paymentHash" + ); + + takerPayments[id].state = TakerPaymentState.TakerRefunded; + + emit TakerPaymentRefundedSecret(id, takerSecret); + + uint256 total_amount = amount + dexFee; + if (tokenAddress == address(0)) { + payable(msg.sender).transfer(total_amount); + } else { + IERC20 token = IERC20(tokenAddress); + token.safeTransfer(msg.sender, total_amount); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index a8e6443..2d22927 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,3 +7,5 @@ services: tty: true volumes: - ./:/usr/src/workspace + ports: + - "8545:8545" diff --git a/hardhat.config.js b/hardhat.config.js index 34e686b..2585a2c 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -4,7 +4,9 @@ module.exports = { solidity: "0.8.20", networks: { hardhat: { - // Hardhat Network's default settings are fine for most projects + chainId: 1337, // or another number that Remix will accept + host: "0.0.0.0", // Listen on all network interfaces + port: 8545 // Ensure this matches the port exposed in docker-compose } } }; diff --git a/test/EtomicSwap.js b/test/EtomicSwap.js index 8673bd4..094971d 100644 --- a/test/EtomicSwap.js +++ b/test/EtomicSwap.js @@ -59,19 +59,19 @@ describe("EtomicSwap", function() { EtomicSwap = await ethers.getContractFactory("EtomicSwap"); etomicSwap = await EtomicSwap.deploy(); - etomicSwap.waitForDeployment(); + await etomicSwap.waitForDeployment(); Token = await ethers.getContractFactory("Token"); token = await Token.deploy(); - token.waitForDeployment(); + await token.waitForDeployment(); Erc721Token = await ethers.getContractFactory("Erc721Token"); erc721token = await Erc721Token.deploy("MyNFT", "MNFT"); - erc721token.waitForDeployment(); + await erc721token.waitForDeployment(); Erc1155Token = await ethers.getContractFactory("Erc1155Token"); erc1155token = await Erc1155Token.deploy("uri"); - erc1155token.waitForDeployment(); + await erc1155token.waitForDeployment(); await token.transfer(accounts[1].address, ethers.parseEther('100')); }); diff --git a/test/EtomicSwapMakerNftV2Test.js b/test/EtomicSwapMakerNftV2Test.js new file mode 100644 index 0000000..b0512a2 --- /dev/null +++ b/test/EtomicSwapMakerNftV2Test.js @@ -0,0 +1,487 @@ +const { + expect +} = require("chai"); +const { + ethers +} = require("hardhat"); +const crypto = require('crypto'); +const {AbiCoder} = require("ethers"); + +require('chai') + .use(require('chai-as-promised')) + .should(); + +const INVALID_HASH = 'Invalid paymentHash'; +const INVALID_PAYMENT_STATE_SENT = 'Invalid payment state. Must be PaymentSent'; +const REFUND_TIMESTAMP_NOT_REACHED = 'Current timestamp didn\'t exceed payment refund lock time'; + +/** + * Advances the Ethereum Virtual Machine (EVM) time by a specified amount and then mines a new block. + * + * @param {number} increaseAmount The amount of time to advance in seconds. + * + * This function is used in Ethereum smart contract testing to simulate the passage of time. In the EVM, + * time is measured based on block timestamps. The 'evm_increaseTime' method increases the EVM's internal + * clock, but this change only affects the next mined block. Therefore, 'evm_mine' is called immediately + * afterwards to mine a new block, ensuring that the blockchain's timestamp is updated to reflect the time + * change. This approach is essential for testing time-dependent contract features like lock periods or deadlines. + */ +async function advanceTimeAndMine(increaseAmount) { + await ethers.provider.send("evm_increaseTime", [increaseAmount]); + await ethers.provider.send("evm_mine"); +} + +async function currentEvmTime() { + const block = await ethers.provider.getBlock("latest"); + return block.timestamp; +} + +const id = '0x' + crypto.randomBytes(32).toString('hex'); +const [MAKER_PAYMENT_UNINITIALIZED, MAKER_PAYMENT_SENT, TAKER_SPENT, MAKER_REFUNDED] = [0, 1, 2, 3]; + +const takerSecret = crypto.randomBytes(32); +const takerSecretHash = '0x' + crypto.createHash('sha256').update(takerSecret).digest('hex'); + +const makerSecret = crypto.randomBytes(32); +const makerSecretHash = '0x' + crypto.createHash('sha256').update(makerSecret).digest('hex'); + +const invalidSecret = crypto.randomBytes(32); + +describe("EtomicSwapMakerNftV2", function() { + + beforeEach(async function() { + accounts = await ethers.getSigners(); + + EtomicSwapMakerNftV2 = await ethers.getContractFactory("EtomicSwapMakerNftV2"); + etomicSwapMakerNftV2 = await EtomicSwapMakerNftV2.deploy(); + await etomicSwapMakerNftV2.waitForDeployment(); + + Token = await ethers.getContractFactory("Token"); + token = await Token.deploy(); + await token.waitForDeployment(); + + Erc721Token = await ethers.getContractFactory("Erc721Token"); + erc721token = await Erc721Token.deploy("MyNFT", "MNFT"); + await erc721token.waitForDeployment(); + + Erc1155Token = await ethers.getContractFactory("Erc1155Token"); + erc1155token = await Erc1155Token.deploy("uri"); + await erc1155token.waitForDeployment(); + + await token.transfer(accounts[1].address, ethers.parseEther('100')); + }); + + it('should create contract with uninitialized payments', async function() { + const makerPayment = await etomicSwapMakerNftV2.makerPayments(id); + expect(Number(makerPayment[2])).to.equal(MAKER_PAYMENT_UNINITIALIZED); + }); + + it('should allow maker to send ERC721 payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const tokenId = 1; // Assuming token ID 1 is minted to accounts[0] in Erc721Token contract + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc721token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + + const makerErc721Runner0 = erc721token.connect(accounts[0]); + + // Make the Maker ERC721 payment. Call safeTransferFrom directly to transfer the token to the etomicSwapMakerNftV2 contract. + // Explicitly specify the method signature. + const tx = await makerErc721Runner0['safeTransferFrom(address,address,uint256,bytes)'](accounts[0].address, etomicSwapMakerNftV2.target, tokenId, data).should.be.fulfilled; + const receipt = await tx.wait(); + console.log(`Gas Used: ${receipt.gasUsed.toString()}`); + + // Check the payment lockTime and state + const payment = await etomicSwapMakerNftV2.makerPayments(id); + expect(Number(payment[1])).to.equal(paymentLockTime); + expect(Number(payment[2])).to.equal(MAKER_PAYMENT_SENT); + + // Should not allow to send again ( reverted with custom error ERC721InsufficientApproval ) + await expect(makerErc721Runner0['safeTransferFrom(address,address,uint256,bytes)'](accounts[0].address, etomicSwapMakerNftV2.target, tokenId, data)).to.be.rejectedWith("ERC721InsufficientApproval"); + }); + + it('should allow maker to send ERC1155 payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const tokenId = 1; // Token ID used in Erc1155Token contract + const amountToSend = 2; // Amount of tokens to send + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + + const makerErc1155Runner0 = erc1155token.connect(accounts[0]); + + // Make the Maker ERC1155 payment. Call safeTransferFrom directly to transfer the token to the etomicSwapMakerNftV2 contract. + const tx = await makerErc1155Runner0.safeTransferFrom(accounts[0].address, etomicSwapMakerNftV2.target, tokenId, amountToSend, data).should.be.fulfilled; + const receipt = await tx.wait(); + console.log(`Gas Used: ${receipt.gasUsed.toString()}`); + + // Check the payment lockTime and state + const payment = await etomicSwapMakerNftV2.makerPayments(id); + expect(Number(payment[1])).to.equal(paymentLockTime); + expect(Number(payment[2])).to.equal(MAKER_PAYMENT_SENT); + + // Check the balance of the token in the swap contract + const tokenBalance = await erc1155token.balanceOf(etomicSwapMakerNftV2.target, tokenId); + expect(tokenBalance).to.equal(BigInt(amountToSend)); + + // Check sending same params again - should fail + await expect(makerErc1155Runner0.safeTransferFrom(accounts[0].address, etomicSwapMakerNftV2.target, tokenId, amountToSend, data)).to.be.rejectedWith("ERC1155InsufficientBalance"); + + // Maker should be capable to send more tokens, if they have it. Note: Check Erc1155.sol file. By default, ERC1155 is minted with 3 value. + const id1 = '0x' + crypto.randomBytes(32).toString('hex'); + const data1 = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id1, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + await makerErc1155Runner0.safeTransferFrom(accounts[0].address, etomicSwapMakerNftV2.target, tokenId, 1, data1).should.be.fulfilled; + + // Check sending more tokens than the sender owns - should fail + const id2 = '0x' + crypto.randomBytes(32).toString('hex'); + const data2 = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id2, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + await expect(makerErc1155Runner0.safeTransferFrom(accounts[0].address, etomicSwapMakerNftV2.target, tokenId, amountToSend, data2)).to.be.rejectedWith("ERC1155InsufficientBalance"); + }); + + it('should allow taker to spend ERC721 maker payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const tokenId = 1; + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc721token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + + // Make the Maker ERC721 payment. Call safeTransferFrom directly to transfer the token to the etomicSwapMakerNftV2 contract. + // Explicitly specify the method signature. + await erc721token.connect(accounts[0])['safeTransferFrom(address,address,uint256,bytes)'](accounts[0].address, etomicSwapMakerNftV2.target, tokenId, data).should.be.fulfilled; + + // Check the ownership of the token before Taker spend Maker ERC721 payment - should be owned by Swap NFT contract + const tokenOwnerBeforeTakerSpend = await erc721token.ownerOf(tokenId); + expect(tokenOwnerBeforeTakerSpend).to.equal(etomicSwapMakerNftV2.target); + + const takerSwapRunner = etomicSwapMakerNftV2.connect(accounts[1]); + + const spendParamsInvalidSecret = [ + id, + accounts[0].address, + takerSecretHash, + invalidSecret, + erc721token.target, + tokenId + ]; + // Attempt to spend with invalid secret - should fail + await takerSwapRunner.spendErc721MakerPayment(...spendParamsInvalidSecret).should.be.rejectedWith(INVALID_HASH); + + const spendParams = [ + id, + accounts[0].address, + takerSecretHash, + makerSecret, + erc721token.target, + tokenId + ]; + + // should not allow to spend from non-taker address even with valid secret + await etomicSwapMakerNftV2.connect(accounts[2]).spendErc721MakerPayment(...spendParams).should.be.rejectedWith(INVALID_HASH); + + // Successful spend by Taker with valid secret + await takerSwapRunner.spendErc721MakerPayment(...spendParams).should.be.fulfilled; + + // Check the state of the payment + const payment = await etomicSwapMakerNftV2.makerPayments(id); + expect(Number(payment[2])).to.equal(TAKER_SPENT); + + // Check the ownership of the token - should be transferred to the Taker (accounts[1]) + const tokenOwner = await erc721token.ownerOf(tokenId); + expect(tokenOwner).to.equal(accounts[1].address); + + // should not allow to spend again + await takerSwapRunner.spendErc721MakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }) + + it('should allow taker to spend ERC1155 maker payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const tokenId = 1; // Token ID used in Erc1155Token contract + const amountToSend = 2; // Amount of tokens to send + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + + // Make the Maker ERC1155 payment. Call safeTransferFrom directly to transfer the token to the etomicSwapMakerNftV2 contract. + await erc1155token.connect(accounts[0]).safeTransferFrom(accounts[0].address, etomicSwapMakerNftV2.target, tokenId, amountToSend, data).should.be.fulfilled; + + // Check the balance of the token before Taker spend Maker ERC1155 payment - should be in Swap NFT contract + let tokenBalanceBeforeTakerSpend = await erc1155token.balanceOf(etomicSwapMakerNftV2.target, tokenId); + expect(tokenBalanceBeforeTakerSpend).to.equal(BigInt(amountToSend)); + + const takerSwapRunner = etomicSwapMakerNftV2.connect(accounts[1]); + + const spendParamsInvalidSecret = [ + id, + accounts[0].address, + takerSecretHash, + invalidSecret, + erc1155token.target, + tokenId, + amountToSend + ]; + // Attempt to spend with invalid secret - should fail + await takerSwapRunner.spendErc1155MakerPayment(...spendParamsInvalidSecret).should.be.rejectedWith(INVALID_HASH); + + const spendParams = [ + id, + accounts[0].address, + takerSecretHash, + makerSecret, + erc1155token.target, + tokenId, + amountToSend + ]; + + // should not allow to spend from non-taker address even with valid secret + await etomicSwapMakerNftV2.connect(accounts[2]).spendErc1155MakerPayment(...spendParams).should.be.rejectedWith(INVALID_HASH); + + // Successful spend by Taker with valid secret + await takerSwapRunner.spendErc1155MakerPayment(...spendParams).should.be.fulfilled; + + // Check the state of the payment + const payment = await etomicSwapMakerNftV2.makerPayments(id); + expect(Number(payment[2])).to.equal(TAKER_SPENT); + + // Check the balance of the token - should be transferred to the Taker (accounts[1]) + let tokenBalance = await erc1155token.balanceOf(accounts[1].address, tokenId); + expect(tokenBalance).to.equal(BigInt(amountToSend)); + + // Check that the Swap NFT contract no longer holds the tokens + tokenBalance = await erc1155token.balanceOf(etomicSwapMakerNftV2.target, tokenId); + expect(tokenBalance).to.equal(BigInt(0)); + + // should not allow to spend again + await takerSwapRunner.spendErc1155MakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ERC721 payment after locktime', async function() { + const lockTime = await currentEvmTime() + 1000; + const tokenId = 1; + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc721token.target, takerSecretHash, makerSecretHash, lockTime] + ); + + let makerSwapRunner = etomicSwapMakerNftV2.connect(accounts[0]); + + // Not allow maker to refund if payment was not sent + const refundParams = [ + id, + accounts[1].address, + takerSecretHash, + makerSecretHash, + erc721token.target, + tokenId + ]; + await makerSwapRunner.refundErc721MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the Maker ERC721 payment. Call safeTransferFrom directly to transfer the token to the etomicSwapMakerNftV2 contract. + // Explicitly specify the method signature. + await erc721token.connect(accounts[0])['safeTransferFrom(address,address,uint256,bytes)'](accounts[0].address, etomicSwapMakerNftV2.target, tokenId, data).should.be.fulfilled; + + // Not allow to refund before locktime + await makerSwapRunner.refundErc721MakerPaymentTimelock(...refundParams).should.be.rejectedWith(REFUND_TIMESTAMP_NOT_REACHED); + + // Simulate time passing to exceed the locktime + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-maker address + await etomicSwapMakerNftV2.connect(accounts[1]).refundErc721MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Successful refund by maker after locktime + await makerSwapRunner.refundErc721MakerPaymentTimelock(...refundParams).should.be.fulfilled; + + // Check the state of the payment + const payment = await etomicSwapMakerNftV2.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Not allow maker to refund again + await makerSwapRunner.refundErc721MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ERC1155 payment after locktime', async function() { + const lockTime = await currentEvmTime() + 1000; + const tokenId = 1; // Token ID used in Erc1155Token contract + const amountToSend = 3; // Amount of tokens to send + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, lockTime] + ); + + let makerSwapRunner = etomicSwapMakerNftV2.connect(accounts[0]); + + const refundParams = [ + id, + accounts[1].address, + takerSecretHash, + makerSecretHash, + erc1155token.target, + tokenId, + amountToSend + ]; + + // Not allow maker to refund if payment was not sent + await makerSwapRunner.refundErc1155MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the Maker ERC1155 payment. Call safeTransferFrom directly to transfer the token to the etomicSwapMakerNftV2 contract. + await erc1155token.connect(accounts[0]).safeTransferFrom(accounts[0].address, etomicSwapMakerNftV2.target, tokenId, amountToSend, data).should.be.fulfilled; + + // Not allow to refund before locktime + await makerSwapRunner.refundErc1155MakerPaymentTimelock(...refundParams).should.be.rejectedWith(REFUND_TIMESTAMP_NOT_REACHED); + + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-maker address + await etomicSwapMakerNftV2.connect(accounts[1]).refundErc1155MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH) + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + accounts[1].address, + takerSecretHash, + makerSecretHash, + erc1155token.target, + tokenId, + 2 + ]; + await makerSwapRunner.refundErc1155MakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + // Successful refund by maker after locktime + await makerSwapRunner.refundErc1155MakerPaymentTimelock(...refundParams).should.be.fulfilled; + + // Check the state of the payment + const payment = await etomicSwapMakerNftV2.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Check the balance of the token - should be back to the maker (accounts[0]) + const tokenBalance = await erc1155token.balanceOf(accounts[0].address, tokenId); + expect(tokenBalance).to.equal(BigInt(amountToSend)); + + // Do not allow to refund again + await makerSwapRunner.refundErc1155MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ERC721 payment using taker secret', async function() { + const lockTime = await currentEvmTime() + 1000; + const tokenId = 1; + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc721token.target, takerSecretHash, makerSecretHash, lockTime] + ); + + let makerSwapRunner = etomicSwapMakerNftV2.connect(accounts[0]); + + // Not allow to refund if payment was not sent + const refundParams = [ + id, + accounts[1].address, + takerSecret, + makerSecretHash, + erc721token.target, + tokenId + ]; + await makerSwapRunner.refundErc721MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the Maker ERC721 payment. Call safeTransferFrom directly to transfer the token to the etomicSwapMakerNftV2 contract. + // Explicitly specify the method signature. + await erc721token.connect(accounts[0])['safeTransferFrom(address,address,uint256,bytes)'](accounts[0].address, etomicSwapMakerNftV2.target, tokenId, data).should.be.fulfilled; + + // Not allow to call refund from non-maker address + await etomicSwapMakerNftV2.connect(accounts[1]).refundErc721MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Successful refund by maker using taker secret + await makerSwapRunner.refundErc721MakerPaymentSecret(...refundParams).should.be.fulfilled; + + // Check the state of the payment + const payment = await etomicSwapMakerNftV2.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Not allow maker to refund again + await makerSwapRunner.refundErc721MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ERC1155 payment using taker secret', async function() { + const lockTime = await currentEvmTime() + 1000; + const tokenId = 1; // Token ID used in Erc1155Token contract + const amountToSend = 3; // Amount of tokens to send + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, lockTime] + ); + + let makerSwapRunner = etomicSwapMakerNftV2.connect(accounts[0]); + + const refundParams = [ + id, + accounts[1].address, + takerSecret, + makerSecretHash, + erc1155token.target, + tokenId, + amountToSend + ]; + + // Not allow maker to refund if payment was not sent + await makerSwapRunner.refundErc1155MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the Maker ERC1155 payment. Call safeTransferFrom directly to transfer the token to the etomicSwapMakerNftV2 contract. + await erc1155token.connect(accounts[0]).safeTransferFrom(accounts[0].address, etomicSwapMakerNftV2.target, tokenId, amountToSend, data).should.be.fulfilled; + + // Not allow to call refund from non-maker address + await etomicSwapMakerNftV2.connect(accounts[1]).refundErc1155MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + accounts[1].address, + takerSecret, + makerSecretHash, + erc1155token.target, + tokenId, + 2 + ]; + await makerSwapRunner.refundErc1155MakerPaymentSecret(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + await makerSwapRunner.refundErc1155MakerPaymentSecret(...refundParams).should.be.fulfilled; + + // Successful refund by maker using taker secret + const payment = await etomicSwapMakerNftV2.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Do not allow to refund again + await makerSwapRunner.refundErc1155MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + +}); + diff --git a/test/EtomicSwapMakerV2Test.js b/test/EtomicSwapMakerV2Test.js new file mode 100644 index 0000000..5ab0da9 --- /dev/null +++ b/test/EtomicSwapMakerV2Test.js @@ -0,0 +1,551 @@ +const { + expect +} = require("chai"); +const { + ethers +} = require("hardhat"); +const crypto = require('crypto'); + +require('chai') + .use(require('chai-as-promised')) + .should(); + +const INVALID_HASH = 'Invalid paymentHash'; +const INVALID_PAYMENT_STATE_SENT = 'Invalid payment state. Must be PaymentSent'; +const REFUND_TIMESTAMP_NOT_REACHED = 'Current timestamp didn\'t exceed payment refund lock time'; + +/** + * Advances the Ethereum Virtual Machine (EVM) time by a specified amount and then mines a new block. + * + * @param {number} increaseAmount The amount of time to advance in seconds. + * + * This function is used in Ethereum smart contract testing to simulate the passage of time. In the EVM, + * time is measured based on block timestamps. The 'evm_increaseTime' method increases the EVM's internal + * clock, but this change only affects the next mined block. Therefore, 'evm_mine' is called immediately + * afterwards to mine a new block, ensuring that the blockchain's timestamp is updated to reflect the time + * change. This approach is essential for testing time-dependent contract features like lock periods or deadlines. + */ +async function advanceTimeAndMine(increaseAmount) { + await ethers.provider.send("evm_increaseTime", [increaseAmount]); + await ethers.provider.send("evm_mine"); +} + +async function currentEvmTime() { + const block = await ethers.provider.getBlock("latest"); + return block.timestamp; +} + +const id = '0x' + crypto.randomBytes(32).toString('hex'); +const [MAKER_PAYMENT_UNINITIALIZED, MAKER_PAYMENT_SENT, TAKER_SPENT, MAKER_REFUNDED] = [0, 1, 2, 3]; + +const takerSecret = crypto.randomBytes(32); +const takerSecretHash = '0x' + crypto.createHash('sha256').update(takerSecret).digest('hex'); + +const makerSecret = crypto.randomBytes(32); +const makerSecretHash = '0x' + crypto.createHash('sha256').update(makerSecret).digest('hex'); + +const invalidSecret = crypto.randomBytes(32); + +const zeroAddr = '0x0000000000000000000000000000000000000000'; + +describe("EtomicSwapMakerV2", function() { + + beforeEach(async function() { + accounts = await ethers.getSigners(); + + EtomicSwapMakerV2 = await ethers.getContractFactory("EtomicSwapMakerV2"); + etomicSwapMakerV2 = await EtomicSwapMakerV2.deploy(); + await etomicSwapMakerV2.waitForDeployment(); + + Token = await ethers.getContractFactory("Token"); + token = await Token.deploy(); + await token.waitForDeployment(); + + await token.transfer(accounts[1].address, ethers.parseEther('100')); + }); + + it('should create contract with uninitialized maker payments', async function() { + const makerPayment = await etomicSwapMakerV2.makerPayments(id); + expect(Number(makerPayment[2])).to.equal(MAKER_PAYMENT_UNINITIALIZED); + }); + + it('should allow maker to send ETH payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const params = [ + id, + accounts[1].address, + takerSecretHash, + makerSecretHash, + paymentLockTime + ]; + + // Make the ETH payment + await etomicSwapMakerV2.connect(accounts[0]).ethMakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + const payment = await etomicSwapMakerV2.makerPayments(id); + + expect(Number(payment[1])).to.equal(paymentLockTime); + expect(Number(payment[2])).to.equal(MAKER_PAYMENT_SENT); + + // Check that it should not allow to send again + await etomicSwapMakerV2.connect(accounts[0]).ethMakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.rejectedWith("Maker payment is already initialized"); + }); + + it('should allow maker to send ERC20 payment', async function() { + const currentTime = await currentEvmTime(); + + const paymentLockTime = currentTime + 100; + + const payment_params = [ + id, + ethers.parseEther('1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + paymentLockTime, + ]; + + let etomicSwapRunner0 = etomicSwapMakerV2.connect(accounts[0]); + + await token.approve(etomicSwapMakerV2.target, ethers.parseEther('1')); + // Make the ERC20 payment + await etomicSwapRunner0.erc20MakerPayment(...payment_params).should.be.fulfilled; + + // Check contract token balance + const balance = await token.balanceOf(etomicSwapMakerV2.target); + expect(balance).to.equal(ethers.parseEther('1')); + + const payment = await etomicSwapMakerV2.makerPayments(id); + + // Check locktime and status + expect(payment[1]).to.equal(BigInt(paymentLockTime)); + expect(payment[2]).to.equal(BigInt(MAKER_PAYMENT_SENT)); + + // Should not allow to send payment again + await etomicSwapRunner0.erc20MakerPayment(...payment_params).should.be.rejectedWith("Maker payment is already initialized"); + }); + + it('should allow taker to spend ETH maker payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const payment_params = [ + id, + accounts[1].address, + takerSecretHash, + makerSecretHash, + paymentLockTime + ]; + + const makerSwapRunner = etomicSwapMakerV2.connect(accounts[0]); + const takerSwapRunner = etomicSwapMakerV2.connect(accounts[1]); + + // Make the ETH payment + await makerSwapRunner.ethMakerPayment(...payment_params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + const spendParamsInvalidSecret = [ + id, + ethers.parseEther('1'), + accounts[0].address, + takerSecretHash, + invalidSecret, + zeroAddr, + ]; + await takerSwapRunner.spendMakerPayment(...spendParamsInvalidSecret).should.be.rejectedWith(INVALID_HASH); + + const spendParamsInvalidAmount = [ + id, + ethers.parseEther('0.9'), + accounts[0].address, + takerSecretHash, + makerSecret, + zeroAddr, + ]; + await takerSwapRunner.spendMakerPayment(...spendParamsInvalidAmount).should.be.rejectedWith(INVALID_HASH); + + const spendParams = [ + id, + ethers.parseEther('1'), + accounts[0].address, + takerSecretHash, + makerSecret, + zeroAddr, + ]; + + // should not allow to spend from non-taker address + await etomicSwapMakerV2.connect(accounts[2]).spendMakerPayment(...spendParams).should.be.rejectedWith(INVALID_HASH); + + const balanceBefore = await ethers.provider.getBalance(accounts[1].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const spendTx = await takerSwapRunner.spendMakerPayment(...spendParams, { + gasPrice + }).should.be.fulfilled; + + const spendReceipt = await spendTx.wait(); + const gasUsed = ethers.parseUnits(spendReceipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[1].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('1')); + + const payment = await etomicSwapMakerV2.makerPayments(id); + + expect(Number(payment[2])).to.equal(TAKER_SPENT); + + // should not allow to spend again + await takerSwapRunner.spendMakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to spend ERC20 maker payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const payment_params = [ + id, + ethers.parseEther('1'), + token.target, + accounts[1].address, // taker + takerSecretHash, + makerSecretHash, + paymentLockTime + ]; + + const makerSwapRunner = etomicSwapMakerV2.connect(accounts[0]); + const takerSwapRunner = etomicSwapMakerV2.connect(accounts[1]); + + // Make the ERC20 payment + await token.approve(etomicSwapMakerV2.target, ethers.parseEther('1')); + await makerSwapRunner.erc20MakerPayment(...payment_params).should.be.fulfilled; + + const contractBalance = await token.balanceOf(etomicSwapMakerV2.target); + expect(contractBalance).to.equal(ethers.parseEther('1')); + + const spendParamsInvalidSecret = [ + id, + ethers.parseEther('1'), + accounts[0].address, + takerSecretHash, + invalidSecret, + token.target, + ]; + await takerSwapRunner.spendMakerPayment(...spendParamsInvalidSecret).should.be.rejectedWith(INVALID_HASH); + + const spendParamsInvalidAmount = [ + id, + ethers.parseEther('0.9'), + accounts[0].address, + takerSecretHash, + makerSecret, + token.target, + ]; + await takerSwapRunner.spendMakerPayment(...spendParamsInvalidAmount).should.be.rejectedWith(INVALID_HASH); + + const spendParams = [ + id, + ethers.parseEther('1'), + accounts[0].address, // maker + takerSecretHash, + makerSecret, + token.target, + ]; + + // should not allow to spend from non-taker address + await etomicSwapMakerV2.connect(accounts[2]).spendMakerPayment(...spendParams).should.be.rejectedWith(INVALID_HASH); + + const balanceBefore = await token.balanceOf(accounts[1].address); + + const gasPrice = ethers.parseUnits('100', 'gwei'); + await takerSwapRunner.spendMakerPayment(...spendParams, { + gasPrice + }).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[1].address); + // Check taker balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + + const payment = await etomicSwapMakerV2.makerPayments(id); + + expect(Number(payment[2])).to.equal(TAKER_SPENT); + + // should not allow to spend again + await takerSwapRunner.spendMakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ETH payment after locktime', async function() { + const lockTime = (await ethers.provider.getBlock('latest')).timestamp + 1000; + const params = [ + id, + accounts[1].address, + takerSecretHash, + makerSecretHash, + lockTime + ]; + + let etomicSwapRunner0 = etomicSwapMakerV2.connect(accounts[0]); + let etomicSwapRunner1 = etomicSwapMakerV2.connect(accounts[1]); + + // Not allow to refund if payment was not sent + const refundParams = [ + id, + ethers.parseEther('1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr, + ]; + + await etomicSwapRunner0.refundMakerPaymentTimelock(...refundParams) + .should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the ETH payment + await etomicSwapRunner0.ethMakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + // Not allow to refund before locktime + await etomicSwapRunner0.refundMakerPaymentTimelock(...refundParams).should.be.rejectedWith(REFUND_TIMESTAMP_NOT_REACHED); + + // Simulate time passing to exceed the locktime + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-sender address + await etomicSwapRunner1.refundMakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + let invalidAmountParams = [ + id, + ethers.parseEther('0.9'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr, + ]; + await etomicSwapRunner0.refundMakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await ethers.provider.getBalance(accounts[0].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const tx = await etomicSwapRunner0.refundMakerPaymentTimelock(...refundParams, { + gasPrice + }).should.be.fulfilled; + + const receipt = await tx.wait(); + const gasUsed = ethers.parseUnits(receipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[0].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapMakerV2.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Not allow to refund again + await etomicSwapRunner0.refundMakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ERC20 payment after locktime', async function() { + const lockTime = await currentEvmTime() + 1000; + const params = [ + id, + ethers.parseEther('1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + lockTime + ]; + + let etomicSwapRunner0 = etomicSwapMakerV2.connect(accounts[0]); + let etomicSwapRunner1 = etomicSwapMakerV2.connect(accounts[1]); + + await token.approve(etomicSwapMakerV2.target, ethers.parseEther('1')); + // Make the ERC20 payment + await expect(etomicSwapRunner0.erc20MakerPayment(...params)).to.be.fulfilled; + + const refundParams = [ + id, + ethers.parseEther('1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await etomicSwapRunner0.refundMakerPaymentTimelock(...refundParams).should.be.rejectedWith(REFUND_TIMESTAMP_NOT_REACHED); + + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-sender address + await etomicSwapRunner1.refundMakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.9'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await etomicSwapRunner0.refundMakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await token.balanceOf(accounts[0].address); + + await etomicSwapRunner0.refundMakerPaymentTimelock(...refundParams).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[0].address); + + // Check maker balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapMakerV2.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Do not allow to refund again + await etomicSwapRunner0.refundMakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ETH payment using taker secret', async function() { + const lockTime = (await ethers.provider.getBlock('latest')).timestamp + 1000; + const params = [ + id, + accounts[1].address, + takerSecretHash, + makerSecretHash, + lockTime + ]; + + let etomicSwapRunner0 = etomicSwapMakerV2.connect(accounts[0]); + let etomicSwapRunner1 = etomicSwapMakerV2.connect(accounts[1]); + + // Not allow to refund if payment was not sent + const refundParams = [ + id, + ethers.parseEther('1'), + accounts[1].address, + takerSecret, + makerSecretHash, + zeroAddr, + ]; + + await etomicSwapRunner0.refundMakerPaymentSecret(...refundParams) + .should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the ETH payment + await etomicSwapRunner0.ethMakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + // Not allow to call refund from non-sender address + await etomicSwapRunner1.refundMakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + let invalidAmountParams = [ + id, + ethers.parseEther('0.9'), + accounts[1].address, + takerSecret, + makerSecretHash, + zeroAddr, + ]; + await etomicSwapRunner0.refundMakerPaymentSecret(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await ethers.provider.getBalance(accounts[0].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const tx = await etomicSwapRunner0.refundMakerPaymentSecret(...refundParams, { + gasPrice + }).should.be.fulfilled; + + const receipt = await tx.wait(); + const gasUsed = ethers.parseUnits(receipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[0].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapMakerV2.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Not allow to refund again + await etomicSwapRunner0.refundMakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ERC20 payment using taker secret', async function() { + const lockTime = await currentEvmTime() + 1000; + const params = [ + id, + ethers.parseEther('1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + lockTime + ]; + + let etomicSwapRunner0 = etomicSwapMakerV2.connect(accounts[0]); + let etomicSwapRunner1 = etomicSwapMakerV2.connect(accounts[1]); + + await token.approve(etomicSwapMakerV2.target, ethers.parseEther('1')); + // Make the ERC20 payment + await expect(etomicSwapRunner0.erc20MakerPayment(...params)).to.be.fulfilled; + + const refundParams = [ + id, + ethers.parseEther('1'), + accounts[1].address, + takerSecret, + makerSecretHash, + token.target, + ]; + + // Not allow to call refund from non-sender address + await etomicSwapRunner1.refundMakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.9'), + accounts[1].address, + takerSecret, + makerSecretHash, + token.target, + ]; + + await etomicSwapRunner0.refundMakerPaymentSecret(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await token.balanceOf(accounts[0].address); + + await etomicSwapRunner0.refundMakerPaymentSecret(...refundParams).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[0].address); + + // Check maker balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapMakerV2.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Do not allow to refund again + await etomicSwapRunner0.refundMakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); +}); diff --git a/test/EtomicSwapNft.js b/test/EtomicSwapNft.js new file mode 100644 index 0000000..b549d57 --- /dev/null +++ b/test/EtomicSwapNft.js @@ -0,0 +1,1300 @@ +const { + expect +} = require("chai"); +const { + ethers +} = require("hardhat"); +const crypto = require('crypto'); +const {AbiCoder} = require("ethers"); + +require('chai') + .use(require('chai-as-promised')) + .should(); + +const INVALID_HASH = 'Invalid paymentHash'; +const INVALID_PAYMENT_STATE_SENT = 'Invalid payment state. Must be PaymentSent'; +const INVALID_PAYMENT_STATE_APPROVED = 'Invalid payment state. Must be TakerApproved'; +const REFUND_TIMESTAMP_NOT_REACHED = 'Current timestamp didn\'t exceed payment refund lock time'; +const PRE_APPROVE_REFUND_TIMESTAMP_NOT_REACHED = 'Current timestamp didn\'t exceed payment pre-approve lock time'; + +/** + * Advances the Ethereum Virtual Machine (EVM) time by a specified amount and then mines a new block. + * + * @param {number} increaseAmount The amount of time to advance in seconds. + * + * This function is used in Ethereum smart contract testing to simulate the passage of time. In the EVM, + * time is measured based on block timestamps. The 'evm_increaseTime' method increases the EVM's internal + * clock, but this change only affects the next mined block. Therefore, 'evm_mine' is called immediately + * afterwards to mine a new block, ensuring that the blockchain's timestamp is updated to reflect the time + * change. This approach is essential for testing time-dependent contract features like lock periods or deadlines. + */ +async function advanceTimeAndMine(increaseAmount) { + await ethers.provider.send("evm_increaseTime", [increaseAmount]); + await ethers.provider.send("evm_mine"); +} + +async function currentEvmTime() { + const block = await ethers.provider.getBlock("latest"); + return block.timestamp; +} + +const id = '0x' + crypto.randomBytes(32).toString('hex'); +const [TAKER_PAYMENT_UNINITIALIZED, TAKER_PAYMENT_SENT, TAKER_PAYMENT_APPROVED, MAKER_SPENT, TAKER_REFUNDED] = [0, 1, 2, 3, 4]; +const [MAKER_PAYMENT_UNINITIALIZED, MAKER_PAYMENT_SENT, TAKER_SPENT, MAKER_REFUNDED] = [0, 1, 2, 3]; + +const takerSecret = crypto.randomBytes(32); +const takerSecretHash = '0x' + crypto.createHash('sha256').update(takerSecret).digest('hex'); + +const makerSecret = crypto.randomBytes(32); +const makerSecretHash = '0x' + crypto.createHash('sha256').update(makerSecret).digest('hex'); + +const invalidSecret = crypto.randomBytes(32); + +const zeroAddr = '0x0000000000000000000000000000000000000000'; +const dexFeeAddr = '0x8888888888888888888888888888888888888888'; + +describe("etomicSwapNft", function() { + + beforeEach(async function() { + accounts = await ethers.getSigners(); + + EtomicSwapNft = await ethers.getContractFactory("EtomicSwapNft"); + etomicSwapNft = await EtomicSwapNft.deploy(dexFeeAddr); + await etomicSwapNft.waitForDeployment(); + + Token = await ethers.getContractFactory("Token"); + token = await Token.deploy(); + await token.waitForDeployment(); + + Erc721Token = await ethers.getContractFactory("Erc721Token"); + erc721token = await Erc721Token.deploy("MyNFT", "MNFT"); + await erc721token.waitForDeployment(); + + Erc1155Token = await ethers.getContractFactory("Erc1155Token"); + erc1155token = await Erc1155Token.deploy("uri"); + await erc1155token.waitForDeployment(); + + await token.transfer(accounts[1].address, ethers.parseEther('100')); + }); + + it('should create contract with uninitialized payments', async function() { + const takerPayment = await etomicSwapNft.takerPayments(id); + expect(Number(takerPayment[3])).to.equal(TAKER_PAYMENT_UNINITIALIZED); + + const makerPayment = await etomicSwapNft.makerPayments(id); + expect(Number(makerPayment[2])).to.equal(MAKER_PAYMENT_UNINITIALIZED); + }); + + it('should allow maker to send ERC721 payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const tokenId = 1; // Assuming token ID 1 is minted to accounts[0] in Erc721Token contract + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc721token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + + const makerErc721Runner0 = erc721token.connect(accounts[0]); + + // Make the Maker ERC721 payment. Call safeTransferFrom directly to transfer the token to the EtomicSwapNft contract. + // Explicitly specify the method signature. + const tx = await makerErc721Runner0['safeTransferFrom(address,address,uint256,bytes)'](accounts[0].address, etomicSwapNft.target, tokenId, data).should.be.fulfilled; + const receipt = await tx.wait(); + console.log(`Gas Used: ${receipt.gasUsed.toString()}`); + + // Check the payment lockTime and state + const payment = await etomicSwapNft.makerPayments(id); + expect(Number(payment[1])).to.equal(paymentLockTime); + expect(Number(payment[2])).to.equal(MAKER_PAYMENT_SENT); + + // Should not allow to send again ( reverted with custom error ERC721InsufficientApproval ) + await expect(makerErc721Runner0['safeTransferFrom(address,address,uint256,bytes)'](accounts[0].address, etomicSwapNft.target, tokenId, data)).to.be.rejectedWith("ERC721InsufficientApproval"); + }); + + it('should allow maker to send ERC1155 payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const tokenId = 1; // Token ID used in Erc1155Token contract + const amountToSend = 2; // Amount of tokens to send + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + + const makerErc1155Runner0 = erc1155token.connect(accounts[0]); + + // Make the Maker ERC1155 payment. Call safeTransferFrom directly to transfer the token to the EtomicSwapNft contract. + const tx = await makerErc1155Runner0.safeTransferFrom(accounts[0].address, etomicSwapNft.target, tokenId, amountToSend, data).should.be.fulfilled; + const receipt = await tx.wait(); + console.log(`Gas Used: ${receipt.gasUsed.toString()}`); + + // Check the payment lockTime and state + const payment = await etomicSwapNft.makerPayments(id); + expect(Number(payment[1])).to.equal(paymentLockTime); + expect(Number(payment[2])).to.equal(MAKER_PAYMENT_SENT); + + // Check the balance of the token in the swap contract + const tokenBalance = await erc1155token.balanceOf(etomicSwapNft.target, tokenId); + expect(tokenBalance).to.equal(BigInt(amountToSend)); + + // Check sending same params again - should fail + await expect(makerErc1155Runner0.safeTransferFrom(accounts[0].address, etomicSwapNft.target, tokenId, amountToSend, data)).to.be.rejectedWith("ERC1155InsufficientBalance"); + + // Maker should be capable to send more tokens, if they have it. Note: Check Erc1155.sol file. By default, ERC1155 is minted with 3 value. + const id1 = '0x' + crypto.randomBytes(32).toString('hex'); + const data1 = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id1, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + await makerErc1155Runner0.safeTransferFrom(accounts[0].address, etomicSwapNft.target, tokenId, 1, data1).should.be.fulfilled; + + // Check sending more tokens than the sender owns - should fail + const id2 = '0x' + crypto.randomBytes(32).toString('hex'); + const data2 = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id2, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + await expect(makerErc1155Runner0.safeTransferFrom(accounts[0].address, etomicSwapNft.target, tokenId, amountToSend, data2)).to.be.rejectedWith("ERC1155InsufficientBalance"); + }); + + it('should allow taker to spend ERC721 maker payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const tokenId = 1; + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc721token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + + // Make the Maker ERC721 payment. Call safeTransferFrom directly to transfer the token to the EtomicSwapNft contract. + // Explicitly specify the method signature. + await erc721token.connect(accounts[0])['safeTransferFrom(address,address,uint256,bytes)'](accounts[0].address, etomicSwapNft.target, tokenId, data).should.be.fulfilled; + + // Check the ownership of the token before Taker spend Maker ERC721 payment - should be owned by Swap NFT contract + const tokenOwnerBeforeTakerSpend = await erc721token.ownerOf(tokenId); + expect(tokenOwnerBeforeTakerSpend).to.equal(etomicSwapNft.target); + + const takerSwapRunner = etomicSwapNft.connect(accounts[1]); + + const spendParamsInvalidSecret = [ + id, + accounts[0].address, + takerSecretHash, + invalidSecret, + erc721token.target, + tokenId + ]; + // Attempt to spend with invalid secret - should fail + await takerSwapRunner.spendErc721MakerPayment(...spendParamsInvalidSecret).should.be.rejectedWith(INVALID_HASH); + + const spendParams = [ + id, + accounts[0].address, + takerSecretHash, + makerSecret, + erc721token.target, + tokenId + ]; + + // should not allow to spend from non-taker address even with valid secret + await etomicSwapNft.connect(accounts[2]).spendErc721MakerPayment(...spendParams).should.be.rejectedWith(INVALID_HASH); + + // Successful spend by Taker with valid secret + await takerSwapRunner.spendErc721MakerPayment(...spendParams).should.be.fulfilled; + + // Check the state of the payment + const payment = await etomicSwapNft.makerPayments(id); + expect(Number(payment[2])).to.equal(TAKER_SPENT); + + // Check the ownership of the token - should be transferred to the Taker (accounts[1]) + const tokenOwner = await erc721token.ownerOf(tokenId); + expect(tokenOwner).to.equal(accounts[1].address); + + // should not allow to spend again + await takerSwapRunner.spendErc721MakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }) + + it('should allow taker to spend ERC1155 maker payment', async function() { + let currentTime = await currentEvmTime(); + const paymentLockTime = currentTime + 100; + const tokenId = 1; // Token ID used in Erc1155Token contract + const amountToSend = 2; // Amount of tokens to send + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, paymentLockTime] + ); + + // Make the Maker ERC1155 payment. Call safeTransferFrom directly to transfer the token to the EtomicSwapNft contract. + await erc1155token.connect(accounts[0]).safeTransferFrom(accounts[0].address, etomicSwapNft.target, tokenId, amountToSend, data).should.be.fulfilled; + + // Check the balance of the token before Taker spend Maker ERC1155 payment - should be in Swap NFT contract + let tokenBalanceBeforeTakerSpend = await erc1155token.balanceOf(etomicSwapNft.target, tokenId); + expect(tokenBalanceBeforeTakerSpend).to.equal(BigInt(amountToSend)); + + const takerSwapRunner = etomicSwapNft.connect(accounts[1]); + + const spendParamsInvalidSecret = [ + id, + accounts[0].address, + takerSecretHash, + invalidSecret, + erc1155token.target, + tokenId, + amountToSend + ]; + // Attempt to spend with invalid secret - should fail + await takerSwapRunner.spendErc1155MakerPayment(...spendParamsInvalidSecret).should.be.rejectedWith(INVALID_HASH); + + const spendParams = [ + id, + accounts[0].address, + takerSecretHash, + makerSecret, + erc1155token.target, + tokenId, + amountToSend + ]; + + // should not allow to spend from non-taker address even with valid secret + await etomicSwapNft.connect(accounts[2]).spendErc1155MakerPayment(...spendParams).should.be.rejectedWith(INVALID_HASH); + + // Successful spend by Taker with valid secret + await takerSwapRunner.spendErc1155MakerPayment(...spendParams).should.be.fulfilled; + + // Check the state of the payment + const payment = await etomicSwapNft.makerPayments(id); + expect(Number(payment[2])).to.equal(TAKER_SPENT); + + // Check the balance of the token - should be transferred to the Taker (accounts[1]) + let tokenBalance = await erc1155token.balanceOf(accounts[1].address, tokenId); + expect(tokenBalance).to.equal(BigInt(amountToSend)); + + // Check that the Swap NFT contract no longer holds the tokens + tokenBalance = await erc1155token.balanceOf(etomicSwapNft.target, tokenId); + expect(tokenBalance).to.equal(BigInt(0)); + + // should not allow to spend again + await takerSwapRunner.spendErc1155MakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ERC721 payment after locktime', async function() { + const lockTime = await currentEvmTime() + 1000; + const tokenId = 1; + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc721token.target, takerSecretHash, makerSecretHash, lockTime] + ); + + let makerSwapRunner = etomicSwapNft.connect(accounts[0]); + + // Not allow maker to refund if payment was not sent + const refundParams = [ + id, + accounts[1].address, + takerSecretHash, + makerSecretHash, + erc721token.target, + tokenId + ]; + await makerSwapRunner.refundErc721MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the Maker ERC721 payment. Call safeTransferFrom directly to transfer the token to the EtomicSwapNft contract. + // Explicitly specify the method signature. + await erc721token.connect(accounts[0])['safeTransferFrom(address,address,uint256,bytes)'](accounts[0].address, etomicSwapNft.target, tokenId, data).should.be.fulfilled; + + // Not allow to refund before locktime + await makerSwapRunner.refundErc721MakerPaymentTimelock(...refundParams).should.be.rejectedWith(REFUND_TIMESTAMP_NOT_REACHED); + + // Simulate time passing to exceed the locktime + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-maker address + await etomicSwapNft.connect(accounts[1]).refundErc721MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Successful refund by maker after locktime + await makerSwapRunner.refundErc721MakerPaymentTimelock(...refundParams).should.be.fulfilled; + + // Check the state of the payment + const payment = await etomicSwapNft.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Not allow maker to refund again + await makerSwapRunner.refundErc721MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ERC1155 payment after locktime', async function() { + const lockTime = await currentEvmTime() + 1000; + const tokenId = 1; // Token ID used in Erc1155Token contract + const amountToSend = 3; // Amount of tokens to send + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, lockTime] + ); + + let makerSwapRunner = etomicSwapNft.connect(accounts[0]); + + const refundParams = [ + id, + accounts[1].address, + takerSecretHash, + makerSecretHash, + erc1155token.target, + tokenId, + amountToSend + ]; + + // Not allow maker to refund if payment was not sent + await makerSwapRunner.refundErc1155MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the Maker ERC1155 payment. Call safeTransferFrom directly to transfer the token to the EtomicSwapNft contract. + await erc1155token.connect(accounts[0]).safeTransferFrom(accounts[0].address, etomicSwapNft.target, tokenId, amountToSend, data).should.be.fulfilled; + + // Not allow to refund before locktime + await makerSwapRunner.refundErc1155MakerPaymentTimelock(...refundParams).should.be.rejectedWith(REFUND_TIMESTAMP_NOT_REACHED); + + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-maker address + await etomicSwapNft.connect(accounts[1]).refundErc1155MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH) + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + accounts[1].address, + takerSecretHash, + makerSecretHash, + erc1155token.target, + tokenId, + 2 + ]; + await makerSwapRunner.refundErc1155MakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + // Successful refund by maker after locktime + await makerSwapRunner.refundErc1155MakerPaymentTimelock(...refundParams).should.be.fulfilled; + + // Check the state of the payment + const payment = await etomicSwapNft.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Check the balance of the token - should be back to the maker (accounts[0]) + const tokenBalance = await erc1155token.balanceOf(accounts[0].address, tokenId); + expect(tokenBalance).to.equal(BigInt(amountToSend)); + + // Do not allow to refund again + await makerSwapRunner.refundErc1155MakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ERC721 payment using taker secret', async function() { + const lockTime = await currentEvmTime() + 1000; + const tokenId = 1; + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc721token.target, takerSecretHash, makerSecretHash, lockTime] + ); + + let makerSwapRunner = etomicSwapNft.connect(accounts[0]); + + // Not allow to refund if payment was not sent + const refundParams = [ + id, + accounts[1].address, + takerSecret, + makerSecretHash, + erc721token.target, + tokenId + ]; + await makerSwapRunner.refundErc721MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the Maker ERC721 payment. Call safeTransferFrom directly to transfer the token to the EtomicSwapNft contract. + // Explicitly specify the method signature. + await erc721token.connect(accounts[0])['safeTransferFrom(address,address,uint256,bytes)'](accounts[0].address, etomicSwapNft.target, tokenId, data).should.be.fulfilled; + + // Not allow to call refund from non-maker address + await etomicSwapNft.connect(accounts[1]).refundErc721MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Successful refund by maker using taker secret + await makerSwapRunner.refundErc721MakerPaymentSecret(...refundParams).should.be.fulfilled; + + // Check the state of the payment + const payment = await etomicSwapNft.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Not allow maker to refund again + await makerSwapRunner.refundErc721MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow maker to refund ERC1155 payment using taker secret', async function() { + const lockTime = await currentEvmTime() + 1000; + const tokenId = 1; // Token ID used in Erc1155Token contract + const amountToSend = 3; // Amount of tokens to send + + const abiCoder = new AbiCoder(); + const data = abiCoder.encode( + ['bytes32', 'address', 'address', 'bytes32', 'bytes32','uint32'], + [id, accounts[1].address, erc1155token.target, takerSecretHash, makerSecretHash, lockTime] + ); + + let makerSwapRunner = etomicSwapNft.connect(accounts[0]); + + const refundParams = [ + id, + accounts[1].address, + takerSecret, + makerSecretHash, + erc1155token.target, + tokenId, + amountToSend + ]; + + // Not allow maker to refund if payment was not sent + await makerSwapRunner.refundErc1155MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the Maker ERC1155 payment. Call safeTransferFrom directly to transfer the token to the EtomicSwapNft contract. + await erc1155token.connect(accounts[0]).safeTransferFrom(accounts[0].address, etomicSwapNft.target, tokenId, amountToSend, data).should.be.fulfilled; + + // Not allow to call refund from non-maker address + await etomicSwapNft.connect(accounts[1]).refundErc1155MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + accounts[1].address, + takerSecret, + makerSecretHash, + erc1155token.target, + tokenId, + 2 + ]; + await makerSwapRunner.refundErc1155MakerPaymentSecret(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + await makerSwapRunner.refundErc1155MakerPaymentSecret(...refundParams).should.be.fulfilled; + + // Successful refund by maker using taker secret + const payment = await etomicSwapNft.makerPayments(id); + expect(payment.state).to.equal(BigInt(MAKER_REFUNDED)); + + // Do not allow to refund again + await makerSwapRunner.refundErc1155MakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to send ETH payment', async function() { + let currentTime = await currentEvmTime(); + const immediateRefundLockTime = currentTime + 100; + const paymentLockTime = currentTime + 100; + const params = [ + id, + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + immediateRefundLockTime, + paymentLockTime + ]; + // Make the ETH payment + await etomicSwapNft.connect(accounts[0]).ethTakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + const payment = await etomicSwapNft.takerPayments(id); + + expect(Number(payment[1])).to.equal(immediateRefundLockTime); + expect(Number(payment[2])).to.equal(paymentLockTime); + expect(Number(payment[3])).to.equal(TAKER_PAYMENT_SENT); + + // Check that it should not allow to send again + await etomicSwapNft.connect(accounts[0]).ethTakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.rejectedWith("Taker payment is already initialized"); + }); + + it('should allow taker to send ERC20 payment', async function() { + const currentTime = await currentEvmTime(); + + const immediateRefundLockTime = currentTime + 10; + const paymentLockTime = currentTime + 100; + + const paymentParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + immediateRefundLockTime, + paymentLockTime, + ]; + + let etomicSwapRunner0 = etomicSwapNft.connect(accounts[0]); + + await token.approve(etomicSwapNft.target, ethers.parseEther('1')); + // Make the ERC20 payment + await etomicSwapRunner0.erc20TakerPayment(...paymentParams).should.be.fulfilled; + + // Check contract token balance + const balance = await token.balanceOf(etomicSwapNft.target); + expect(balance).to.equal(ethers.parseEther('1')); + + const payment = await etomicSwapNft.takerPayments(id); + + // Check locktime and status + expect(payment[1]).to.equal(BigInt(immediateRefundLockTime)); + expect(payment[2]).to.equal(BigInt(paymentLockTime)); + expect(payment[3]).to.equal(BigInt(TAKER_PAYMENT_SENT)); + + // Should not allow to send payment again + await etomicSwapRunner0.erc20TakerPayment(...paymentParams).should.be.rejectedWith("ERC20 v2 payment is already initialized"); + }); + + it('should allow maker to spend ETH taker payment', async function() { + let currentTime = await currentEvmTime(); + const immediateRefundLockTime = currentTime + 100; + const paymentLockTime = currentTime + 100; + const paymentParams = [ + id, + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + immediateRefundLockTime, + paymentLockTime + ]; + // Make the ETH payment + await etomicSwapNft.connect(accounts[0]).ethTakerPayment(...paymentParams, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + const spendParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[0].address, + takerSecretHash, + makerSecret, + zeroAddr, + ]; + + // should not allow to spend before payment is approved by taker + await etomicSwapNft.connect(accounts[1]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_APPROVED); + + const approveParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr, + ]; + + await etomicSwapNft.connect(accounts[0]).takerPaymentApprove(...approveParams).should.be.fulfilled; + + // should not allow to spend from invalid address + await etomicSwapNft.connect(accounts[0]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_HASH); + + // should not allow to spend with invalid amounts + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[0].address, + takerSecretHash, + makerSecret, + zeroAddr, + ]; + + await etomicSwapNft.connect(accounts[1]).spendTakerPayment(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[0].address, + takerSecretHash, + makerSecret, + zeroAddr, + ]; + + await etomicSwapNft.connect(accounts[1]).spendTakerPayment(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + const balanceBefore = await ethers.provider.getBalance(accounts[1].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const spendTx = await etomicSwapNft.connect(accounts[1]).spendTakerPayment(...spendParams, { + gasPrice + }).should.be.fulfilled; + + const spendReceipt = await spendTx.wait(); + const gasUsed = ethers.parseUnits(spendReceipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[1].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('0.9')); + + const dexFeeAddrBalance = await ethers.provider.getBalance(dexFeeAddr); + expect(dexFeeAddrBalance).to.equal(ethers.parseEther('0.1')); + + const payment = await etomicSwapNft.takerPayments(id); + + expect(Number(payment[3])).to.equal(MAKER_SPENT); + + // Do not allow to spend again + await etomicSwapNft.connect(accounts[1]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_APPROVED); + }); + + it('should allow maker to spend ERC20 taker payment', async function() { + let currentTime = await currentEvmTime(); + const immediateRefundLockTime = currentTime + 100; + const paymentLockTime = currentTime + 100; + const paymentParams = [ + id, + ethers.parseEther('0.9'), // amount + ethers.parseEther('0.1'), // dexFee + token.target, + accounts[1].address, // receiver + takerSecretHash, + makerSecretHash, + immediateRefundLockTime, + paymentLockTime + ]; + + // Make the ERC20 payment + await token.approve(etomicSwapNft.target, ethers.parseEther('1')); + await etomicSwapNft.connect(accounts[0]).erc20TakerPayment(...paymentParams).should.be.fulfilled; + + const contractBalance = await token.balanceOf(etomicSwapNft.target); + expect(contractBalance).to.equal(ethers.parseEther('1')); + + const spendParams = [ + id, + ethers.parseEther('0.9'), // amount + ethers.parseEther('0.1'), // dexFee + accounts[0].address, + takerSecretHash, + makerSecret, + token.target, // tokenAddress + ]; + + // should not allow to spend before taker's approval + await etomicSwapNft.connect(accounts[1]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_APPROVED); + + const approveParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await etomicSwapNft.connect(accounts[0]).takerPaymentApprove(...approveParams).should.be.fulfilled; + + // should not allow to spend from invalid address + await etomicSwapNft.connect(accounts[0]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_HASH); + + // should not allow to spend with invalid amounts + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[0].address, + takerSecretHash, + makerSecret, + token.target, + ]; + + await etomicSwapNft.connect(accounts[1]).spendTakerPayment(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[0].address, + takerSecretHash, + makerSecret, + token.target, + ]; + + await etomicSwapNft.connect(accounts[1]).spendTakerPayment(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + const balanceBefore = await token.balanceOf(accounts[1].address); + + const gasPrice = ethers.parseUnits('100', 'gwei'); + await etomicSwapNft.connect(accounts[1]).spendTakerPayment(...spendParams, { + gasPrice + }).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[1].address); + // Check receiver balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('0.9')); + + const dexFeeAddrBalance = await token.balanceOf(dexFeeAddr); + expect(dexFeeAddrBalance).to.equal(ethers.parseEther('0.1')); + + const payment = await etomicSwapNft.takerPayments(id); + + expect(Number(payment[3])).to.equal(MAKER_SPENT); + + // Do not allow to spend again + await etomicSwapNft.connect(accounts[1]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_APPROVED); + }); + + it('should allow taker to refund approved ETH payment after locktime', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + const params = [ + id, + ethers.parseEther('0.1'), // dexFee + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime + ]; + + let takerSwapRunner = etomicSwapNft.connect(accounts[0]); + let makerSwapRunner = etomicSwapNft.connect(accounts[1]); + + // Not allow to refund if payment was not sent + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the ETH payment + await takerSwapRunner.ethTakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + const approveParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr, + ]; + await takerSwapRunner.takerPaymentApprove(...approveParams).should.be.fulfilled; + + // Not allow to refund before locktime + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(REFUND_TIMESTAMP_NOT_REACHED); + + // Simulate time passing to exceed the locktime + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-taker address + await makerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + await takerSwapRunner.refundTakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + await takerSwapRunner.refundTakerPaymentTimelock(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await ethers.provider.getBalance(accounts[0].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const tx = await takerSwapRunner.refundTakerPaymentTimelock(...refundParams, { + gasPrice + }).should.be.fulfilled; + + const receipt = await tx.wait(); + const gasUsed = ethers.parseUnits(receipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[0].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapNft.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Not allow to refund again + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to refund non-approved ETH payment only after pre-approve locktime', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.1'), // dexFee + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let takerSwapRunner = etomicSwapNft.connect(accounts[0]); + let makerSwapRunner = etomicSwapNft.connect(accounts[1]); + + // Not allow to refund if payment was not sent + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the ETH payment + await takerSwapRunner.ethTakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + await advanceTimeAndMine(2000); + + // Not allow to refund before pre-approve locktime + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(PRE_APPROVE_REFUND_TIMESTAMP_NOT_REACHED); + + // Simulate time passing to exceed the locktime + await advanceTimeAndMine(3000); + + // Not allow to call refund from non-sender address + await makerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await ethers.provider.getBalance(accounts[0].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const tx = await takerSwapRunner.refundTakerPaymentTimelock(...refundParams, { + gasPrice + }).should.be.fulfilled; + + const receipt = await tx.wait(); + const gasUsed = ethers.parseUnits(receipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[0].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapNft.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Not allow to refund again + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to refund approved ERC20 payment after locktime', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let takerSwapRunner = etomicSwapNft.connect(accounts[0]); + let makerSwapRunner = etomicSwapNft.connect(accounts[1]); + + await token.approve(etomicSwapNft.target, ethers.parseEther('1')); + // Make the ERC20 payment + await expect(takerSwapRunner.erc20TakerPayment(...params)).to.be.fulfilled; + + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + const approveParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await takerSwapRunner.takerPaymentApprove(...approveParams).should.be.fulfilled; + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(REFUND_TIMESTAMP_NOT_REACHED); + + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-sender address + await makerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await token.balanceOf(accounts[0].address); + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[0].address); + + // Check sender balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapNft.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Do not allow to refund again + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to refund non-approved ERC20 payment only after pre-approve locktime', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let takerSwapRunner = etomicSwapNft.connect(accounts[0]); + let makerSwapRunner = etomicSwapNft.connect(accounts[1]); + + await token.approve(etomicSwapNft.target, ethers.parseEther('1')); + // Make the ERC20 payment + await expect(takerSwapRunner.erc20TakerPayment(...params)).to.be.fulfilled; + + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await advanceTimeAndMine(2000); + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(PRE_APPROVE_REFUND_TIMESTAMP_NOT_REACHED); + + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-sender address + await makerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await token.balanceOf(accounts[0].address); + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[0].address); + + // Check sender balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapNft.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Do not allow to refund again + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to refund ETH payment using secret', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.1'), // dexFee + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let etomicSwapRunner0 = etomicSwapNft.connect(accounts[0]); + let etomicSwapRunner1 = etomicSwapNft.connect(accounts[1]); + + // Not allow to refund if payment was not sent + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecret, + makerSecretHash, + zeroAddr, + ]; + + await etomicSwapRunner0.refundTakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the ETH payment + await etomicSwapRunner0.ethTakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + // Not allow to call refund from non-sender address + await etomicSwapRunner1.refundTakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecret, + makerSecretHash, + zeroAddr, + ]; + + await etomicSwapRunner0.refundTakerPaymentSecret(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecret, + makerSecretHash, + zeroAddr, + ]; + + await etomicSwapRunner0.refundTakerPaymentSecret(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await ethers.provider.getBalance(accounts[0].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const tx = await etomicSwapRunner0.refundTakerPaymentSecret(...refundParams, { + gasPrice + }).should.be.fulfilled; + + const receipt = await tx.wait(); + const gasUsed = ethers.parseUnits(receipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[0].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapNft.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Not allow to refund again + await etomicSwapRunner0.refundTakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to refund ERC20 payment using secret', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let etomicSwapRunner0 = etomicSwapNft.connect(accounts[0]); + let etomicSwapRunner1 = etomicSwapNft.connect(accounts[1]); + + await token.approve(etomicSwapNft.target, ethers.parseEther('1')); + // Make the ERC20 payment + await expect(etomicSwapRunner0.erc20TakerPayment(...params)).to.be.fulfilled; + + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecret, + makerSecretHash, + token.target, + ]; + + // Not allow to call refund from non-sender address + await etomicSwapRunner1.refundTakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecret, + makerSecretHash, + token.target, + ]; + + await etomicSwapRunner0.refundTakerPaymentSecret(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecret, + makerSecretHash, + token.target, + ]; + + await etomicSwapRunner0.refundTakerPaymentSecret(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await token.balanceOf(accounts[0].address); + + await etomicSwapRunner0.refundTakerPaymentSecret(...refundParams).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[0].address); + + // Check sender balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapNft.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Do not allow to refund again + await etomicSwapRunner0.refundTakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); +}); \ No newline at end of file diff --git a/test/EtomicSwapTakerV2Test.js b/test/EtomicSwapTakerV2Test.js new file mode 100644 index 0000000..e80f458 --- /dev/null +++ b/test/EtomicSwapTakerV2Test.js @@ -0,0 +1,885 @@ +const { + expect +} = require("chai"); +const { + ethers +} = require("hardhat"); +const crypto = require('crypto'); + +require('chai') + .use(require('chai-as-promised')) + .should(); + +const INVALID_HASH = 'Invalid paymentHash'; +const INVALID_PAYMENT_STATE_SENT = 'Invalid payment state. Must be PaymentSent'; +const INVALID_PAYMENT_STATE_APPROVED = 'Invalid payment state. Must be TakerApproved'; +const REFUND_TIMESTAMP_NOT_REACHED = 'Current timestamp didn\'t exceed payment refund lock time'; +const PRE_APPROVE_REFUND_TIMESTAMP_NOT_REACHED = 'Current timestamp didn\'t exceed payment pre-approve lock time'; + +/** + * Advances the Ethereum Virtual Machine (EVM) time by a specified amount and then mines a new block. + * + * @param {number} increaseAmount The amount of time to advance in seconds. + * + * This function is used in Ethereum smart contract testing to simulate the passage of time. In the EVM, + * time is measured based on block timestamps. The 'evm_increaseTime' method increases the EVM's internal + * clock, but this change only affects the next mined block. Therefore, 'evm_mine' is called immediately + * afterwards to mine a new block, ensuring that the blockchain's timestamp is updated to reflect the time + * change. This approach is essential for testing time-dependent contract features like lock periods or deadlines. + */ +async function advanceTimeAndMine(increaseAmount) { + await ethers.provider.send("evm_increaseTime", [increaseAmount]); + await ethers.provider.send("evm_mine"); +} + +async function currentEvmTime() { + const block = await ethers.provider.getBlock("latest"); + return block.timestamp; +} + +const id = '0x' + crypto.randomBytes(32).toString('hex'); +const [TAKER_PAYMENT_UNINITIALIZED, TAKER_PAYMENT_SENT, TAKER_PAYMENT_APPROVED, MAKER_SPENT, TAKER_REFUNDED] = [0, 1, 2, 3, 4]; + +const takerSecret = crypto.randomBytes(32); +const takerSecretHash = '0x' + crypto.createHash('sha256').update(takerSecret).digest('hex'); + +const makerSecret = crypto.randomBytes(32); +const makerSecretHash = '0x' + crypto.createHash('sha256').update(makerSecret).digest('hex'); + +const invalidSecret = crypto.randomBytes(32); + +const zeroAddr = '0x0000000000000000000000000000000000000000'; +const dexFeeAddr = '0x9999999999999999999999999999999999999999'; + +describe("EtomicSwapTakerV2", function() { + + beforeEach(async function() { + accounts = await ethers.getSigners(); + + EtomicSwapTakerV2 = await ethers.getContractFactory("EtomicSwapTakerV2"); + etomicSwapTakerV2 = await EtomicSwapTakerV2.deploy(dexFeeAddr); + await etomicSwapTakerV2.waitForDeployment(); + + Token = await ethers.getContractFactory("Token"); + token = await Token.deploy(); + await token.waitForDeployment(); + + await token.transfer(accounts[1].address, ethers.parseEther('100')); + }); + + it('should create contract with uninitialized payments', async function() { + const takerPayment = await etomicSwapTakerV2.takerPayments(id); + expect(Number(takerPayment[3])).to.equal(TAKER_PAYMENT_UNINITIALIZED); + }); + + it('should allow taker to send ETH payment', async function() { + let currentTime = await currentEvmTime(); + const immediateRefundLockTime = currentTime + 100; + const paymentLockTime = currentTime + 100; + const params = [ + id, + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + immediateRefundLockTime, + paymentLockTime + ]; + // Make the ETH payment + await etomicSwapTakerV2.connect(accounts[0]).ethTakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + const payment = await etomicSwapTakerV2.takerPayments(id); + + expect(Number(payment[1])).to.equal(immediateRefundLockTime); + expect(Number(payment[2])).to.equal(paymentLockTime); + expect(Number(payment[3])).to.equal(TAKER_PAYMENT_SENT); + + // Check that it should not allow to send again + await etomicSwapTakerV2.connect(accounts[0]).ethTakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.rejectedWith("Taker payment is already initialized"); + }); + + it('should allow taker to send ERC20 payment', async function() { + const currentTime = await currentEvmTime(); + + const immediateRefundLockTime = currentTime + 10; + const paymentLockTime = currentTime + 100; + + const payment_params = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + immediateRefundLockTime, + paymentLockTime, + ]; + + let etomicSwapRunner0 = etomicSwapTakerV2.connect(accounts[0]); + + await token.approve(etomicSwapTakerV2.target, ethers.parseEther('1')); + // Make the ERC20 payment + await etomicSwapRunner0.erc20TakerPayment(...payment_params).should.be.fulfilled; + + // Check contract token balance + const balance = await token.balanceOf(etomicSwapTakerV2.target); + expect(balance).to.equal(ethers.parseEther('1')); + + const payment = await etomicSwapTakerV2.takerPayments(id); + + // Check locktime and status + expect(payment[1]).to.equal(BigInt(immediateRefundLockTime)); + expect(payment[2]).to.equal(BigInt(paymentLockTime)); + expect(payment[3]).to.equal(BigInt(TAKER_PAYMENT_SENT)); + + // Should not allow to send payment again + await etomicSwapRunner0.erc20TakerPayment(...payment_params).should.be.rejectedWith("ERC20 v2 payment is already initialized"); + }); + + it('should allow maker to spend ETH taker payment', async function() { + let currentTime = await currentEvmTime(); + const immediateRefundLockTime = currentTime + 100; + const paymentLockTime = currentTime + 100; + const payment_params = [ + id, + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + immediateRefundLockTime, + paymentLockTime + ]; + // Make the ETH payment + await etomicSwapTakerV2.connect(accounts[0]).ethTakerPayment(...payment_params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + const spendParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[0].address, + takerSecretHash, + makerSecret, + zeroAddr, + ]; + + // should not allow to spend before payment is approved by taker + await etomicSwapTakerV2.connect(accounts[1]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_APPROVED); + + const approveParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr, + ]; + + await etomicSwapTakerV2.connect(accounts[0]).takerPaymentApprove(...approveParams).should.be.fulfilled; + + // should not allow to spend from invalid address + await etomicSwapTakerV2.connect(accounts[0]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_HASH); + + // should not allow to spend with invalid amounts + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[0].address, + takerSecretHash, + makerSecret, + zeroAddr, + ]; + + await etomicSwapTakerV2.connect(accounts[1]).spendTakerPayment(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[0].address, + takerSecretHash, + makerSecret, + zeroAddr, + ]; + + await etomicSwapTakerV2.connect(accounts[1]).spendTakerPayment(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + const balanceBefore = await ethers.provider.getBalance(accounts[1].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const spendTx = await etomicSwapTakerV2.connect(accounts[1]).spendTakerPayment(...spendParams, { + gasPrice + }).should.be.fulfilled; + + const spendReceipt = await spendTx.wait(); + const gasUsed = ethers.parseUnits(spendReceipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[1].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('0.9')); + + const dexFeeAddrBalance = await ethers.provider.getBalance(dexFeeAddr); + expect(dexFeeAddrBalance).to.equal(ethers.parseEther('0.1')); + + const payment = await etomicSwapTakerV2.takerPayments(id); + + expect(Number(payment[3])).to.equal(MAKER_SPENT); + + // Do not allow to spend again + await etomicSwapTakerV2.connect(accounts[1]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_APPROVED); + }); + + it('should allow maker to spend ERC20 taker payment', async function() { + let currentTime = await currentEvmTime(); + const immediateRefundLockTime = currentTime + 100; + const paymentLockTime = currentTime + 100; + const payment_params = [ + id, + ethers.parseEther('0.9'), // amount + ethers.parseEther('0.1'), // dexFee + token.target, + accounts[1].address, // receiver + takerSecretHash, + makerSecretHash, + immediateRefundLockTime, + paymentLockTime + ]; + + // Make the ERC20 payment + await token.approve(etomicSwapTakerV2.target, ethers.parseEther('1')); + await etomicSwapTakerV2.connect(accounts[0]).erc20TakerPayment(...payment_params).should.be.fulfilled; + + const contractBalance = await token.balanceOf(etomicSwapTakerV2.target); + expect(contractBalance).to.equal(ethers.parseEther('1')); + + const spendParams = [ + id, + ethers.parseEther('0.9'), // amount + ethers.parseEther('0.1'), // dexFee + accounts[0].address, + takerSecretHash, + makerSecret, + token.target, // tokenAddress + ]; + + // should not allow to spend before taker's approval + await etomicSwapTakerV2.connect(accounts[1]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_APPROVED); + + const approveParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await etomicSwapTakerV2.connect(accounts[0]).takerPaymentApprove(...approveParams).should.be.fulfilled; + + // should not allow to spend from invalid address + await etomicSwapTakerV2.connect(accounts[0]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_HASH); + + // should not allow to spend with invalid amounts + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[0].address, + takerSecretHash, + makerSecret, + token.target, + ]; + + await etomicSwapTakerV2.connect(accounts[1]).spendTakerPayment(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[0].address, + takerSecretHash, + makerSecret, + token.target, + ]; + + await etomicSwapTakerV2.connect(accounts[1]).spendTakerPayment(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + const balanceBefore = await token.balanceOf(accounts[1].address); + + const gasPrice = ethers.parseUnits('100', 'gwei'); + await etomicSwapTakerV2.connect(accounts[1]).spendTakerPayment(...spendParams, { + gasPrice + }).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[1].address); + // Check receiver balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('0.9')); + + const dexFeeAddrBalance = await token.balanceOf(dexFeeAddr); + expect(dexFeeAddrBalance).to.equal(ethers.parseEther('0.1')); + + const payment = await etomicSwapTakerV2.takerPayments(id); + + expect(Number(payment[3])).to.equal(MAKER_SPENT); + + // Do not allow to spend again + await etomicSwapTakerV2.connect(accounts[1]).spendTakerPayment(...spendParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_APPROVED); + }); + + it('should allow taker to refund approved ETH payment after locktime', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.1'), // dexFee + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let takerSwapRunner = etomicSwapTakerV2.connect(accounts[0]); + let makerSwapRunner = etomicSwapTakerV2.connect(accounts[1]); + + // Not allow to refund if payment was not sent + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the ETH payment + await takerSwapRunner.ethTakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + const approveParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr, + ]; + + await takerSwapRunner.takerPaymentApprove(...approveParams).should.be.fulfilled; + + // Not allow to refund before locktime + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(REFUND_TIMESTAMP_NOT_REACHED); + + // Simulate time passing to exceed the locktime + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-sender address + await makerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await ethers.provider.getBalance(accounts[0].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const tx = await takerSwapRunner.refundTakerPaymentTimelock(...refundParams, { + gasPrice + }).should.be.fulfilled; + + const receipt = await tx.wait(); + const gasUsed = ethers.parseUnits(receipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[0].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapTakerV2.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Not allow to refund again + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to refund non-approved ETH payment only after pre-approve locktime', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.1'), // dexFee + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let takerSwapRunner = etomicSwapTakerV2.connect(accounts[0]); + let makerSwapRunner = etomicSwapTakerV2.connect(accounts[1]); + + // Not allow to refund if payment was not sent + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the ETH payment + await takerSwapRunner.ethTakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + await advanceTimeAndMine(2000); + + // Not allow to refund before pre-approve locktime + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(PRE_APPROVE_REFUND_TIMESTAMP_NOT_REACHED); + + // Simulate time passing to exceed the locktime + await advanceTimeAndMine(3000); + + // Not allow to call refund from non-sender address + await makerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + zeroAddr + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await ethers.provider.getBalance(accounts[0].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const tx = await takerSwapRunner.refundTakerPaymentTimelock(...refundParams, { + gasPrice + }).should.be.fulfilled; + + const receipt = await tx.wait(); + const gasUsed = ethers.parseUnits(receipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[0].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapTakerV2.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Not allow to refund again + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to refund approved ERC20 payment after locktime', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let takerSwapRunner = etomicSwapTakerV2.connect(accounts[0]); + let makerSwapRunner = etomicSwapTakerV2.connect(accounts[1]); + + await token.approve(etomicSwapTakerV2.target, ethers.parseEther('1')); + // Make the ERC20 payment + await expect(takerSwapRunner.erc20TakerPayment(...params)).to.be.fulfilled; + + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + const approveParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await takerSwapRunner.takerPaymentApprove(...approveParams).should.be.fulfilled; + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(REFUND_TIMESTAMP_NOT_REACHED); + + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-sender address + await makerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await token.balanceOf(accounts[0].address); + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[0].address); + + // Check sender balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapTakerV2.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Do not allow to refund again + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to refund non-approved ERC20 payment only after pre-approve locktime', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let takerSwapRunner = etomicSwapTakerV2.connect(accounts[0]); + let makerSwapRunner = etomicSwapTakerV2.connect(accounts[1]); + + await token.approve(etomicSwapTakerV2.target, ethers.parseEther('1')); + // Make the ERC20 payment + await expect(takerSwapRunner.erc20TakerPayment(...params)).to.be.fulfilled; + + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await advanceTimeAndMine(2000); + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(PRE_APPROVE_REFUND_TIMESTAMP_NOT_REACHED); + + await advanceTimeAndMine(1000); + + // Not allow to call refund from non-sender address + await makerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecretHash, + makerSecretHash, + token.target, + ]; + + await takerSwapRunner.refundTakerPaymentTimelock(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await token.balanceOf(accounts[0].address); + + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[0].address); + + // Check sender balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapTakerV2.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Do not allow to refund again + await takerSwapRunner.refundTakerPaymentTimelock(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to refund ETH payment using secret', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.1'), // dexFee + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let etomicSwapRunner0 = etomicSwapTakerV2.connect(accounts[0]); + let etomicSwapRunner1 = etomicSwapTakerV2.connect(accounts[1]); + + // Not allow to refund if payment was not sent + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecret, + makerSecretHash, + zeroAddr, + ]; + + await etomicSwapRunner0.refundTakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + + // Make the ETH payment + await etomicSwapRunner0.ethTakerPayment(...params, { + value: ethers.parseEther('1') + }).should.be.fulfilled; + + // Not allow to call refund from non-sender address + await etomicSwapRunner1.refundTakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecret, + makerSecretHash, + zeroAddr, + ]; + + await etomicSwapRunner0.refundTakerPaymentSecret(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecret, + makerSecretHash, + zeroAddr, + ]; + + await etomicSwapRunner0.refundTakerPaymentSecret(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await ethers.provider.getBalance(accounts[0].address); + const gasPrice = ethers.parseUnits('100', 'gwei'); + + const tx = await etomicSwapRunner0.refundTakerPaymentSecret(...refundParams, { + gasPrice + }).should.be.fulfilled; + + const receipt = await tx.wait(); + const gasUsed = ethers.parseUnits(receipt.gasUsed.toString(), 'wei'); + const txFee = gasUsed * gasPrice; + + const balanceAfter = await ethers.provider.getBalance(accounts[0].address); + // Check sender balance + expect((balanceAfter - balanceBefore + txFee)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapTakerV2.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Not allow to refund again + await etomicSwapRunner0.refundTakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); + + it('should allow taker to refund ERC20 payment using secret', async function() { + const preApproveLockTime = await currentEvmTime() + 3000; + const paymentLockTime = await currentEvmTime() + 1000; + + const params = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + token.target, + accounts[1].address, + takerSecretHash, + makerSecretHash, + preApproveLockTime, + paymentLockTime, + ]; + + let etomicSwapRunner0 = etomicSwapTakerV2.connect(accounts[0]); + let etomicSwapRunner1 = etomicSwapTakerV2.connect(accounts[1]); + + await token.approve(etomicSwapTakerV2.target, ethers.parseEther('1')); + // Make the ERC20 payment + await expect(etomicSwapRunner0.erc20TakerPayment(...params)).to.be.fulfilled; + + const refundParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecret, + makerSecretHash, + token.target, + ]; + + // Not allow to call refund from non-sender address + await etomicSwapRunner1.refundTakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_HASH); + + // Not allow to refund invalid amount + const invalidAmountParams = [ + id, + ethers.parseEther('0.8'), + ethers.parseEther('0.1'), + accounts[1].address, + takerSecret, + makerSecretHash, + token.target, + ]; + + await etomicSwapRunner0.refundTakerPaymentSecret(...invalidAmountParams).should.be.rejectedWith(INVALID_HASH); + + const invalidDexFeeParams = [ + id, + ethers.parseEther('0.9'), + ethers.parseEther('0.2'), + accounts[1].address, + takerSecret, + makerSecretHash, + token.target, + ]; + + await etomicSwapRunner0.refundTakerPaymentSecret(...invalidDexFeeParams).should.be.rejectedWith(INVALID_HASH); + + // Success refund + const balanceBefore = await token.balanceOf(accounts[0].address); + + await etomicSwapRunner0.refundTakerPaymentSecret(...refundParams).should.be.fulfilled; + + const balanceAfter = await token.balanceOf(accounts[0].address); + + // Check sender balance + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + expect((balanceAfter - balanceBefore)).to.equal(ethers.parseEther('1')); + + // Check the state of the payment + const payment = await etomicSwapTakerV2.takerPayments(id); + expect(payment.state).to.equal(BigInt(TAKER_REFUNDED)); + + // Do not allow to refund again + await etomicSwapRunner0.refundTakerPaymentSecret(...refundParams).should.be.rejectedWith(INVALID_PAYMENT_STATE_SENT); + }); +});