|
@@ -0,0 +1,333 @@
|
|
|
|
|
+// SPDX-License-Identifier: LGPLv3
|
|
|
|
|
+pragma solidity ^0.8.20;
|
|
|
|
|
+
|
|
|
|
|
+import {IERC20} from "../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
|
|
|
|
|
+import {SafeERC20} from "../lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
|
|
|
+import {Secp256k1} from "./Secp256k1.sol";
|
|
|
|
|
+
|
|
|
|
|
+// SwapCreator facilitates swapping between Alice, a party that has an EVM
|
|
|
|
|
+// native currency or a token (ERC-20 or compatible API) that she wants to
|
|
|
|
|
+// exchange cross-chain for a different currency, and Bob, a party that has the
|
|
|
|
|
+// other chain's currency and wishes to exchange it for Alice's currency.
|
|
|
|
|
+contract SwapCreator is Secp256k1 {
|
|
|
|
|
+ using SafeERC20 for IERC20;
|
|
|
|
|
+
|
|
|
|
|
+ // Stage represents the swap state. It is PENDING when `newSwap` is called
|
|
|
|
|
+ // to create and fund the swap. Alice sets Stage to READY, via `setReady`,
|
|
|
|
|
+ // after verifying that funds are locked on the other chain. Bob cannot
|
|
|
|
|
+ // claim the swap funds until Alice sets the swap Stage to READY. The Stage
|
|
|
|
|
+ // is set to COMPLETED when Bob claims directly via `claim` or indirectly
|
|
|
|
|
+ // via `claimRelayer`, or by Alice calling `refund`.
|
|
|
|
|
+ enum Stage {
|
|
|
|
|
+ INVALID,
|
|
|
|
|
+ PENDING,
|
|
|
|
|
+ READY,
|
|
|
|
|
+ COMPLETED
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // swaps maps from a swap ID to the swap's current Stage
|
|
|
|
|
+ mapping(bytes32 => Stage) public swaps;
|
|
|
|
|
+
|
|
|
|
|
+ // Swap stores the swap parameters, the hash of which forms the swap ID.
|
|
|
|
|
+ struct Swap {
|
|
|
|
|
+ // owner is the address of Alice, who initiates the swap by calling
|
|
|
|
|
+ // `newSwap`. Only the owner is allowed to call `setReady` or `refund`.
|
|
|
|
|
+ address payable owner;
|
|
|
|
|
+ // claimer is the address of Bob. Only the claimer can call `claim` or
|
|
|
|
|
+ // sign a RelaySwap object that `claimRelayer` will accept the signature
|
|
|
|
|
+ // for.
|
|
|
|
|
+ address payable claimer;
|
|
|
|
|
+ // claimCommitment is the Keccak-256 hash of the expected secp256k1
|
|
|
|
|
+ // public key derived from the secret (private key) that Bob sends when
|
|
|
|
|
+ // claiming. Alice receives this commitment off-chain.
|
|
|
|
|
+ bytes32 claimCommitment;
|
|
|
|
|
+ // refundCommitment is the Keccak-256 hash of the expected secp256k1
|
|
|
|
|
+ // public key derived from the secret (private key) that Alice sends if
|
|
|
|
|
+ // refunding.
|
|
|
|
|
+ bytes32 refundCommitment;
|
|
|
|
|
+ // timeout1 is the block timestamp before which Alice can call
|
|
|
|
|
+ // either `setReady` or `refund`.
|
|
|
|
|
+ uint256 timeout1;
|
|
|
|
|
+ // timeout2 is the block timestamp after which Bob cannot claim, only
|
|
|
|
|
+ // Alice can refund.
|
|
|
|
|
+ uint256 timeout2;
|
|
|
|
|
+ // asset is address(0) for EVM native currency swaps, or it is the
|
|
|
|
|
+ // address of the token that Alice is providing.
|
|
|
|
|
+ address asset;
|
|
|
|
|
+ // value is the wei or token unit amount that Alice locked in the contract
|
|
|
|
|
+ uint256 value;
|
|
|
|
|
+ // nonce is a random value chosen by Alice
|
|
|
|
|
+ uint256 nonce;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // RelaySwap contains additional information required for relayed claim
|
|
|
|
|
+ // transactions. This entire structure is encoded and signed by the swap
|
|
|
|
|
+ // claimer, and the signature is passed to `claimRelayer`.
|
|
|
|
|
+ struct RelaySwap {
|
|
|
|
|
+ // swap specifies which swap is being claimed
|
|
|
|
|
+ Swap swap;
|
|
|
|
|
+ // fee is the wei amount paid to the relayer
|
|
|
|
|
+ uint256 fee;
|
|
|
|
|
+ // relayerHash Keccak-256 hash of (relayer's payout address || 4-byte salt)
|
|
|
|
|
+ bytes32 relayerHash;
|
|
|
|
|
+ // swapCreator is the address of the swap's contract
|
|
|
|
|
+ address swapCreator;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ event New(
|
|
|
|
|
+ bytes32 swapID,
|
|
|
|
|
+ bytes32 claimKey,
|
|
|
|
|
+ bytes32 refundKey,
|
|
|
|
|
+ uint256 timeout1,
|
|
|
|
|
+ uint256 timeout2,
|
|
|
|
|
+ address asset,
|
|
|
|
|
+ uint256 value
|
|
|
|
|
+ );
|
|
|
|
|
+ event Ready(bytes32 indexed swapID);
|
|
|
|
|
+ event Claimed(bytes32 indexed swapID, bytes32 indexed s);
|
|
|
|
|
+ event Refunded(bytes32 indexed swapID, bytes32 indexed s);
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when the value parameter to `newSwap` is zero
|
|
|
|
|
+ error ZeroValue();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when either of the claimCommitment or refundCommitment parameters
|
|
|
|
|
+ // passed to `newSwap` are zero
|
|
|
|
|
+ error InvalidSwapKey();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when the claimer parameter for `newSwap` is the zero address
|
|
|
|
|
+ error InvalidClaimer();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when the timeout1 or timeout2 parameters for `newSwap` are zero
|
|
|
|
|
+ error InvalidTimeout();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when msg.value of a `newSwap` transaction has the wrong value
|
|
|
|
|
+ error InvalidValue();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when trying to initiate a swap with an ID that already exists
|
|
|
|
|
+ error SwapAlreadyExists();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when trying to call `setReady` on a swap that is not in the
|
|
|
|
|
+ // PENDING stage
|
|
|
|
|
+ error SwapNotPending();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when the caller of `setReady` or `refund` is not the swap owner
|
|
|
|
|
+ error OnlySwapOwner();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when the signer of the relayed transaction is not the swap's
|
|
|
|
|
+ // claimer
|
|
|
|
|
+ error OnlySwapClaimer();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when trying to call `claim` or `refund` on an invalid swap
|
|
|
|
|
+ error InvalidSwap();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when trying to call `claim` or `refund` on a swap that's already
|
|
|
|
|
+ // completed
|
|
|
|
|
+ error SwapCompleted();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when trying to call `claim` on a swap that's not set to ready or
|
|
|
|
|
+ // the first timeout has not been reached
|
|
|
|
|
+ error TooEarlyToClaim();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when trying to call `claim` on a swap where the second timeout has
|
|
|
|
|
+ // been reached
|
|
|
|
|
+ error TooLateToClaim();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when it's the counterparty's turn to claim and refunding is not
|
|
|
|
|
+ // allowed
|
|
|
|
|
+ error NotTimeToRefund();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when the provided secret does not match its expected public key
|
|
|
|
|
+ // hash
|
|
|
|
|
+ error InvalidSecret();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when the signature of a `RelaySwap` is invalid
|
|
|
|
|
+ error InvalidSignature();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when the SwapCreator address is a `RelaySwap` is not the address
|
|
|
|
|
+ // of this contract
|
|
|
|
|
+ error InvalidContractAddress();
|
|
|
|
|
+
|
|
|
|
|
+ // thrown when the hash of the relayer address and salt passed to
|
|
|
|
|
+ // `claimRelayer` does not match the relayer hash in `RelaySwap`
|
|
|
|
|
+ error InvalidRelayerAddress();
|
|
|
|
|
+
|
|
|
|
|
+ // `newSwap` creates a new Swap instance using the passed parameters and
|
|
|
|
|
+ // locks Alice's native EVM currency or token asset in the contract. On
|
|
|
|
|
+ // success, the swap ID is returned.
|
|
|
|
|
+ //
|
|
|
|
|
+ // Note that the duration values are distinct from the timeout values:
|
|
|
|
|
+ //
|
|
|
|
|
+ // _timeoutDuration1:
|
|
|
|
|
+ // duration, in seconds, between the current block timestamp and
|
|
|
|
|
+ // timeout1
|
|
|
|
|
+ //
|
|
|
|
|
+ // _timeoutDuration2:
|
|
|
|
|
+ // duration, in seconds, between timeout1 and timeout2
|
|
|
|
|
+ //
|
|
|
|
|
+ function newSwap(
|
|
|
|
|
+ bytes32 _claimCommitment,
|
|
|
|
|
+ bytes32 _refundCommitment,
|
|
|
|
|
+ address payable _claimer,
|
|
|
|
|
+ uint256 _timeoutDuration1,
|
|
|
|
|
+ uint256 _timeoutDuration2,
|
|
|
|
|
+ address _asset,
|
|
|
|
|
+ uint256 _value,
|
|
|
|
|
+ uint256 _nonce
|
|
|
|
|
+ ) public payable returns (bytes32) {
|
|
|
|
|
+ if (_value == 0) revert ZeroValue();
|
|
|
|
|
+ if (_asset == address(0)) {
|
|
|
|
|
+ if (_value != msg.value) revert InvalidValue();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // transfer the token amount to this contract
|
|
|
|
|
+ // WARN: fee-on-transfer tokens are not supported
|
|
|
|
|
+ IERC20(_asset).safeTransferFrom(msg.sender, address(this), _value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (_claimCommitment == 0 || _refundCommitment == 0) revert InvalidSwapKey();
|
|
|
|
|
+ if (_claimer == address(0)) revert InvalidClaimer();
|
|
|
|
|
+ if (_timeoutDuration1 == 0 || _timeoutDuration2 == 0) revert InvalidTimeout();
|
|
|
|
|
+
|
|
|
|
|
+ Swap memory swap = Swap({
|
|
|
|
|
+ owner: payable(msg.sender),
|
|
|
|
|
+ claimCommitment: _claimCommitment,
|
|
|
|
|
+ refundCommitment: _refundCommitment,
|
|
|
|
|
+ claimer: _claimer,
|
|
|
|
|
+ timeout1: block.timestamp + _timeoutDuration1,
|
|
|
|
|
+ timeout2: block.timestamp + _timeoutDuration1 + _timeoutDuration2,
|
|
|
|
|
+ asset: _asset,
|
|
|
|
|
+ value: _value,
|
|
|
|
|
+ nonce: _nonce
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ bytes32 swapID = keccak256(abi.encode(swap));
|
|
|
|
|
+
|
|
|
|
|
+ // ensure that we are not overriding an existing swap
|
|
|
|
|
+ if (swaps[swapID] != Stage.INVALID) revert SwapAlreadyExists();
|
|
|
|
|
+
|
|
|
|
|
+ emit New(
|
|
|
|
|
+ swapID,
|
|
|
|
|
+ _claimCommitment,
|
|
|
|
|
+ _refundCommitment,
|
|
|
|
|
+ swap.timeout1,
|
|
|
|
|
+ swap.timeout2,
|
|
|
|
|
+ swap.asset,
|
|
|
|
|
+ swap.value
|
|
|
|
|
+ );
|
|
|
|
|
+ swaps[swapID] = Stage.PENDING;
|
|
|
|
|
+ return swapID;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Alice should call `setReady` before timeout1 and after verifying that Bob
|
|
|
|
|
+ // locked his swap funds.
|
|
|
|
|
+ function setReady(Swap memory _swap) public {
|
|
|
|
|
+ bytes32 swapID = keccak256(abi.encode(_swap));
|
|
|
|
|
+ if (swaps[swapID] != Stage.PENDING) revert SwapNotPending();
|
|
|
|
|
+ if (_swap.owner != msg.sender) revert OnlySwapOwner();
|
|
|
|
|
+ swaps[swapID] = Stage.READY;
|
|
|
|
|
+ emit Ready(swapID);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Bob can call `claim` if either of these hold true:
|
|
|
|
|
+ // (1) Alice has set the swap to `ready` and it's before timeout1
|
|
|
|
|
+ // (2) It is between timeout1 and timeout2
|
|
|
|
|
+ function claim(Swap memory _swap, bytes32 _secret) public {
|
|
|
|
|
+ if (msg.sender != _swap.claimer) revert OnlySwapClaimer();
|
|
|
|
|
+ _claim(_swap, _secret);
|
|
|
|
|
+
|
|
|
|
|
+ if (_swap.asset == address(0)) {
|
|
|
|
|
+ // Transfer the swap value as the EVM's native currency
|
|
|
|
|
+ _swap.claimer.transfer(_swap.value);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Transfer the swap value as a token amount.
|
|
|
|
|
+ // WARNING: this will FAIL for fee-on-transfer or rebasing tokens if
|
|
|
|
|
+ // the token transfer reverts (i.e. if this contract does not
|
|
|
|
|
+ // contain _swap.value tokens), exposing Bob's secret while giving
|
|
|
|
|
+ // him nothing.
|
|
|
|
|
+ IERC20(_swap.asset).safeTransfer(_swap.claimer, _swap.value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Anyone can call `claimRelayer` if they receive a signed _relaySwap object
|
|
|
|
|
+ // from Bob. The same rules for when Bob can call `claim` apply here when a
|
|
|
|
|
+ // 3rd party relays a claim for Bob. This version of claiming transfers a
|
|
|
|
|
+ // _relaySwap.fee to _relayer. To prevent front-running, while not requiring
|
|
|
|
|
+ // Bob to know the relayer's payout address, Bob only signs a salted hash of
|
|
|
|
|
+ // the relayer's payout address in _relaySwap.relayerHash.
|
|
|
|
|
+ // Note: claimRelayer will revert if the swap value is less than the relayer
|
|
|
|
|
+ // fee; in that case, Bob must call claim directly.
|
|
|
|
|
+ function claimRelayer(
|
|
|
|
|
+ RelaySwap memory _relaySwap,
|
|
|
|
|
+ bytes32 _secret,
|
|
|
|
|
+ address payable _relayer,
|
|
|
|
|
+ uint32 _salt,
|
|
|
|
|
+ uint8 v,
|
|
|
|
|
+ bytes32 r,
|
|
|
|
|
+ bytes32 s
|
|
|
|
|
+ ) public {
|
|
|
|
|
+ address signer = ecrecover(keccak256(abi.encode(_relaySwap)), v, r, s);
|
|
|
|
|
+ if (signer != _relaySwap.swap.claimer) revert InvalidSignature();
|
|
|
|
|
+ if (address(this) != _relaySwap.swapCreator) revert InvalidContractAddress();
|
|
|
|
|
+ if (keccak256(abi.encodePacked(_relayer, _salt)) != _relaySwap.relayerHash)
|
|
|
|
|
+ revert InvalidRelayerAddress();
|
|
|
|
|
+
|
|
|
|
|
+ _claim(_relaySwap.swap, _secret);
|
|
|
|
|
+
|
|
|
|
|
+ // send ether to swap claimer, subtracting the relayer fee
|
|
|
|
|
+ if (_relaySwap.swap.asset == address(0)) {
|
|
|
|
|
+ _relaySwap.swap.claimer.transfer(_relaySwap.swap.value - _relaySwap.fee);
|
|
|
|
|
+ payable(_relayer).transfer(_relaySwap.fee);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // WARN: this will FAIL for fee-on-transfer or rebasing tokens if the token
|
|
|
|
|
+ // transfer reverts (i.e. if this contract does not contain _swap.value tokens),
|
|
|
|
|
+ // exposing Bob's secret while giving him nothing.
|
|
|
|
|
+ IERC20(_relaySwap.swap.asset).safeTransfer(
|
|
|
|
|
+ _relaySwap.swap.claimer,
|
|
|
|
|
+ _relaySwap.swap.value - _relaySwap.fee
|
|
|
|
|
+ );
|
|
|
|
|
+ IERC20(_relaySwap.swap.asset).safeTransfer(_relayer, _relaySwap.fee);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function _claim(Swap memory _swap, bytes32 _secret) internal {
|
|
|
|
|
+ bytes32 swapID = keccak256(abi.encode(_swap));
|
|
|
|
|
+ Stage swapStage = swaps[swapID];
|
|
|
|
|
+ if (swapStage == Stage.INVALID) revert InvalidSwap();
|
|
|
|
|
+ if (swapStage == Stage.COMPLETED) revert SwapCompleted();
|
|
|
|
|
+ if (block.timestamp < _swap.timeout1 && swapStage != Stage.READY) revert TooEarlyToClaim();
|
|
|
|
|
+ if (block.timestamp >= _swap.timeout2) revert TooLateToClaim();
|
|
|
|
|
+
|
|
|
|
|
+ verifySecret(_secret, _swap.claimCommitment);
|
|
|
|
|
+ emit Claimed(swapID, _secret);
|
|
|
|
|
+ swaps[swapID] = Stage.COMPLETED;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Alice can `refund` her swap funds:
|
|
|
|
|
+ // - Until timeout1, unless she called `setReady`
|
|
|
|
|
+ // - After timeout2, independent of whether she called `setReady`
|
|
|
|
|
+ function refund(Swap memory _swap, bytes32 _secret) public {
|
|
|
|
|
+ bytes32 swapID = keccak256(abi.encode(_swap));
|
|
|
|
|
+ Stage swapStage = swaps[swapID];
|
|
|
|
|
+ if (swapStage == Stage.INVALID) revert InvalidSwap();
|
|
|
|
|
+ if (swapStage == Stage.COMPLETED) revert SwapCompleted();
|
|
|
|
|
+ if (_swap.owner != msg.sender) revert OnlySwapOwner();
|
|
|
|
|
+ if (
|
|
|
|
|
+ block.timestamp < _swap.timeout2 &&
|
|
|
|
|
+ (block.timestamp > _swap.timeout1 || swapStage == Stage.READY)
|
|
|
|
|
+ ) revert NotTimeToRefund();
|
|
|
|
|
+
|
|
|
|
|
+ verifySecret(_secret, _swap.refundCommitment);
|
|
|
|
|
+ emit Refunded(swapID, _secret);
|
|
|
|
|
+
|
|
|
|
|
+ // send asset back to swap owner
|
|
|
|
|
+ swaps[swapID] = Stage.COMPLETED;
|
|
|
|
|
+ if (_swap.asset == address(0)) {
|
|
|
|
|
+ _swap.owner.transfer(_swap.value);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ IERC20(_swap.asset).safeTransfer(_swap.owner, _swap.value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function verifySecret(bytes32 _secret, bytes32 _hashedPubkey) internal pure {
|
|
|
|
|
+ if (!mulVerify(uint256(_secret), uint256(_hashedPubkey))) revert InvalidSecret();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|