├── .gitignore ├── LICENSE.md ├── README.md ├── contracts ├── common │ ├── EIP712.sol │ ├── MEMETH.sol │ ├── PermitExecutor.sol │ ├── SignatureVerification.sol │ └── interfaces │ │ ├── IEIP2612.sol │ │ └── IPermit2.sol ├── erc20 │ ├── MemswapERC20.sol │ └── interfaces │ │ └── ISolutionERC20.sol ├── erc721 │ ├── MemswapERC721.sol │ └── interfaces │ │ └── ISolutionERC721.sol ├── mocks │ ├── MockERC20.sol │ ├── MockERC721.sol │ └── MockSolutionProxy.sol ├── nft │ └── MemswapAlphaNFT.sol └── solution │ └── SolutionProxy.sol ├── docker-compose.yaml ├── hardhat.config.ts ├── package.json ├── scripts ├── deploy.ts ├── intent-erc20.ts ├── intent-erc721.ts └── seaport.ts ├── src ├── common │ ├── addresses.ts │ ├── config.ts │ ├── flashbots-monkey-patch.ts │ ├── logger.ts │ ├── reservoir.ts │ ├── tx.ts │ ├── types.ts │ └── utils.ts ├── matchmaker │ ├── .env.example │ ├── README.md │ ├── config.ts │ ├── index.ts │ ├── jobs │ │ ├── index.ts │ │ ├── submission-erc20.ts │ │ └── submission-erc721.ts │ ├── redis.ts │ ├── solutions │ │ ├── erc20.ts │ │ ├── erc721.ts │ │ └── index.ts │ └── types.ts └── solver │ ├── .env.example │ ├── README.md │ ├── config.ts │ ├── index.ts │ ├── jobs │ ├── index.ts │ ├── inventory-manager.ts │ ├── seaport-solver.ts │ ├── tx-listener.ts │ ├── tx-solver-erc20.ts │ └── tx-solver-erc721.ts │ ├── redis.ts │ ├── solutions │ ├── index.ts │ ├── reservoir.ts │ ├── uniswap.ts │ └── zeroex.ts │ └── types.ts ├── test ├── erc20 │ ├── authorization.test.ts │ ├── bulk-signing.test.ts │ ├── incentivization.test.ts │ ├── misc.test.ts │ ├── random.test.ts │ └── utils.ts ├── erc721 │ ├── authorization.test.ts │ ├── bulk-signing.test.ts │ ├── criteria.test.ts │ ├── incentivization.test.ts │ ├── misc.test.ts │ ├── random.test.ts │ └── utils.ts ├── memeth.test.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Secrets 5 | .env*.sh 6 | 7 | # Build 8 | dist 9 | 10 | # Misc 11 | artifacts 12 | cache 13 | tenderly.yaml 14 | yarn-error.log 15 | 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | MIT License 4 | 5 | Copyright (c) 2023 Memswap 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | 9 | of this software and associated documentation files (the "Software"), to deal 10 | 11 | in the Software without restriction, including without limitation the rights 12 | 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | 15 | copies of the Software, and to permit persons to whom the Software is 16 | 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | 25 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 26 | 27 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | 29 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 30 | 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | 33 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 34 | 35 | SOFTWARE. 36 | 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Memswap 2 | 3 | Memswap is a fully decentralized intent-based swap protocol that leverages the public mempool for distribution. 4 | 5 | Repository structure: 6 | 7 | - [`contracts`](./contracts): core contracts (ALPHA - UNAUDITED) 8 | - [`test`](./test): contract tests 9 | - [`common`](./src/common): logic shared between the solver and the matchmaker 10 | - [`matchmaker`](./src/matchmaker): reference matchmaker implementation 11 | - [`solver`](./src/solver): reference solver implementation 12 | -------------------------------------------------------------------------------- /contracts/common/EIP712.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | contract EIP712 { 5 | // --- Public fields --- 6 | 7 | bytes32 public immutable DOMAIN_SEPARATOR; 8 | 9 | // --- Constructor --- 10 | 11 | constructor(bytes memory name, bytes memory version) { 12 | uint256 chainId; 13 | assembly { 14 | chainId := chainid() 15 | } 16 | 17 | DOMAIN_SEPARATOR = keccak256( 18 | abi.encode( 19 | keccak256( 20 | "EIP712Domain(" 21 | "string name," 22 | "string version," 23 | "uint256 chainId," 24 | "address verifyingContract" 25 | ")" 26 | ), 27 | keccak256(name), 28 | keccak256(version), 29 | chainId, 30 | address(this) 31 | ) 32 | ); 33 | } 34 | 35 | // --- Internal methods --- 36 | 37 | /** 38 | * @dev Get the EIP712 hash of a struct hash 39 | * 40 | * @param structHash Struct hash to get the EIP712 hash for 41 | * 42 | * @return eip712Hash The resulting EIP712 hash 43 | */ 44 | function _getEIP712Hash( 45 | bytes32 structHash 46 | ) internal view returns (bytes32 eip712Hash) { 47 | eip712Hash = keccak256( 48 | abi.encodePacked(hex"1901", DOMAIN_SEPARATOR, structHash) 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /contracts/common/MEMETH.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.9; 3 | 4 | // Fork of the canonical `WETH9` contract that supports `depositAndApprove` 5 | contract MEMETH { 6 | string public name = "Memswap Ether"; 7 | string public symbol = "MEMETH"; 8 | uint8 public decimals = 18; 9 | 10 | event Approval(address indexed src, address indexed guy, uint256 wad); 11 | event Transfer(address indexed src, address indexed dst, uint256 wad); 12 | event Deposit(address indexed dst, uint256 wad); 13 | event Withdrawal(address indexed src, uint256 wad); 14 | 15 | mapping(address => uint256) public balanceOf; 16 | mapping(address => mapping(address => uint256)) public allowance; 17 | 18 | receive() external payable { 19 | deposit(); 20 | } 21 | 22 | function deposit() public payable { 23 | balanceOf[msg.sender] += msg.value; 24 | emit Deposit(msg.sender, msg.value); 25 | } 26 | 27 | function depositAndApprove(address guy, uint256 wad) public payable { 28 | deposit(); 29 | approve(guy, wad); 30 | } 31 | 32 | function withdraw(uint256 wad) public { 33 | require(balanceOf[msg.sender] >= wad); 34 | balanceOf[msg.sender] -= wad; 35 | payable(msg.sender).transfer(wad); 36 | emit Withdrawal(msg.sender, wad); 37 | } 38 | 39 | function totalSupply() public view returns (uint256) { 40 | return address(this).balance; 41 | } 42 | 43 | function approve(address guy, uint256 wad) public returns (bool) { 44 | allowance[msg.sender][guy] = wad; 45 | emit Approval(msg.sender, guy, wad); 46 | return true; 47 | } 48 | 49 | function transfer(address dst, uint256 wad) public returns (bool) { 50 | return transferFrom(msg.sender, dst, wad); 51 | } 52 | 53 | function transferFrom( 54 | address src, 55 | address dst, 56 | uint256 wad 57 | ) public returns (bool) { 58 | require(balanceOf[src] >= wad); 59 | 60 | if ( 61 | src != msg.sender && allowance[src][msg.sender] != type(uint256).max 62 | ) { 63 | require(allowance[src][msg.sender] >= wad); 64 | allowance[src][msg.sender] -= wad; 65 | } 66 | 67 | balanceOf[src] -= wad; 68 | balanceOf[dst] += wad; 69 | 70 | emit Transfer(src, dst, wad); 71 | 72 | return true; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /contracts/common/PermitExecutor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {IPermit2} from "./interfaces/IPermit2.sol"; 5 | import {IEIP2612} from "./interfaces/IEIP2612.sol"; 6 | 7 | contract PermitExecutor { 8 | // --- Structs and enums --- 9 | 10 | enum Kind { 11 | EIP2612, 12 | PERMIT2 13 | } 14 | 15 | struct Permit { 16 | Kind kind; 17 | bytes data; 18 | } 19 | 20 | // --- Public fields --- 21 | 22 | address public immutable permit2 = 23 | 0x000000000022D473030F116dDEE9F6B43aC78BA3; 24 | 25 | // --- Modifiers --- 26 | 27 | /** 28 | * @dev Execute permits 29 | * 30 | * @param permits Permits to execute 31 | */ 32 | modifier executePermits(Permit[] calldata permits) { 33 | unchecked { 34 | uint256 permitsLength = permits.length; 35 | for (uint256 i; i < permitsLength; i++) { 36 | Permit calldata permit = permits[i]; 37 | if (permit.kind == Kind.EIP2612) { 38 | ( 39 | address token, 40 | address owner, 41 | address spender, 42 | uint256 value, 43 | uint256 deadline, 44 | uint8 v, 45 | bytes32 r, 46 | bytes32 s 47 | ) = abi.decode( 48 | permit.data, 49 | ( 50 | address, 51 | address, 52 | address, 53 | uint256, 54 | uint256, 55 | uint8, 56 | bytes32, 57 | bytes32 58 | ) 59 | ); 60 | 61 | IEIP2612(token).permit( 62 | owner, 63 | spender, 64 | value, 65 | deadline, 66 | v, 67 | r, 68 | s 69 | ); 70 | } else { 71 | ( 72 | address owner, 73 | IPermit2.PermitSingle memory permitSingle, 74 | bytes memory signature 75 | ) = abi.decode( 76 | permit.data, 77 | (address, IPermit2.PermitSingle, bytes) 78 | ); 79 | 80 | IPermit2(permit2).permit(owner, permitSingle, signature); 81 | } 82 | } 83 | } 84 | 85 | _; 86 | } 87 | 88 | // --- Internal methods --- 89 | 90 | function _permit2TransferFrom( 91 | address from, 92 | address to, 93 | uint160 amount, 94 | address token 95 | ) internal { 96 | IPermit2(permit2).transferFrom(from, to, amount, token); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /contracts/common/SignatureVerification.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {EIP712} from "./EIP712.sol"; 5 | 6 | // Copied from Seaport's source code 7 | abstract contract SignatureVerification is EIP712 { 8 | // --- Errors --- 9 | 10 | error InvalidSignature(); 11 | 12 | // --- Virtual methods --- 13 | 14 | function _lookupBulkOrderTypehash( 15 | uint256 treeHeight 16 | ) internal pure virtual returns (bytes32 typeHash); 17 | 18 | // --- Internal methods --- 19 | 20 | function _verifySignature( 21 | bytes32 intentHash, 22 | address signer, 23 | bytes memory signature 24 | ) internal view { 25 | // Skip signature verification if the signer is the caller 26 | if (signer == msg.sender) { 27 | return; 28 | } 29 | 30 | bytes32 originalDigest = _getEIP712Hash(intentHash); 31 | uint256 originalSignatureLength = signature.length; 32 | 33 | bytes32 digest; 34 | if (_isValidBulkOrderSize(originalSignatureLength)) { 35 | (intentHash) = _computeBulkOrderProof(signature, intentHash); 36 | digest = _getEIP712Hash(intentHash); 37 | } else { 38 | digest = originalDigest; 39 | } 40 | 41 | _assertValidSignature( 42 | signer, 43 | digest, 44 | originalDigest, 45 | originalSignatureLength, 46 | signature 47 | ); 48 | } 49 | 50 | function _isValidBulkOrderSize( 51 | uint256 signatureLength 52 | ) internal pure returns (bool validLength) { 53 | // Utilize assembly to validate the length: 54 | // (64 + x) + 3 + 32y where (0 <= x <= 1) and (1 <= y <= 24) 55 | assembly { 56 | validLength := and( 57 | lt(sub(signatureLength, 0x63), 0x2e2), 58 | lt(and(add(signatureLength, 0x1d), 0x1f), 0x2) 59 | ) 60 | } 61 | } 62 | 63 | function _computeBulkOrderProof( 64 | bytes memory proofAndSignature, 65 | bytes32 leaf 66 | ) internal pure returns (bytes32 bulkOrderHash) { 67 | // Declare arguments for the root hash and the height of the proof 68 | bytes32 root; 69 | uint256 height; 70 | 71 | // Utilize assembly to efficiently derive the root hash using the proof 72 | assembly { 73 | // Retrieve the length of the proof, key, and signature combined 74 | let fullLength := mload(proofAndSignature) 75 | 76 | // If proofAndSignature has odd length, it is a compact signature with 64 bytes 77 | let signatureLength := sub(65, and(fullLength, 1)) 78 | 79 | // Derive height (or depth of tree) with signature and proof length 80 | height := shr(0x5, sub(fullLength, signatureLength)) 81 | 82 | // Update the length in memory to only include the signature 83 | mstore(proofAndSignature, signatureLength) 84 | 85 | // Derive the pointer for the key using the signature length 86 | let keyPtr := add(proofAndSignature, add(0x20, signatureLength)) 87 | 88 | // Retrieve the three-byte key using the derived pointer 89 | let key := shr(0xe8, mload(keyPtr)) 90 | 91 | // Retrieve pointer to first proof element by applying a constant for the key size to the derived key pointer 92 | let proof := add(keyPtr, 0x3) 93 | 94 | // Compute level 1 95 | let scratchPtr1 := shl(0x5, and(key, 1)) 96 | mstore(scratchPtr1, leaf) 97 | mstore(xor(scratchPtr1, 0x20), mload(proof)) 98 | 99 | // Compute remaining proofs 100 | for { 101 | let i := 1 102 | } lt(i, height) { 103 | i := add(i, 1) 104 | } { 105 | proof := add(proof, 0x20) 106 | let scratchPtr := shl(0x5, and(shr(i, key), 1)) 107 | mstore(scratchPtr, keccak256(0, 0x40)) 108 | mstore(xor(scratchPtr, 0x20), mload(proof)) 109 | } 110 | 111 | // Compute root hash 112 | root := keccak256(0, 0x40) 113 | } 114 | 115 | // Retrieve appropriate typehash constant based on height. 116 | bytes32 rootTypeHash = _lookupBulkOrderTypehash(height); 117 | 118 | // Use the typehash and the root hash to derive final bulk order hash 119 | assembly { 120 | mstore(0, rootTypeHash) 121 | mstore(0x20, root) 122 | bulkOrderHash := keccak256(0, 0x40) 123 | } 124 | } 125 | 126 | function _assertValidSignature( 127 | address signer, 128 | bytes32 digest, 129 | bytes32 originalDigest, 130 | uint256 originalSignatureLength, 131 | bytes memory signature 132 | ) internal view { 133 | // Declare value for ecrecover equality or 1271 call success status 134 | bool success; 135 | 136 | // Utilize assembly to perform optimized signature verification check 137 | assembly { 138 | // Ensure that first word of scratch space is empty 139 | mstore(0, 0) 140 | 141 | // Get the length of the signature. 142 | let signatureLength := mload(signature) 143 | 144 | // Get the pointer to the value preceding the signature length 145 | // This will be used for temporary memory overrides - either the signature head for isValidSignature or the digest for ecrecover 146 | let wordBeforeSignaturePtr := sub(signature, 0x20) 147 | 148 | // Cache the current value behind the signature to restore it later 149 | let cachedWordBeforeSignature := mload(wordBeforeSignaturePtr) 150 | 151 | // Declare lenDiff + recoveredSigner scope to manage stack pressure 152 | { 153 | // Take the difference between the max ECDSA signature length and the actual signature length (overflow desired for any values > 65) 154 | // If the diff is not 0 or 1, it is not a valid ECDSA signature - move on to EIP1271 check 155 | let lenDiff := sub(65, signatureLength) 156 | 157 | // Declare variable for recovered signer 158 | let recoveredSigner 159 | 160 | // If diff is 0 or 1, it may be an ECDSA signature 161 | // Try to recover signer 162 | if iszero(gt(lenDiff, 1)) { 163 | // Read the signature `s` value 164 | let originalSignatureS := mload(add(signature, 0x40)) 165 | 166 | // Read the first byte of the word after `s` 167 | // If the signature is 65 bytes, this will be the real `v` value 168 | // If not, it will need to be modified - doing it this way saves an extra condition. 169 | let v := byte(0, mload(add(signature, 0x60))) 170 | 171 | // If lenDiff is 1, parse 64-byte signature as ECDSA 172 | if lenDiff { 173 | // Extract yParity from highest bit of vs and add 27 to get v 174 | v := add(shr(0xff, originalSignatureS), 27) 175 | 176 | // Extract canonical s from vs, all but the highest bit 177 | // Temporarily overwrite the original `s` value in the signature 178 | mstore( 179 | add(signature, 0x40), 180 | and( 181 | originalSignatureS, 182 | 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 183 | ) 184 | ) 185 | } 186 | // Temporarily overwrite the signature length with `v` to conform to the expected input for ecrecover 187 | mstore(signature, v) 188 | 189 | // Temporarily overwrite the word before the length with `digest` to conform to the expected input for ecrecover 190 | mstore(wordBeforeSignaturePtr, digest) 191 | 192 | // Attempt to recover the signer for the given signature 193 | // Do not check the call status as ecrecover will return a null address if the signature is invalid 194 | pop( 195 | staticcall( 196 | gas(), 197 | 0x1, // Call ecrecover precompile 198 | wordBeforeSignaturePtr, // Use data memory location 199 | 0x80, // Size of digest, v, r, and s 200 | 0, // Write result to scratch space 201 | 0x20 // Provide size of returned result 202 | ) 203 | ) 204 | 205 | // Restore cached word before signature 206 | mstore(wordBeforeSignaturePtr, cachedWordBeforeSignature) 207 | 208 | // Restore cached signature length 209 | mstore(signature, signatureLength) 210 | 211 | // Restore cached signature `s` value 212 | mstore(add(signature, 0x40), originalSignatureS) 213 | 214 | // Read the recovered signer from the buffer given as return space for ecrecover 215 | recoveredSigner := mload(0) 216 | } 217 | 218 | // Set success to true if the signature provided was a valid 219 | // ECDSA signature and the signer is not the null address 220 | // Use gt instead of direct as success is used outside of assembly 221 | success := and(eq(signer, recoveredSigner), gt(signer, 0)) 222 | } 223 | 224 | // If the signature was not verified with ecrecover, try EIP1271 225 | if iszero(success) { 226 | // Reset the original signature length 227 | mstore(signature, originalSignatureLength) 228 | 229 | // Temporarily overwrite the word before the signature length and use it as the 230 | // head of the signature input to `isValidSignature`, which has a value of 64 231 | mstore(wordBeforeSignaturePtr, 0x40) 232 | 233 | // Get pointer to use for the selector of `isValidSignature` 234 | let selectorPtr := sub(signature, 0x44) 235 | 236 | // Cache the value currently stored at the selector pointer 237 | let cachedWordOverwrittenBySelector := mload(selectorPtr) 238 | 239 | // Cache the value currently stored at the digest pointer 240 | let cachedWordOverwrittenByDigest := mload(sub(signature, 0x40)) 241 | 242 | // Write the selector first, since it overlaps the digest 243 | mstore(selectorPtr, 0x44) 244 | 245 | // Next, write the original digest 246 | mstore(sub(signature, 0x40), originalDigest) 247 | 248 | // Call signer with `isValidSignature` to validate signature 249 | success := staticcall( 250 | gas(), 251 | signer, 252 | selectorPtr, 253 | add(originalSignatureLength, 0x64), 254 | 0, 255 | 0x20 256 | ) 257 | 258 | // Determine if the signature is valid on successful calls 259 | if success { 260 | // If first word of scratch space does not contain EIP-1271 signature selector, revert 261 | if iszero( 262 | eq( 263 | mload(0), 264 | 0x1626ba7e00000000000000000000000000000000000000000000000000000000 265 | ) 266 | ) { 267 | success := 0 268 | } 269 | } 270 | 271 | // Restore the cached values overwritten by selector, digest and signature head 272 | mstore(wordBeforeSignaturePtr, cachedWordBeforeSignature) 273 | mstore(selectorPtr, cachedWordOverwrittenBySelector) 274 | mstore(sub(signature, 0x40), cachedWordOverwrittenByDigest) 275 | } 276 | } 277 | 278 | if (!success) { 279 | revert InvalidSignature(); 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /contracts/common/interfaces/IEIP2612.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | interface IEIP2612 { 5 | function permit( 6 | address owner, 7 | address spender, 8 | uint256 value, 9 | uint256 deadline, 10 | uint8 v, 11 | bytes32 r, 12 | bytes32 s 13 | ) external; 14 | } 15 | -------------------------------------------------------------------------------- /contracts/common/interfaces/IPermit2.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | interface IPermit2 { 5 | event Permit( 6 | address indexed owner, 7 | address indexed token, 8 | address indexed spender, 9 | uint160 amount, 10 | uint48 expiration, 11 | uint48 nonce 12 | ); 13 | 14 | struct PermitDetails { 15 | address token; 16 | uint160 amount; 17 | uint48 expiration; 18 | uint48 nonce; 19 | } 20 | 21 | struct PermitSingle { 22 | PermitDetails details; 23 | address spender; 24 | uint256 sigDeadline; 25 | } 26 | 27 | function permit( 28 | address owner, 29 | PermitSingle memory permitSingle, 30 | bytes calldata signature 31 | ) external; 32 | 33 | function transferFrom( 34 | address from, 35 | address to, 36 | uint160 amount, 37 | address token 38 | ) external; 39 | } 40 | -------------------------------------------------------------------------------- /contracts/erc20/interfaces/ISolutionERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {MemswapERC20} from "../MemswapERC20.sol"; 5 | 6 | interface ISolutionERC20 { 7 | function callback( 8 | MemswapERC20.Intent memory intent, 9 | uint128 amountToFill, 10 | bytes memory data 11 | ) external; 12 | 13 | function refund() external payable; 14 | } 15 | -------------------------------------------------------------------------------- /contracts/erc721/interfaces/ISolutionERC721.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {MemswapERC721} from "../MemswapERC721.sol"; 5 | 6 | interface ISolutionERC721 { 7 | function callback( 8 | MemswapERC721.Intent memory intent, 9 | MemswapERC721.TokenDetails[] memory tokenDetailsToFill, 10 | bytes memory data 11 | ) external; 12 | 13 | function refund() external payable; 14 | } 15 | -------------------------------------------------------------------------------- /contracts/mocks/MockERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 6 | 7 | contract MockERC20 is ERC20 { 8 | constructor() ERC20("Mock", "MOCK") {} 9 | 10 | function mint(uint256 amount) external { 11 | _mint(msg.sender, amount); 12 | } 13 | 14 | // Mock implementations for EIP2612 (no checks are performed) 15 | 16 | function version() external pure returns (string memory) { 17 | return "1.0"; 18 | } 19 | 20 | function permit( 21 | address owner, 22 | address spender, 23 | uint256 value, 24 | uint256, 25 | uint8, 26 | bytes32, 27 | bytes32 28 | ) external { 29 | _approve(owner, spender, value); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /contracts/mocks/MockERC721.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 6 | 7 | contract MockERC721 is ERC721 { 8 | constructor() ERC721("Mock", "MOCK") {} 9 | 10 | function mint(uint256 tokenId) external { 11 | _mint(msg.sender, tokenId); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /contracts/nft/MemswapAlphaNFT.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import "@openzeppelin/contracts/access/Ownable.sol"; 6 | import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; 7 | import "@openzeppelin/contracts/utils/Strings.sol"; 8 | 9 | contract MemswapAlphaNFT is ERC1155, Ownable { 10 | using Strings for uint256; 11 | 12 | // --- Errors --- 13 | 14 | error Unauthorized(); 15 | 16 | // --- Fields --- 17 | 18 | // Public 19 | 20 | string public name; 21 | string public symbol; 22 | 23 | string public contractURI; 24 | mapping(address => bool) public isAllowedToMint; 25 | 26 | // Private 27 | 28 | uint256 private constant TOKEN_ID = 0; 29 | 30 | // --- Constructor --- 31 | 32 | constructor( 33 | address _owner, 34 | string memory _tokenURI, 35 | string memory _contractURI 36 | ) ERC1155(_tokenURI) { 37 | name = "Memswap Alpha NFT"; 38 | symbol = "MEM"; 39 | 40 | contractURI = _contractURI; 41 | 42 | _transferOwnership(_owner); 43 | } 44 | 45 | // --- Public methods --- 46 | 47 | function mint(address recipient) external { 48 | if (!isAllowedToMint[msg.sender]) { 49 | revert Unauthorized(); 50 | } 51 | 52 | _mint(recipient, TOKEN_ID, 1, ""); 53 | } 54 | 55 | // --- View methods --- 56 | 57 | function uri( 58 | uint256 tokenId 59 | ) public view virtual override returns (string memory) { 60 | return string(abi.encodePacked(super.uri(tokenId), tokenId.toString())); 61 | } 62 | 63 | // --- Owner methods --- 64 | 65 | function updateTokenURI(string memory newTokenURI) external onlyOwner { 66 | _setURI(newTokenURI); 67 | } 68 | 69 | function updateContractURI( 70 | string memory newContractURI 71 | ) external onlyOwner { 72 | contractURI = newContractURI; 73 | } 74 | 75 | function setIsAllowedToMint( 76 | address[] calldata minters, 77 | bool[] calldata allowed 78 | ) external onlyOwner { 79 | unchecked { 80 | for (uint256 i; i < minters.length; i++) { 81 | isAllowedToMint[minters[i]] = allowed[i]; 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /contracts/solution/SolutionProxy.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.9; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; 6 | 7 | import {MemswapERC20} from "../erc20/MemswapERC20.sol"; 8 | import {MemswapERC721} from "../erc721/MemswapERC721.sol"; 9 | import {PermitExecutor} from "../common/PermitExecutor.sol"; 10 | 11 | import {ISolutionERC20} from "../erc20/interfaces/ISolutionERC20.sol"; 12 | import {ISolutionERC721} from "../erc721/interfaces/ISolutionERC721.sol"; 13 | 14 | contract SolutionProxy is ISolutionERC20, ISolutionERC721 { 15 | // --- Structs --- 16 | 17 | struct Call { 18 | address to; 19 | bytes data; 20 | uint256 value; 21 | } 22 | 23 | // --- Errors --- 24 | 25 | error NotSupported(); 26 | error Unauthorized(); 27 | error UnsuccessfulCall(); 28 | 29 | // --- Fields --- 30 | 31 | address public owner; 32 | address public memswapERC20; 33 | address public memswapERC721; 34 | 35 | // --- Constructor --- 36 | 37 | constructor( 38 | address ownerAddress, 39 | address memswapERC20Address, 40 | address memswapERC721Address 41 | ) { 42 | owner = ownerAddress; 43 | memswapERC20 = memswapERC20Address; 44 | memswapERC721 = memswapERC721Address; 45 | } 46 | 47 | // --- Fallback --- 48 | 49 | receive() external payable {} 50 | 51 | // --- Modifiers --- 52 | 53 | modifier restrictCaller(address caller) { 54 | if (msg.sender != caller) { 55 | revert Unauthorized(); 56 | } 57 | 58 | _; 59 | } 60 | 61 | // --- Owner methods --- 62 | 63 | function transferOwnership( 64 | address newOwner 65 | ) external restrictCaller(owner) { 66 | owner = newOwner; 67 | } 68 | 69 | function updateMemswapERC20( 70 | address newMemswapERC20 71 | ) external restrictCaller(owner) { 72 | memswapERC20 = newMemswapERC20; 73 | } 74 | 75 | function updateMemswapERC721( 76 | address newMemswapERC721 77 | ) external restrictCaller(owner) { 78 | memswapERC721 = newMemswapERC721; 79 | } 80 | 81 | // --- Common --- 82 | 83 | function refund() 84 | external 85 | payable 86 | override(ISolutionERC20, ISolutionERC721) 87 | { 88 | makeCall(Call(owner, "", address(this).balance)); 89 | } 90 | 91 | // --- ERC20 --- 92 | 93 | function solveERC20( 94 | MemswapERC20.Intent calldata intent, 95 | MemswapERC20.Solution calldata solution, 96 | PermitExecutor.Permit[] calldata permits 97 | ) external payable restrictCaller(owner) { 98 | MemswapERC20(payable(memswapERC20)).solve{value: msg.value}( 99 | intent, 100 | solution, 101 | permits 102 | ); 103 | } 104 | 105 | function solveWithOnChainAuthorizationCheckERC20( 106 | MemswapERC20.Intent calldata intent, 107 | MemswapERC20.Solution calldata solution, 108 | PermitExecutor.Permit[] calldata permits 109 | ) external payable restrictCaller(owner) { 110 | MemswapERC20(payable(memswapERC20)).solveWithOnChainAuthorizationCheck{ 111 | value: msg.value 112 | }(intent, solution, permits); 113 | } 114 | 115 | function solveWithSignatureAuthorizationCheckERC20( 116 | MemswapERC20.Intent calldata intent, 117 | MemswapERC20.Solution calldata solution, 118 | MemswapERC20.Authorization calldata auth, 119 | bytes calldata authSignature, 120 | PermitExecutor.Permit[] calldata permits 121 | ) external payable restrictCaller(owner) { 122 | MemswapERC20(payable(memswapERC20)) 123 | .solveWithSignatureAuthorizationCheck{value: msg.value}( 124 | intent, 125 | solution, 126 | auth, 127 | authSignature, 128 | permits 129 | ); 130 | } 131 | 132 | function callback( 133 | MemswapERC20.Intent memory intent, 134 | uint128 amountToFill, 135 | bytes memory data 136 | ) external override restrictCaller(memswapERC20) { 137 | (uint128 amountToExecute, Call[] memory calls) = abi.decode( 138 | data, 139 | (uint128, Call[]) 140 | ); 141 | 142 | // Make calls 143 | unchecked { 144 | uint256 callsLength = calls.length; 145 | for (uint256 i; i < callsLength; i++) { 146 | makeCall(calls[i]); 147 | } 148 | } 149 | 150 | if (intent.isBuy) { 151 | // Push outputs to maker 152 | bool outputETH = intent.buyToken == address(0); 153 | if (outputETH) { 154 | makeCall(Call(intent.maker, "", amountToFill)); 155 | } else { 156 | IERC20(intent.buyToken).transfer(intent.maker, amountToFill); 157 | } 158 | 159 | uint256 amountLeft; 160 | 161 | // Take profits in sell token 162 | amountLeft = IERC20(intent.sellToken).balanceOf(address(this)); 163 | if (amountLeft > 0) { 164 | IERC20(intent.sellToken).transfer(owner, amountLeft); 165 | } 166 | 167 | // Take profits in native token 168 | amountLeft = address(this).balance; 169 | if (amountLeft > 0) { 170 | makeCall(Call(owner, "", amountLeft)); 171 | } 172 | } else { 173 | uint256 amountLeft; 174 | 175 | // Push outputs to maker 176 | bool outputETH = intent.buyToken == address(0); 177 | if (outputETH) { 178 | makeCall(Call(intent.maker, "", amountToExecute)); 179 | 180 | // Take profits in native token 181 | amountLeft = address(this).balance; 182 | if (amountLeft > 0) { 183 | makeCall(Call(owner, "", amountLeft)); 184 | } 185 | } else { 186 | IERC20(intent.buyToken).transfer(intent.maker, amountToExecute); 187 | 188 | // Take profits in buy token 189 | amountLeft = IERC20(intent.buyToken).balanceOf(address(this)); 190 | if (amountLeft > 0) { 191 | IERC20(intent.buyToken).transfer(owner, amountLeft); 192 | } 193 | } 194 | } 195 | } 196 | 197 | // --- ERC721 --- 198 | 199 | function solveERC721( 200 | MemswapERC721.Intent calldata intent, 201 | MemswapERC721.Solution calldata solution, 202 | PermitExecutor.Permit[] calldata permits 203 | ) external payable restrictCaller(owner) { 204 | MemswapERC721(payable(memswapERC721)).solve{value: msg.value}( 205 | intent, 206 | solution, 207 | permits 208 | ); 209 | } 210 | 211 | function solveWithOnChainAuthorizationCheckERC721( 212 | MemswapERC721.Intent calldata intent, 213 | MemswapERC721.Solution calldata solution, 214 | PermitExecutor.Permit[] calldata permits 215 | ) external payable restrictCaller(owner) { 216 | MemswapERC721(payable(memswapERC721)) 217 | .solveWithOnChainAuthorizationCheck{value: msg.value}( 218 | intent, 219 | solution, 220 | permits 221 | ); 222 | } 223 | 224 | function solveWithSignatureAuthorizationCheckERC721( 225 | MemswapERC721.Intent calldata intent, 226 | MemswapERC721.Solution calldata solution, 227 | MemswapERC721.Authorization calldata auth, 228 | bytes calldata authSignature, 229 | PermitExecutor.Permit[] calldata permits 230 | ) external payable restrictCaller(owner) { 231 | MemswapERC721(payable(memswapERC721)) 232 | .solveWithSignatureAuthorizationCheck{value: msg.value}( 233 | intent, 234 | solution, 235 | auth, 236 | authSignature, 237 | permits 238 | ); 239 | } 240 | 241 | function callback( 242 | MemswapERC721.Intent memory intent, 243 | MemswapERC721.TokenDetails[] memory, 244 | bytes memory data 245 | ) external override restrictCaller(memswapERC721) { 246 | Call[] memory calls = abi.decode(data, (Call[])); 247 | 248 | // Make calls 249 | unchecked { 250 | uint256 callsLength = calls.length; 251 | for (uint256 i; i < callsLength; i++) { 252 | makeCall(calls[i]); 253 | } 254 | } 255 | 256 | if (intent.isBuy) { 257 | uint256 amountLeft; 258 | 259 | // Take profits in sell token 260 | amountLeft = IERC20(intent.sellToken).balanceOf(address(this)); 261 | if (amountLeft > 0) { 262 | IERC20(intent.sellToken).transfer(owner, amountLeft); 263 | } 264 | 265 | // Take profits in native token 266 | amountLeft = address(this).balance; 267 | if (amountLeft > 0) { 268 | makeCall(Call(owner, "", amountLeft)); 269 | } 270 | } else { 271 | revert NotSupported(); 272 | } 273 | } 274 | 275 | // --- Internal methods --- 276 | 277 | function makeCall(Call memory call) internal { 278 | (bool success, ) = call.to.call{value: call.value}(call.data); 279 | if (!success) { 280 | revert UnsuccessfulCall(); 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | redis: 5 | image: redis:6.2.2 6 | command: sh -c "redis-server --requirepass password" 7 | networks: 8 | - local 9 | ports: 10 | - 6379:6379 11 | 12 | networks: 13 | local: 14 | driver: bridge 15 | -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import { HardhatUserConfig } from "hardhat/config"; 2 | 3 | import "@nomiclabs/hardhat-ethers"; 4 | import "@nomiclabs/hardhat-etherscan"; 5 | import "@nomiclabs/hardhat-waffle"; 6 | import "hardhat-gas-reporter"; 7 | import "hardhat-tracer"; 8 | 9 | const config: HardhatUserConfig = { 10 | solidity: { 11 | version: "0.8.19", 12 | settings: { 13 | viaIR: true, 14 | optimizer: { 15 | enabled: true, 16 | runs: 200, 17 | }, 18 | }, 19 | }, 20 | networks: { 21 | hardhat: { 22 | chainId: 1, 23 | forking: { 24 | url: String(process.env.RPC_URL), 25 | blockNumber: Number(process.env.BLOCK_NUMBER), 26 | }, 27 | }, 28 | localhost: { 29 | url: "http://127.0.0.1:8545", 30 | }, 31 | upstream: { 32 | url: String(process.env.RPC_URL), 33 | accounts: process.env.DEPLOYER_PK ? [process.env.DEPLOYER_PK] : undefined, 34 | }, 35 | }, 36 | etherscan: { 37 | apiKey: String(process.env.ETHERSCAN_API_KEY), 38 | }, 39 | gasReporter: { 40 | enabled: process.env.REPORT_GAS ? true : false, 41 | }, 42 | }; 43 | 44 | export default config; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "name": "memswap", 4 | "dependencies": { 5 | "@bull-board/express": "^5.8.0", 6 | "@flashbots/ethers-provider-bundle": "^0.6.2", 7 | "@georgeroman/evm-tx-simulator": "^0.0.28", 8 | "@nomicfoundation/hardhat-network-helpers": "^1.0.8", 9 | "@nomiclabs/hardhat-ethers": "^2.2.3", 10 | "@nomiclabs/hardhat-etherscan": "^3.1.7", 11 | "@nomiclabs/hardhat-waffle": "^2.0.6", 12 | "@openzeppelin/contracts": "^4.9.2", 13 | "@reservoir0x/sdk": "^0.0.342", 14 | "@types/chai": "^4.3.5", 15 | "@types/cors": "^2.8.14", 16 | "@types/express": "^4.17.17", 17 | "@types/ioredis": "^5.0.0", 18 | "@types/mocha": "^10.0.1", 19 | "@types/node-cron": "^3.0.8", 20 | "@types/ws": "^7", 21 | "@uniswap/smart-order-router": "^3.15.14", 22 | "alchemy-sdk": "^2.10.1", 23 | "axios": "^1.4.0", 24 | "bullmq": "^4.7.2", 25 | "chai": "^4.3.7", 26 | "cors": "^2.8.5", 27 | "ethereum-waffle": "^4.0.10", 28 | "ethers": "^5.7.2", 29 | "express": "^4.18.2", 30 | "hardhat": "^2.17.0", 31 | "hardhat-gas-reporter": "^1.0.9", 32 | "hardhat-tracer": "^2.5.1", 33 | "ioredis": "^5.3.2", 34 | "merkletreejs": "^0.3.10", 35 | "node-cron": "^3.0.2", 36 | "ts-node": "^10.9.1", 37 | "typescript": "^5.1.6", 38 | "winston": "^3.10.0", 39 | "ws": "^7" 40 | }, 41 | "scripts": { 42 | "build": "tsc -b", 43 | "start-matchmaker": "node ./dist/src/matchmaker/index.js", 44 | "start-solver": "node ./dist/src/solver/index.js" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /scripts/deploy.ts: -------------------------------------------------------------------------------- 1 | import { Interface } from "@ethersproject/abi"; 2 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 3 | import hre, { ethers } from "hardhat"; 4 | 5 | import { 6 | MEMSWAP_ERC20, 7 | MEMSWAP_ERC721, 8 | MEMSWAP_NFT, 9 | SOLVER, 10 | } from "../src/common/addresses"; 11 | 12 | const deployContract = async ( 13 | deployer: SignerWithAddress, 14 | name: string, 15 | constructorArguments: any[] = [] 16 | ) => { 17 | const contract = await ethers 18 | .getContractFactory(name, deployer) 19 | .then((factory: { deploy: (...args: any[]) => any }) => 20 | factory.deploy(...constructorArguments) 21 | ); 22 | console.log(`${name} deployed at ${contract.address.toLowerCase()}`); 23 | 24 | await new Promise((resolve) => setTimeout(resolve, 60 * 1000)); 25 | 26 | await hre.run("verify:verify", { 27 | address: contract.address, 28 | constructorArguments, 29 | }); 30 | 31 | return contract.address; 32 | }; 33 | 34 | const main = async () => { 35 | const [deployer] = await ethers.getSigners(); 36 | const chainId = await ethers.provider.getNetwork().then((n) => n.chainId); 37 | 38 | // Common 39 | // await deployContract(deployer, "MEMETH"); 40 | 41 | // MemswapNFT 42 | // MEMSWAP_NFT[chainId] = await deployContract(deployer, "MemswapAlphaNFT", [ 43 | // deployer.address, 44 | // "https://test-tokens-metadata.vercel.app/api/memswap-alpha/", 45 | // "https://test-tokens-metadata.vercel.app/api/memswap-alpha/contract", 46 | // ]); 47 | 48 | // MemswapERC20 49 | // MEMSWAP_ERC20[chainId] = await deployContract(deployer, "MemswapERC20", [ 50 | // MEMSWAP_NFT[chainId], 51 | // ]); 52 | 53 | // MemswapERC721 54 | // MEMSWAP_ERC721[chainId] = await deployContract(deployer, "MemswapERC721", [ 55 | // MEMSWAP_NFT[chainId], 56 | // ]); 57 | 58 | // SolutionProxy 59 | // await deployContract(deployer, "SolutionProxy", [ 60 | // SOLVER[chainId], 61 | // MEMSWAP_ERC20[chainId], 62 | // MEMSWAP_ERC721[chainId], 63 | // ]); 64 | 65 | // Set MemswapERC20 and MemswapERC721 as minters 66 | // await deployer.sendTransaction({ 67 | // to: MEMSWAP_NFT[chainId], 68 | // data: new Interface([ 69 | // "function setIsAllowedToMint(address[] minters, bool[] allowed)", 70 | // ]).encodeFunctionData("setIsAllowedToMint", [ 71 | // [MEMSWAP_ERC20[chainId], MEMSWAP_ERC721[chainId]], 72 | // [true, true], 73 | // ]), 74 | // }); 75 | }; 76 | 77 | main() 78 | .then(() => process.exit(0)) 79 | .catch((error) => { 80 | console.error(error); 81 | process.exit(1); 82 | }); 83 | -------------------------------------------------------------------------------- /scripts/intent-erc20.ts: -------------------------------------------------------------------------------- 1 | import { Interface, defaultAbiCoder } from "@ethersproject/abi"; 2 | import { AddressZero } from "@ethersproject/constants"; 3 | import { Contract } from "@ethersproject/contracts"; 4 | import { JsonRpcProvider } from "@ethersproject/providers"; 5 | import { parseUnits } from "@ethersproject/units"; 6 | import { Wallet } from "@ethersproject/wallet"; 7 | import axios from "axios"; 8 | 9 | import { 10 | MATCHMAKER, 11 | MEMSWAP_ERC20, 12 | MEMETH, 13 | USDC, 14 | WETH9, 15 | } from "../src/common/addresses"; 16 | import { 17 | getEIP712Domain, 18 | getEIP712TypesForIntent, 19 | now, 20 | } from "../src/common/utils"; 21 | import { IntentERC20, Protocol } from "../src/common/types"; 22 | 23 | // Required env variables: 24 | // - JSON_URL: url for the http provider 25 | // - MAKER_PK: private key of the maker 26 | // - MATCHMAKER_BASE_URL: base url of the matchmaker 27 | 28 | const main = async () => { 29 | const provider = new JsonRpcProvider(process.env.JSON_URL!); 30 | const maker = new Wallet(process.env.MAKER_PK!); 31 | 32 | const chainId = await provider.getNetwork().then((n) => n.chainId); 33 | const CURRENCIES = { 34 | ETH_IN: MEMETH[chainId], 35 | ETH_OUT: AddressZero, 36 | WETH: WETH9[chainId], 37 | USDC: USDC[chainId], 38 | }; 39 | 40 | const buyToken = CURRENCIES.USDC; 41 | const sellToken = CURRENCIES.ETH_IN; 42 | 43 | // Create intent 44 | const intent: IntentERC20 = { 45 | isBuy: false, 46 | buyToken, 47 | sellToken, 48 | maker: maker.address, 49 | solver: MATCHMAKER[chainId], 50 | source: AddressZero, 51 | feeBps: 0, 52 | surplusBps: 0, 53 | startTime: now() - 15, 54 | endTime: await provider 55 | .getBlock("latest") 56 | .then((b) => b!.timestamp + 3600 * 24), 57 | nonce: "0", 58 | isPartiallyFillable: false, 59 | isSmartOrder: false, 60 | isIncentivized: true, 61 | amount: parseUnits("0.01", 18).toString(), 62 | endAmount: parseUnits("5", 6).toString(), 63 | startAmountBps: 200, 64 | expectedAmountBps: 50, 65 | // Mock value to pass type checks 66 | signature: "0x", 67 | }; 68 | intent.signature = await maker._signTypedData( 69 | getEIP712Domain(chainId, Protocol.ERC20), 70 | getEIP712TypesForIntent(Protocol.ERC20), 71 | intent 72 | ); 73 | 74 | const memeth = new Contract( 75 | MEMETH[chainId], 76 | new Interface([ 77 | "function balanceOf(address owner) view returns (uint256)", 78 | "function approve(address spender, uint256 amount)", 79 | "function depositAndApprove(address spender, uint256 amount)", 80 | ]), 81 | provider 82 | ); 83 | 84 | const amountToApprove = !intent.isBuy ? intent.amount : intent.endAmount; 85 | 86 | // Generate approval transaction 87 | const approveMethod = 88 | sellToken === MEMETH[chainId] && 89 | (await memeth.balanceOf(maker.address)).lt(amountToApprove) 90 | ? "depositAndApprove" 91 | : "approve"; 92 | const data = 93 | memeth.interface.encodeFunctionData(approveMethod, [ 94 | MEMSWAP_ERC20[chainId], 95 | amountToApprove, 96 | ]) + 97 | defaultAbiCoder 98 | .encode( 99 | [ 100 | "bool", 101 | "address", 102 | "address", 103 | "address", 104 | "address", 105 | "address", 106 | "uint16", 107 | "uint16", 108 | "uint32", 109 | "uint32", 110 | "bool", 111 | "bool", 112 | "bool", 113 | "uint128", 114 | "uint128", 115 | "uint16", 116 | "uint16", 117 | "bytes", 118 | ], 119 | [ 120 | intent.isBuy, 121 | intent.buyToken, 122 | intent.sellToken, 123 | intent.maker, 124 | intent.solver, 125 | intent.source, 126 | intent.feeBps, 127 | intent.surplusBps, 128 | intent.startTime, 129 | intent.endTime, 130 | intent.isPartiallyFillable, 131 | intent.isSmartOrder, 132 | intent.isIncentivized, 133 | intent.amount, 134 | intent.endAmount, 135 | intent.startAmountBps, 136 | intent.expectedAmountBps, 137 | intent.signature, 138 | ] 139 | ) 140 | .slice(2); 141 | 142 | const currentBaseFee = await provider 143 | .getBlock("pending") 144 | .then((b) => b!.baseFeePerGas!); 145 | const nextBaseFee = currentBaseFee.add(currentBaseFee.mul(3000).div(10000)); 146 | const maxPriorityFeePerGas = parseUnits("0.02", "gwei"); 147 | 148 | // const tx = await maker.connect(provider).sendTransaction({ 149 | // to: sellToken, 150 | // data, 151 | // value: approveMethod === "depositAndApprove" ? amountToApprove : 0, 152 | // maxFeePerGas: nextBaseFee.add(maxPriorityFeePerGas), 153 | // maxPriorityFeePerGas: maxPriorityFeePerGas, 154 | // }); 155 | 156 | // console.log(`Approval transaction relayed: ${tx.hash}`); 157 | 158 | const tx = await maker.connect(provider).signTransaction({ 159 | from: maker.address, 160 | to: sellToken, 161 | data, 162 | value: approveMethod === "depositAndApprove" ? amountToApprove : 0, 163 | maxFeePerGas: nextBaseFee.add(maxPriorityFeePerGas), 164 | maxPriorityFeePerGas: maxPriorityFeePerGas, 165 | type: 2, 166 | nonce: await provider.getTransactionCount(maker.address), 167 | gasLimit: 100000, 168 | chainId, 169 | }); 170 | 171 | await axios.post(`${process.env.MATCHMAKER_BASE_URL}/erc20/intents/private`, { 172 | intent, 173 | approvalTxOrTxHash: tx, 174 | }); 175 | 176 | console.log("Intent sent to matchmaker"); 177 | }; 178 | 179 | main(); 180 | -------------------------------------------------------------------------------- /scripts/intent-erc721.ts: -------------------------------------------------------------------------------- 1 | import { Interface, defaultAbiCoder } from "@ethersproject/abi"; 2 | import { AddressZero } from "@ethersproject/constants"; 3 | import { Contract } from "@ethersproject/contracts"; 4 | import { JsonRpcProvider } from "@ethersproject/providers"; 5 | import { parseUnits } from "@ethersproject/units"; 6 | import { Wallet } from "@ethersproject/wallet"; 7 | import axios from "axios"; 8 | 9 | import { 10 | MATCHMAKER, 11 | MEMSWAP_ERC721, 12 | MEMETH, 13 | USDC, 14 | WETH9, 15 | } from "../src/common/addresses"; 16 | import { 17 | getEIP712Domain, 18 | getEIP712TypesForIntent, 19 | now, 20 | } from "../src/common/utils"; 21 | import { IntentERC721, Protocol } from "../src/common/types"; 22 | 23 | // Required env variables: 24 | // - JSON_URL: url for the http provider 25 | // - MAKER_PK: private key of the maker 26 | // - MATCHMAKER_BASE_URL: base url of the matchmaker 27 | 28 | const main = async () => { 29 | const provider = new JsonRpcProvider(process.env.JSON_URL!); 30 | const maker = new Wallet(process.env.MAKER_PK!); 31 | 32 | const chainId = await provider.getNetwork().then((n) => n.chainId); 33 | const CURRENCIES = { 34 | ETH_IN: MEMETH[chainId], 35 | ETH_OUT: AddressZero, 36 | WETH: WETH9[chainId], 37 | USDC: USDC[chainId], 38 | }; 39 | 40 | const buyToken = "0x2143ee9e2c8ab2bca8e89e15ca1c80b2dc6f7e00"; 41 | const sellToken = CURRENCIES.ETH_IN; 42 | 43 | // Create intent 44 | const intent: IntentERC721 = { 45 | isBuy: true, 46 | buyToken, 47 | sellToken, 48 | maker: maker.address, 49 | solver: MATCHMAKER[chainId], 50 | source: AddressZero, 51 | feeBps: 0, 52 | surplusBps: 0, 53 | startTime: now() - 15, 54 | endTime: await provider 55 | .getBlock("latest") 56 | .then((b) => b!.timestamp + 3600 * 24), 57 | nonce: "0", 58 | isPartiallyFillable: false, 59 | isSmartOrder: false, 60 | isIncentivized: true, 61 | isCriteriaOrder: true, 62 | tokenIdOrCriteria: "0", 63 | amount: "1", 64 | endAmount: parseUnits("0.01", 18).toString(), 65 | startAmountBps: 200, 66 | expectedAmountBps: 50, 67 | // Mock value to pass type checks 68 | signature: "0x", 69 | }; 70 | intent.signature = await maker._signTypedData( 71 | getEIP712Domain(chainId, Protocol.ERC721), 72 | getEIP712TypesForIntent(Protocol.ERC721), 73 | intent 74 | ); 75 | 76 | const memeth = new Contract( 77 | MEMETH[chainId], 78 | new Interface([ 79 | "function balanceOf(address owner) view returns (uint256)", 80 | "function approve(address spender, uint256 amount)", 81 | "function depositAndApprove(address spender, uint256 amount)", 82 | ]), 83 | provider 84 | ); 85 | 86 | const amountToApprove = !intent.isBuy ? intent.amount : intent.endAmount; 87 | 88 | // Generate approval transaction 89 | const approveMethod = 90 | sellToken === MEMETH[chainId] && 91 | (await memeth.balanceOf(maker.address)).lt(amountToApprove) 92 | ? "depositAndApprove" 93 | : "approve"; 94 | const data = 95 | memeth.interface.encodeFunctionData(approveMethod, [ 96 | MEMSWAP_ERC721[chainId], 97 | amountToApprove, 98 | ]) + 99 | defaultAbiCoder 100 | .encode( 101 | [ 102 | "bool", 103 | "address", 104 | "address", 105 | "address", 106 | "address", 107 | "address", 108 | "uint16", 109 | "uint16", 110 | "uint32", 111 | "uint32", 112 | "bool", 113 | "bool", 114 | "bool", 115 | "bool", 116 | "uint256", 117 | "uint128", 118 | "uint128", 119 | "uint16", 120 | "uint16", 121 | "bytes", 122 | ], 123 | [ 124 | intent.isBuy, 125 | intent.buyToken, 126 | intent.sellToken, 127 | intent.maker, 128 | intent.solver, 129 | intent.source, 130 | intent.feeBps, 131 | intent.surplusBps, 132 | intent.startTime, 133 | intent.endTime, 134 | intent.isPartiallyFillable, 135 | intent.isSmartOrder, 136 | intent.isIncentivized, 137 | intent.isCriteriaOrder, 138 | intent.tokenIdOrCriteria, 139 | intent.amount, 140 | intent.endAmount, 141 | intent.startAmountBps, 142 | intent.expectedAmountBps, 143 | intent.signature, 144 | ] 145 | ) 146 | .slice(2); 147 | 148 | const currentBaseFee = await provider 149 | .getBlock("pending") 150 | .then((b) => b!.baseFeePerGas!); 151 | const nextBaseFee = currentBaseFee.add(currentBaseFee.mul(3000).div(10000)); 152 | const maxPriorityFeePerGas = parseUnits("0.02", "gwei"); 153 | 154 | // const tx = await maker.connect(provider).sendTransaction({ 155 | // to: sellToken, 156 | // data, 157 | // value: approveMethod === "depositAndApprove" ? amountToApprove : 0, 158 | // maxFeePerGas: nextBaseFee.add(maxPriorityFeePerGas), 159 | // maxPriorityFeePerGas: maxPriorityFeePerGas, 160 | // }); 161 | 162 | // console.log(`Approval transaction relayed: ${tx.hash}`); 163 | 164 | const tx = await maker.connect(provider).signTransaction({ 165 | from: maker.address, 166 | to: sellToken, 167 | data, 168 | value: approveMethod === "depositAndApprove" ? amountToApprove : 0, 169 | maxFeePerGas: nextBaseFee.add(maxPriorityFeePerGas), 170 | maxPriorityFeePerGas: maxPriorityFeePerGas, 171 | type: 2, 172 | nonce: await provider.getTransactionCount(maker.address), 173 | gasLimit: 100000, 174 | chainId, 175 | }); 176 | 177 | await axios.post( 178 | `${process.env.MATCHMAKER_BASE_URL}/erc721/intents/private`, 179 | { 180 | intent, 181 | approvalTxOrTxHash: tx, 182 | } 183 | ); 184 | }; 185 | 186 | main(); 187 | -------------------------------------------------------------------------------- /scripts/seaport.ts: -------------------------------------------------------------------------------- 1 | import { AddressZero, HashZero } from "@ethersproject/constants"; 2 | import { JsonRpcProvider } from "@ethersproject/providers"; 3 | import { randomBytes } from "@ethersproject/random"; 4 | import { parseEther } from "@ethersproject/units"; 5 | import { Wallet } from "@ethersproject/wallet"; 6 | import * as Sdk from "@reservoir0x/sdk"; 7 | import axios from "axios"; 8 | 9 | import { bn } from "../src/common/utils"; 10 | 11 | // Required env variables: 12 | // - JSON_URL: url for the http provider 13 | // - MAKER_PK: private key of the maker 14 | // - SOLVER_BASE_URL: base url of the solver 15 | 16 | const main = async () => { 17 | const provider = new JsonRpcProvider(process.env.JSON_URL!); 18 | const maker = new Wallet(process.env.MAKER_PK!); 19 | 20 | const contract = "0x713d83cb05aa0d48cd162ea2dca44a3435bb3392"; 21 | const tokenId = "2528"; 22 | const price = parseEther("0.007"); 23 | 24 | const chainId = await provider.getNetwork().then((n) => n.chainId); 25 | const order = new Sdk.SeaportV15.Order(chainId, { 26 | offerer: maker.address, 27 | zone: AddressZero, 28 | offer: [ 29 | { 30 | itemType: Sdk.SeaportBase.Types.ItemType.ERC20, 31 | token: Sdk.Common.Addresses.WNative[chainId], 32 | identifierOrCriteria: "0", 33 | startAmount: price.toString(), 34 | endAmount: price.toString(), 35 | }, 36 | ], 37 | consideration: [ 38 | { 39 | itemType: Sdk.SeaportBase.Types.ItemType.ERC721, 40 | token: contract, 41 | identifierOrCriteria: tokenId, 42 | startAmount: "1", 43 | endAmount: "1", 44 | recipient: maker.address, 45 | }, 46 | ], 47 | orderType: Sdk.SeaportBase.Types.OrderType.FULL_OPEN, 48 | startTime: Math.floor(Date.now() / 1000), 49 | endTime: Math.floor(Date.now() / 1000) + 5 * 60, 50 | zoneHash: HashZero, 51 | salt: bn(randomBytes(32)).toHexString(), 52 | conduitKey: Sdk.SeaportBase.Addresses.OpenseaConduitKey[chainId], 53 | counter: ( 54 | await new Sdk.SeaportV15.Exchange(chainId).getCounter( 55 | provider, 56 | maker.address 57 | ) 58 | ).toString(), 59 | totalOriginalConsiderationItems: 1, 60 | }); 61 | await order.sign(maker); 62 | 63 | await axios.post(`${process.env.SOLVER_BASE_URL}/intents/seaport`, { 64 | order: order.params, 65 | }); 66 | }; 67 | 68 | main(); 69 | -------------------------------------------------------------------------------- /src/common/addresses.ts: -------------------------------------------------------------------------------- 1 | type ChainIdToAddress = { [chainId: number]: string }; 2 | 3 | // Protocol 4 | export const MEMSWAP_ERC20: ChainIdToAddress = { 5 | 1: "0x2b8763751a3141dee02ac83290a3426161fe591a", 6 | 5: "0xbc1287f5af439c7d6dcfa0bdcbb30d81725ffda0", 7 | }; 8 | export const MEMSWAP_ERC721: ChainIdToAddress = { 9 | 1: "0x4df1c16c6761e999ff587568be1468d4cfb17c37", 10 | 5: "0x3a62977f4d0a26ce6feb66e180e3eabd631dbf32", 11 | }; 12 | export const MEMETH: ChainIdToAddress = { 13 | 1: "0x8adda31fe63696ac64ded7d0ea208102b1358c44", 14 | 5: "0x6cb5504b957625d01a88db4b27eaafd5ae4422b6", 15 | }; 16 | export const MEMSWAP_NFT: ChainIdToAddress = { 17 | 1: "0x3a420aa01d1029e4a08e5171affa40e705fa73a9", 18 | 5: "0x27ee048b431d00d8f9ffc3fe3b8e657efeb58538", 19 | }; 20 | 21 | // Solver 22 | export const SOLVER: ChainIdToAddress = { 23 | 1: "0x743dbd073d951bc1e7ee276eb79a285595993d63", 24 | 5: "0x743dbd073d951bc1e7ee276eb79a285595993d63", 25 | }; 26 | export const SOLUTION_PROXY: ChainIdToAddress = { 27 | 1: "0x297f09d8cc7b7fd0c0a0be704dad7a3b327e3bce", 28 | 5: "0x357dfbec07a628e934bdb3642056fd72f10a7902", 29 | }; 30 | 31 | // Matchmaker 32 | export const MATCHMAKER: ChainIdToAddress = { 33 | 1: "0xf4f6df97aa065758c70e6fb7d938ec392dda98e0", 34 | 5: "0xf4f6df97aa065758c70e6fb7d938ec392dda98e0", 35 | }; 36 | 37 | // Misc 38 | export const PERMIT2: ChainIdToAddress = { 39 | 1: "0x000000000022d473030f116ddee9f6b43ac78ba3", 40 | 5: "0x000000000022d473030f116ddee9f6b43ac78ba3", 41 | }; 42 | export const USDC: ChainIdToAddress = { 43 | 1: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 44 | 5: "0x07865c6e87b9f70255377e024ace6630c1eaa37f", 45 | }; 46 | export const WETH9: ChainIdToAddress = { 47 | 1: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", 48 | 5: "0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6", 49 | }; 50 | -------------------------------------------------------------------------------- /src/common/config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | chainId: Number(process.env.CHAIN_ID), 3 | jsonUrl: process.env.JSON_URL!, 4 | flashbotsSignerPk: process.env.FLASHBOTS_SIGNER_PK!, 5 | bloxrouteAuth: process.env.BLOXROUTE_AUTH, 6 | reservoirApiKey: process.env.RESERVOIR_API_KEY!, 7 | }; 8 | -------------------------------------------------------------------------------- /src/common/flashbots-monkey-patch.ts: -------------------------------------------------------------------------------- 1 | import { keccak256 } from "@ethersproject/keccak256"; 2 | import { parse } from "@ethersproject/transactions"; 3 | import { 4 | FlashbotsBundleProvider, 5 | FlashbotsBundleRawTransaction, 6 | } from "@flashbots/ethers-provider-bundle"; 7 | import axios from "axios"; 8 | 9 | import { config } from "./config"; 10 | 11 | // Inspiration: 12 | // https://github.com/koraykoska/mev-bundle-submitter/blob/47696f4376e9b97cf44d042112c779e279805b1d/monkey-patches.js 13 | 14 | (FlashbotsBundleProvider.prototype as any).blxrSubmitBundle = async function ( 15 | txs: FlashbotsBundleRawTransaction[], 16 | targetBlock: number 17 | ) { 18 | const response = await axios.post( 19 | "https://mev.api.blxrbdn.com", 20 | { 21 | id: "1", 22 | method: "blxr_submit_bundle", 23 | params: { 24 | transaction: txs.map((tx) => tx.signedTransaction.slice(2)), 25 | block_number: "0x" + targetBlock.toString(16), 26 | mev_builders: { 27 | bloxroute: "", 28 | flashbots: "", 29 | builder0x69: "", 30 | beaverbuild: "", 31 | buildai: "", 32 | all: "", 33 | }, 34 | }, 35 | }, 36 | { 37 | headers: { 38 | Authorization: config.bloxrouteAuth, 39 | }, 40 | } 41 | ); 42 | 43 | const bundleTransactions = txs.map((tx) => { 44 | const txDetails = parse(tx.signedTransaction); 45 | return { 46 | signedTransaction: tx.signedTransaction, 47 | hash: keccak256(tx.signedTransaction), 48 | account: txDetails.from, 49 | nonce: txDetails.nonce, 50 | }; 51 | }); 52 | 53 | return { 54 | wait: () => 55 | this.waitForBundleInclusion(bundleTransactions, targetBlock, 60 * 1000), 56 | bundleHash: response.data?.result?.bundleHash, 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /src/common/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from "winston"; 2 | 3 | const log = (level: "error" | "info" | "warn" | "debug") => { 4 | const service = process.env.SERVICE; 5 | 6 | const logger = createLogger({ 7 | exitOnError: false, 8 | level: "debug", 9 | format: format.combine( 10 | format.timestamp({ 11 | format: "YYYY-MM-DD HH:mm:ss", 12 | }), 13 | format.json() 14 | ), 15 | transports: [ 16 | process.env.DATADOG_API_KEY 17 | ? new transports.Http({ 18 | host: "http-intake.logs.datadoghq.com", 19 | path: `/api/v2/logs?dd-api-key=${process.env.DATADOG_API_KEY}&ddsource=nodejs&service=${service}`, 20 | ssl: true, 21 | }) 22 | : // Fallback to logging to standard output 23 | new transports.Console(), 24 | ], 25 | }); 26 | 27 | return (component: string, message: string) => 28 | logger.log(level, message, { 29 | component, 30 | version: process.env.npm_package_version, 31 | }); 32 | }; 33 | 34 | export const logger = { 35 | error: log("error"), 36 | info: log("info"), 37 | warn: log("warn"), 38 | }; 39 | -------------------------------------------------------------------------------- /src/common/reservoir.ts: -------------------------------------------------------------------------------- 1 | import { AddressZero } from "@ethersproject/constants"; 2 | import axios from "axios"; 3 | 4 | import { MEMETH } from "./addresses"; 5 | import { config } from "./config"; 6 | 7 | export const getReservoirBaseUrl = () => 8 | config.chainId === 1 9 | ? "https://api.reservoir.tools" 10 | : "https://api-goerli.reservoir.tools"; 11 | 12 | export const getEthConversion = async (token: string) => 13 | token === MEMETH[config.chainId] 14 | ? "1" 15 | : await axios 16 | .get( 17 | `${getReservoirBaseUrl()}/currencies/conversion/v1?from=${AddressZero}&to=${token}` 18 | ) 19 | .then((response) => response.data.conversion); 20 | -------------------------------------------------------------------------------- /src/common/tx.ts: -------------------------------------------------------------------------------- 1 | import { JsonRpcProvider } from "@ethersproject/providers"; 2 | import { parse } from "@ethersproject/transactions"; 3 | import { Wallet } from "@ethersproject/wallet"; 4 | import { 5 | FlashbotsBundleProvider, 6 | FlashbotsBundleRawTransaction, 7 | FlashbotsBundleResolution, 8 | } from "@flashbots/ethers-provider-bundle"; 9 | import * as txSimulator from "@georgeroman/evm-tx-simulator"; 10 | 11 | import { logger } from "../common/logger"; 12 | import { PESSIMISTIC_BLOCK_TIME, isTxIncluded } from "../common/utils"; 13 | import { config } from "./config"; 14 | 15 | // Monkey-patch the flashbots bundle provider to support relaying via bloxroute 16 | import "./flashbots-monkey-patch"; 17 | 18 | let cachedFlashbotsProvider: FlashbotsBundleProvider | undefined; 19 | export const getFlashbotsProvider = async () => { 20 | if (!cachedFlashbotsProvider) { 21 | cachedFlashbotsProvider = await FlashbotsBundleProvider.create( 22 | new JsonRpcProvider(config.jsonUrl), 23 | new Wallet(config.flashbotsSignerPk), 24 | config.chainId === 1 25 | ? "https://relay.flashbots.net" 26 | : "https://relay-goerli.flashbots.net" 27 | ); 28 | } 29 | 30 | return cachedFlashbotsProvider; 31 | }; 32 | 33 | // Warm-up 34 | getFlashbotsProvider(); 35 | 36 | // Relay methods 37 | 38 | export const relayViaTransaction = async ( 39 | hash: string, 40 | isIncentivized: boolean, 41 | provider: JsonRpcProvider, 42 | tx: string, 43 | logComponent: string 44 | ) => { 45 | const parsedTx = parse(tx); 46 | try { 47 | await txSimulator.getCallResult( 48 | { 49 | from: parsedTx.from!, 50 | to: parsedTx.to!, 51 | data: parsedTx.data, 52 | value: parsedTx.value, 53 | gas: parsedTx.gasLimit, 54 | maxFeePerGas: parsedTx.maxFeePerGas!, 55 | maxPriorityFeePerGas: parsedTx.maxPriorityFeePerGas!, 56 | }, 57 | provider 58 | ); 59 | } catch { 60 | // For some reason, incentivized intents fail the above simulation very often 61 | 62 | logger[isIncentivized ? "info" : "error"]( 63 | logComponent, 64 | JSON.stringify({ 65 | msg: "Simulation failed", 66 | hash, 67 | parsedTx, 68 | }) 69 | ); 70 | 71 | if (!isIncentivized) { 72 | throw new Error("Simulation failed"); 73 | } 74 | } 75 | 76 | logger.info( 77 | logComponent, 78 | JSON.stringify({ 79 | msg: "Relaying using regular transaction", 80 | hash, 81 | }) 82 | ); 83 | 84 | const txResponse = await provider.sendTransaction(tx).then((tx) => tx.wait()); 85 | 86 | logger.info( 87 | logComponent, 88 | JSON.stringify({ 89 | msg: "Transaction included", 90 | hash, 91 | txHash: txResponse.transactionHash, 92 | }) 93 | ); 94 | }; 95 | 96 | export const relayViaFlashbots = async ( 97 | hash: string, 98 | provider: JsonRpcProvider, 99 | flashbotsProvider: FlashbotsBundleProvider, 100 | txs: FlashbotsBundleRawTransaction[], 101 | // These are to be removed if the simulation fails with "nonce too high" 102 | userTxs: FlashbotsBundleRawTransaction[], 103 | targetBlock: number, 104 | logComponent: string 105 | ): Promise => { 106 | const signedBundle = await flashbotsProvider.signBundle(txs); 107 | 108 | const simulationResult: { error?: string; results: [{ error?: string }] } = 109 | (await flashbotsProvider.simulate(signedBundle, targetBlock)) as any; 110 | if (simulationResult.error || simulationResult.results.some((r) => r.error)) { 111 | if ( 112 | ["nonce too low", "nonce too high"].some((e) => 113 | JSON.stringify(simulationResult.error)?.includes(e) 114 | ) 115 | ) { 116 | // Retry with all user transactions removed - assuming the 117 | // error is coming from their inclusion in previous blocks 118 | const mappedUserTxs = userTxs.map((tx) => tx.signedTransaction); 119 | txs = txs.filter((tx) => !mappedUserTxs.includes(tx.signedTransaction)); 120 | 121 | return relayViaFlashbots( 122 | hash, 123 | provider, 124 | flashbotsProvider, 125 | txs, 126 | [], 127 | targetBlock, 128 | logComponent 129 | ); 130 | } else { 131 | logger.error( 132 | logComponent, 133 | JSON.stringify({ 134 | msg: "Bundle simulation failed", 135 | hash, 136 | simulationResult, 137 | txs, 138 | }) 139 | ); 140 | 141 | throw new Error("Bundle simulation failed"); 142 | } 143 | } 144 | 145 | const receipt = await flashbotsProvider.sendRawBundle( 146 | signedBundle, 147 | targetBlock 148 | ); 149 | const bundleHash = (receipt as any).bundleHash; 150 | 151 | logger.info( 152 | logComponent, 153 | JSON.stringify({ 154 | msg: "Bundle relayed using flashbots", 155 | hash, 156 | targetBlock, 157 | bundleHash, 158 | }) 159 | ); 160 | 161 | const waitResponse = await (receipt as any).wait(); 162 | if ( 163 | waitResponse === FlashbotsBundleResolution.BundleIncluded || 164 | waitResponse === FlashbotsBundleResolution.AccountNonceTooHigh 165 | ) { 166 | if ( 167 | await isTxIncluded( 168 | parse(txs[txs.length - 1].signedTransaction).hash!, 169 | provider 170 | ) 171 | ) { 172 | logger.info( 173 | logComponent, 174 | JSON.stringify({ 175 | msg: "Bundle included", 176 | hash, 177 | targetBlock, 178 | bundleHash: hash, 179 | }) 180 | ); 181 | } else { 182 | logger.info( 183 | logComponent, 184 | JSON.stringify({ 185 | msg: "Bundle not included", 186 | hash, 187 | targetBlock, 188 | bundleHash: hash, 189 | }) 190 | ); 191 | 192 | throw new Error("Bundle not included"); 193 | } 194 | } else { 195 | logger.info( 196 | logComponent, 197 | JSON.stringify({ 198 | msg: "Bundle not included", 199 | hash, 200 | targetBlock, 201 | bundleHash: hash, 202 | }) 203 | ); 204 | 205 | throw new Error("Bundle not included"); 206 | } 207 | }; 208 | 209 | export const relayViaBloxroute = async ( 210 | hash: string, 211 | provider: JsonRpcProvider, 212 | flashbotsProvider: FlashbotsBundleProvider, 213 | txs: FlashbotsBundleRawTransaction[], 214 | // These are to be removed if the simulation fails with "nonce too high" 215 | userTxs: FlashbotsBundleRawTransaction[], 216 | targetBlock: number, 217 | logComponent: string 218 | ): Promise => { 219 | // Simulate via flashbots 220 | const signedBundle = await flashbotsProvider.signBundle(txs); 221 | const simulationResult: { error?: string; results: [{ error?: string }] } = 222 | (await flashbotsProvider.simulate(signedBundle, targetBlock)) as any; 223 | if (simulationResult.error || simulationResult.results.some((r) => r.error)) { 224 | if ( 225 | ["nonce too low", "nonce too high"].some((e) => 226 | JSON.stringify(simulationResult.error)?.includes(e) 227 | ) 228 | ) { 229 | // Retry with all user transactions removed - assuming the 230 | // error is coming from their inclusion in previous blocks 231 | const mappedUserTxs = userTxs.map((tx) => tx.signedTransaction); 232 | txs = txs.filter((tx) => !mappedUserTxs.includes(tx.signedTransaction)); 233 | 234 | return relayViaBloxroute( 235 | hash, 236 | provider, 237 | flashbotsProvider, 238 | txs, 239 | [], 240 | targetBlock, 241 | logComponent 242 | ); 243 | } else { 244 | logger.error( 245 | logComponent, 246 | JSON.stringify({ 247 | msg: "Bundle simulation failed", 248 | hash, 249 | simulationResult, 250 | txs, 251 | }) 252 | ); 253 | 254 | throw new Error("Bundle simulation failed"); 255 | } 256 | } 257 | 258 | logger.info( 259 | logComponent, 260 | JSON.stringify({ 261 | msg: "Bloxroute debug", 262 | params: { 263 | id: "1", 264 | method: "blxr_submit_bundle", 265 | params: { 266 | transaction: txs.map((tx) => tx.signedTransaction.slice(2)), 267 | block_number: "0x" + targetBlock.toString(16), 268 | mev_builders: { 269 | bloxroute: "", 270 | flashbots: "", 271 | builder0x69: "", 272 | beaverbuild: "", 273 | buildai: "", 274 | all: "", 275 | }, 276 | }, 277 | }, 278 | }) 279 | ); 280 | 281 | let done = false; 282 | while (!done) { 283 | try { 284 | const receipt = await (flashbotsProvider as any).blxrSubmitBundle( 285 | txs, 286 | targetBlock 287 | ); 288 | const hash = (receipt as any).bundleHash; 289 | 290 | logger.info( 291 | logComponent, 292 | JSON.stringify({ 293 | msg: "Bundle relayed using bloxroute", 294 | hash, 295 | targetBlock, 296 | bundleHash: hash, 297 | }) 298 | ); 299 | 300 | const waitResponse = await Promise.race([ 301 | (receipt as any).wait(), 302 | new Promise((resolve) => 303 | setTimeout(resolve, PESSIMISTIC_BLOCK_TIME * 1000) 304 | ), 305 | ]); 306 | if ( 307 | waitResponse === FlashbotsBundleResolution.BundleIncluded || 308 | waitResponse === FlashbotsBundleResolution.AccountNonceTooHigh 309 | ) { 310 | if ( 311 | await isTxIncluded( 312 | parse(txs[txs.length - 1].signedTransaction).hash!, 313 | provider 314 | ) 315 | ) { 316 | logger.info( 317 | logComponent, 318 | JSON.stringify({ 319 | msg: "Bundle included", 320 | hash, 321 | targetBlock, 322 | bundleHash: hash, 323 | }) 324 | ); 325 | } else { 326 | logger.info( 327 | logComponent, 328 | JSON.stringify({ 329 | msg: "Bundle not included", 330 | hash, 331 | targetBlock, 332 | bundleHash: hash, 333 | }) 334 | ); 335 | 336 | throw new Error("Bundle not included"); 337 | } 338 | } else { 339 | logger.info( 340 | logComponent, 341 | JSON.stringify({ 342 | msg: "Bundle not included", 343 | hash, 344 | targetBlock, 345 | bundleHash: hash, 346 | }) 347 | ); 348 | 349 | throw new Error("Bundle not included"); 350 | } 351 | } catch (error: any) { 352 | const data = error.response?.data; 353 | if ( 354 | data && 355 | JSON.stringify(data).includes("1 bundle submissions per second") 356 | ) { 357 | // Retry after waiting for 1 second 358 | await new Promise((resolve) => setTimeout(resolve, 1100)); 359 | } else { 360 | throw error; 361 | } 362 | } 363 | 364 | done = true; 365 | } 366 | }; 367 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import { Call } from "../solver/types"; 2 | 3 | // Common 4 | 5 | export type TxData = { 6 | from: string; 7 | to: string; 8 | data: string; 9 | gasLimit?: number; 10 | }; 11 | 12 | export enum Protocol { 13 | ERC20, 14 | ERC721, 15 | } 16 | 17 | export type Authorization = { 18 | intentHash: string; 19 | solver: string; 20 | fillAmountToCheck: string; 21 | executeAmountToCheck: string; 22 | blockDeadline: number; 23 | signature?: string; 24 | }; 25 | 26 | // ERC20 27 | 28 | export type IntentERC20 = { 29 | isBuy: boolean; 30 | buyToken: string; 31 | sellToken: string; 32 | maker: string; 33 | solver: string; 34 | source: string; 35 | feeBps: number; 36 | surplusBps: number; 37 | startTime: number; 38 | endTime: number; 39 | nonce: string; 40 | isPartiallyFillable: boolean; 41 | isSmartOrder: boolean; 42 | isIncentivized: boolean; 43 | amount: string; 44 | endAmount: string; 45 | startAmountBps: number; 46 | expectedAmountBps: number; 47 | signature: string; 48 | }; 49 | 50 | export type SolutionERC20 = { 51 | // Data needed for on-chain purposes 52 | calls: Call[]; 53 | fillAmount: string; 54 | executeAmount: string; 55 | // Data needed for off-chain purposes 56 | expectedAmount: string; 57 | gasConsumed: string; 58 | executeTokenToEthRate: string; 59 | executeTokenDecimals: number; 60 | grossProfitInEth: string; 61 | }; 62 | 63 | // ERC721 64 | 65 | export type IntentERC721 = IntentERC20 & { 66 | isCriteriaOrder: boolean; 67 | tokenIdOrCriteria: string; 68 | }; 69 | 70 | export type TokenDetails = { 71 | tokenId: string; 72 | criteriaProof: string[]; 73 | }; 74 | 75 | export type SolutionERC721 = { 76 | // Data needed for on-chain purposes 77 | calls: Call[]; 78 | fillTokenDetails: TokenDetails[]; 79 | executeAmount: string; 80 | // Data needed for off-chain purposes 81 | expectedAmount: string; 82 | gasConsumed: string; 83 | executeTokenToEthRate: string; 84 | executeTokenDecimals: number; 85 | grossProfitInEth: string; 86 | additionalTxs: TxData[]; 87 | }; 88 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | import { Interface } from "@ethersproject/abi"; 2 | import { Provider } from "@ethersproject/abstract-provider"; 3 | import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; 4 | import { AddressZero } from "@ethersproject/constants"; 5 | import { Contract } from "@ethersproject/contracts"; 6 | import { _TypedDataEncoder } from "@ethersproject/hash"; 7 | import { parseUnits } from "@ethersproject/units"; 8 | import { 9 | WETH9 as UniswapWETH9, 10 | Currency, 11 | Ether, 12 | Token, 13 | } from "@uniswap/sdk-core"; 14 | 15 | import { MEMETH, MEMSWAP_ERC20, MEMSWAP_ERC721, WETH9 } from "./addresses"; 16 | import { config } from "./config"; 17 | import { Authorization, IntentERC20, IntentERC721, Protocol } from "./types"; 18 | 19 | export const AVERAGE_BLOCK_TIME = 12; 20 | export const PESSIMISTIC_BLOCK_TIME = 15; 21 | 22 | export const MATCHMAKER_AUTHORIZATION_GAS = 100000; 23 | export const APPROVAL_FOR_ALL_GAS = 100000; 24 | 25 | export const bn = (value: BigNumberish) => BigNumber.from(value); 26 | 27 | export const now = () => Math.floor(Date.now() / 1000); 28 | 29 | export const isTxIncluded = async (txHash: string, provider: Provider) => 30 | provider.getTransactionReceipt(txHash).then((tx) => tx && tx.status === 1); 31 | 32 | export const isERC721Intent = (intent: IntentERC20 | IntentERC721) => 33 | "isCriteriaOrder" in intent; 34 | 35 | export const isIntentFilled = async ( 36 | intent: IntentERC20 | IntentERC721, 37 | chainId: number, 38 | provider: Provider 39 | ) => { 40 | const memswap = new Contract( 41 | isERC721Intent(intent) ? MEMSWAP_ERC721[chainId] : MEMSWAP_ERC20[chainId], 42 | new Interface([ 43 | `function intentStatus(bytes32 intentHash) view returns ( 44 | ( 45 | bool isValidated, 46 | bool isCancelled, 47 | uint128 amountFilled 48 | ) 49 | )`, 50 | ]), 51 | provider 52 | ); 53 | 54 | const intentHash = getIntentHash(intent); 55 | const result = await memswap.intentStatus(intentHash); 56 | if (result.amountFilled.gte(intent.amount)) { 57 | return true; 58 | } 59 | 60 | return false; 61 | }; 62 | 63 | export const getAuthorizationHash = (authorization: Authorization) => 64 | _TypedDataEncoder.hashStruct( 65 | "Authorization", 66 | getEIP712TypesForAuthorization(), 67 | authorization 68 | ); 69 | 70 | export const getIntentHash = (intent: IntentERC20 | IntentERC721) => 71 | _TypedDataEncoder.hashStruct( 72 | "Intent", 73 | getEIP712TypesForIntent( 74 | isERC721Intent(intent) ? Protocol.ERC721 : Protocol.ERC20 75 | ), 76 | intent 77 | ); 78 | 79 | export const getEIP712Domain = (chainId: number, protocol: Protocol) => ({ 80 | name: protocol === Protocol.ERC20 ? "MemswapERC20" : "MemswapERC721", 81 | version: "1.0", 82 | chainId, 83 | verifyingContract: 84 | protocol === Protocol.ERC20 85 | ? MEMSWAP_ERC20[chainId] 86 | : MEMSWAP_ERC721[chainId], 87 | }); 88 | 89 | export const getEIP712TypesForAuthorization = () => ({ 90 | Authorization: [ 91 | { 92 | name: "intentHash", 93 | type: "bytes32", 94 | }, 95 | { 96 | name: "solver", 97 | type: "address", 98 | }, 99 | { 100 | name: "fillAmountToCheck", 101 | type: "uint128", 102 | }, 103 | { 104 | name: "executeAmountToCheck", 105 | type: "uint128", 106 | }, 107 | { 108 | name: "blockDeadline", 109 | type: "uint32", 110 | }, 111 | ], 112 | }); 113 | 114 | export const getEIP712TypesForIntent = (protocol: Protocol) => ({ 115 | Intent: [ 116 | { 117 | name: "isBuy", 118 | type: "bool", 119 | }, 120 | { 121 | name: "buyToken", 122 | type: "address", 123 | }, 124 | { 125 | name: "sellToken", 126 | type: "address", 127 | }, 128 | { 129 | name: "maker", 130 | type: "address", 131 | }, 132 | { 133 | name: "solver", 134 | type: "address", 135 | }, 136 | { 137 | name: "source", 138 | type: "address", 139 | }, 140 | { 141 | name: "feeBps", 142 | type: "uint16", 143 | }, 144 | { 145 | name: "surplusBps", 146 | type: "uint16", 147 | }, 148 | { 149 | name: "startTime", 150 | type: "uint32", 151 | }, 152 | { 153 | name: "endTime", 154 | type: "uint32", 155 | }, 156 | { 157 | name: "nonce", 158 | type: "uint256", 159 | }, 160 | { 161 | name: "isPartiallyFillable", 162 | type: "bool", 163 | }, 164 | { 165 | name: "isSmartOrder", 166 | type: "bool", 167 | }, 168 | { 169 | name: "isIncentivized", 170 | type: "bool", 171 | }, 172 | ...(protocol === Protocol.ERC721 173 | ? [ 174 | { 175 | name: "isCriteriaOrder", 176 | type: "bool", 177 | }, 178 | { 179 | name: "tokenIdOrCriteria", 180 | type: "uint256", 181 | }, 182 | ] 183 | : []), 184 | { 185 | name: "amount", 186 | type: "uint128", 187 | }, 188 | { 189 | name: "endAmount", 190 | type: "uint128", 191 | }, 192 | { 193 | name: "startAmountBps", 194 | type: "uint16", 195 | }, 196 | { 197 | name: "expectedAmountBps", 198 | type: "uint16", 199 | }, 200 | ], 201 | }); 202 | 203 | export const getIncentivizationTip = ( 204 | isBuy: boolean, 205 | expectedAmount: BigNumberish, 206 | expectedAmountBps: number, 207 | executeAmount: BigNumberish 208 | ): BigNumber => { 209 | const defaultSlippage = 50; 210 | const multiplier = 4; 211 | const minTip = parseUnits("0.05", "gwei").mul(500000); 212 | const maxTip = parseUnits("1.5", "gwei").mul(500000); 213 | 214 | const slippage = 215 | expectedAmountBps === 0 ? defaultSlippage : expectedAmountBps; 216 | 217 | const slippageUnit = bn(expectedAmount).mul(slippage).div(10000); 218 | 219 | if (isBuy) { 220 | const minValue = bn(expectedAmount).sub(slippageUnit.mul(multiplier)); 221 | const maxValue = bn(expectedAmount).add(slippageUnit); 222 | 223 | if (bn(executeAmount).gte(maxValue)) { 224 | return minTip; 225 | } else if (bn(executeAmount).lte(minValue)) { 226 | return maxTip; 227 | } else { 228 | return maxTip.sub( 229 | bn(executeAmount) 230 | .sub(minValue) 231 | .mul(maxTip.sub(minTip)) 232 | .div(maxValue.sub(minValue)) 233 | ); 234 | } 235 | } else { 236 | const minValue = bn(expectedAmount).sub(slippageUnit); 237 | const maxValue = bn(expectedAmount).add(slippageUnit.mul(multiplier)); 238 | 239 | if (bn(executeAmount).gte(maxValue)) { 240 | return minTip; 241 | } else if (bn(executeAmount).lte(minValue)) { 242 | return maxTip; 243 | } else { 244 | return minTip.add( 245 | bn(executeAmount) 246 | .sub(minValue) 247 | .mul(maxTip.sub(minTip)) 248 | .div(maxValue.sub(minValue)) 249 | ); 250 | } 251 | } 252 | }; 253 | 254 | export const getToken = async ( 255 | address: string, 256 | provider: Provider 257 | ): Promise => { 258 | const contract = new Contract( 259 | address, 260 | new Interface(["function decimals() view returns (uint8)"]), 261 | provider 262 | ); 263 | 264 | // The core Uniswap SDK misses the WETH9 address for some chains (eg. Sepolia) 265 | if (!UniswapWETH9[config.chainId]) { 266 | UniswapWETH9[config.chainId] = new Token( 267 | config.chainId, 268 | WETH9[config.chainId], 269 | await contract.decimals(), 270 | "WETH", 271 | "Wrapped Ether" 272 | ); 273 | } 274 | 275 | return [MEMETH[config.chainId], AddressZero].includes(address) 276 | ? Ether.onChain(config.chainId) 277 | : new Token(config.chainId, address, await contract.decimals()); 278 | }; 279 | -------------------------------------------------------------------------------- /src/matchmaker/.env.example: -------------------------------------------------------------------------------- 1 | export SERVICE=matchmaker 2 | export CHAIN_ID= 3 | export JSON_URL= 4 | export REDIS_URL= 5 | export FLASHBOTS_SIGNER_PK= 6 | export MATCHMAKER_PK= 7 | export KNOWN_SOLVERS_ERC20= 8 | export KNOWN_SOLVERS_ERC721= 9 | export PORT= -------------------------------------------------------------------------------- /src/matchmaker/README.md: -------------------------------------------------------------------------------- 1 | # Matchmaker 2 | 3 | This is a reference implementation of a matchmaker, which is responsible for incentivizing solvers to offer better prices for their intent solutions. To be elligible for the benefits the matchmaker offers, a user or app should restrict the filler of their intents to the matchmaker's address. Using the built-in authorization mechanism of Memswap, the matchmaker will then only authorize for filling the solver(s) which offer(s) the best price to the user. The reference implementation uses the signature authorization mechanism as follows: 4 | 5 | - instead of submitting the solutions on-chain (as it's done for open or private intents), solvers of matchmaker-restricted intents can submit solutions directly to the matchmaker (which has a public API) 6 | - for every intent that is being filled, the matchmaker will run a "blind" auction, with solvers submitting solutions without knowing what others submitted (thus being incentivized to bid high and offer users better prices) 7 | - after the auction is complete, the matchmaker will submit the best-priced solution (worth noting that the solutions are signed transactions from the solver that anyone can relay on-chain) 8 | -------------------------------------------------------------------------------- /src/matchmaker/config.ts: -------------------------------------------------------------------------------- 1 | import { config as commonConfig } from "../common/config"; 2 | 3 | export const config = { 4 | port: process.env.PORT!, 5 | redisUrl: process.env.REDIS_URL!, 6 | matchmakerPk: process.env.MATCHMAKER_PK!, 7 | knownSolversERC20: JSON.parse( 8 | process.env.KNOWN_SOLVERS_ERC20 ?? "[]" 9 | ) as string[], 10 | knownSolversERC721: JSON.parse( 11 | process.env.KNOWN_SOLVERS_ERC721 ?? "[]" 12 | ) as string[], 13 | tenderlyGatewayKey: process.env.TENDERLY_GATEWAY_KEY, 14 | ...commonConfig, 15 | }; 16 | -------------------------------------------------------------------------------- /src/matchmaker/index.ts: -------------------------------------------------------------------------------- 1 | import { createBullBoard } from "@bull-board/api"; 2 | import { BullMQAdapter } from "@bull-board/api/bullMQAdapter"; 3 | import { ExpressAdapter } from "@bull-board/express"; 4 | import axios from "axios"; 5 | import express from "express"; 6 | import cors from "cors"; 7 | 8 | import { logger } from "../common/logger"; 9 | import { IntentERC20, IntentERC721 } from "../common/types"; 10 | import { config } from "./config"; 11 | import * as jobs from "./jobs"; 12 | import * as solutions from "./solutions"; 13 | 14 | // Log unhandled errors 15 | process.on("unhandledRejection", (error) => { 16 | logger.error( 17 | "process", 18 | JSON.stringify({ data: `Unhandled rejection: ${error}` }) 19 | ); 20 | }); 21 | 22 | // Initialize app 23 | const app = express(); 24 | 25 | // Initialize BullMQ dashboard 26 | const serverAdapter = new ExpressAdapter(); 27 | serverAdapter.setBasePath("/admin/bullmq"); 28 | createBullBoard({ 29 | queues: [ 30 | new BullMQAdapter(jobs.submissionERC20.queue), 31 | new BullMQAdapter(jobs.submissionERC721.queue), 32 | ], 33 | serverAdapter: serverAdapter, 34 | }); 35 | 36 | app.use(cors()); 37 | app.use(express.json()); 38 | app.use("/admin/bullmq", serverAdapter.getRouter()); 39 | 40 | // Common 41 | 42 | app.get("/lives", (_, res) => { 43 | return res.json({ message: "yes" }); 44 | }); 45 | 46 | // ERC20 47 | 48 | app.post("/erc20/intents/private", async (req, res) => { 49 | let { approvalTxOrTxHash, intent } = req.body as { 50 | approvalTxOrTxHash?: string; 51 | intent: IntentERC20; 52 | }; 53 | 54 | if (approvalTxOrTxHash && !approvalTxOrTxHash.startsWith("0x")) { 55 | approvalTxOrTxHash = "0x" + approvalTxOrTxHash; 56 | } 57 | 58 | if (!config.knownSolversERC20.length) { 59 | return res.status(400).json({ error: "No known solvers" }); 60 | } 61 | 62 | // Forward to a single solver 63 | const [solver] = config.knownSolversERC20.slice(0, 1); 64 | await axios.post(`${solver.split(" ")[1]}/erc20/intents`, { 65 | intent, 66 | approvalTxOrTxHash, 67 | }); 68 | 69 | return res.json({ message: "Success" }); 70 | }); 71 | 72 | app.post("/erc20/intents/public", async (req, res) => { 73 | const { approvalTxOrTxHash, intent } = req.body as { 74 | approvalTxOrTxHash?: string; 75 | intent: IntentERC20; 76 | }; 77 | 78 | if (!config.knownSolversERC20.length) { 79 | return res.status(400).json({ error: "No known solvers" }); 80 | } 81 | 82 | // Forward to all solvers 83 | await Promise.all( 84 | config.knownSolversERC20.map(async (solver) => 85 | axios.post(`${solver.split(" ")[1]}/erc20/intents`, { 86 | intent, 87 | approvalTxOrTxHash, 88 | }) 89 | ) 90 | ); 91 | 92 | // TODO: Relay via bloxroute 93 | 94 | return res.json({ message: "Success" }); 95 | }); 96 | 97 | app.post("/erc20/solutions", async (req, res) => { 98 | const { intent, txs } = req.body as { 99 | intent: IntentERC20; 100 | txs: string[]; 101 | }; 102 | 103 | if (!intent || !txs?.length) { 104 | return res.status(400).json({ message: "Invalid parameters" }); 105 | } 106 | 107 | const result = await solutions.erc20.process(intent, txs); 108 | if (result.status === "error") { 109 | return res.status(400).json({ error: result.error }); 110 | } else if (result.status === "success") { 111 | return res.status(200).json({ 112 | message: "Success", 113 | }); 114 | } 115 | 116 | return res.json({ message: "success" }); 117 | }); 118 | 119 | // ERC721 120 | 121 | app.post("/erc721/intents/private", async (req, res) => { 122 | let { approvalTxOrTxHash, intent } = req.body as { 123 | approvalTxOrTxHash?: string; 124 | intent: IntentERC721; 125 | }; 126 | 127 | if (approvalTxOrTxHash && !approvalTxOrTxHash.startsWith("0x")) { 128 | approvalTxOrTxHash = "0x" + approvalTxOrTxHash; 129 | } 130 | 131 | if (!config.knownSolversERC721.length) { 132 | return res.status(400).json({ error: "No known solvers" }); 133 | } 134 | 135 | // Forward to a single solver 136 | const [solver] = config.knownSolversERC721.slice(0, 1); 137 | await axios.post(`${solver.split(" ")[1]}/erc721/intents`, { 138 | intent, 139 | approvalTxOrTxHash, 140 | }); 141 | 142 | return res.json({ message: "Success" }); 143 | }); 144 | 145 | app.post("/erc721/intents/public", async (req, res) => { 146 | const { approvalTxOrTxHash, intent } = req.body as { 147 | approvalTxOrTxHash?: string; 148 | intent: IntentERC721; 149 | }; 150 | 151 | if (!config.knownSolversERC721.length) { 152 | return res.status(400).json({ error: "No known solvers" }); 153 | } 154 | 155 | // Forward to all solvers 156 | await Promise.all( 157 | config.knownSolversERC721.map(async (solver) => 158 | axios.post(`${solver.split(" ")[1]}/erc721/intents`, { 159 | intent, 160 | approvalTxOrTxHash, 161 | }) 162 | ) 163 | ); 164 | 165 | // TODO: Relay via bloxroute 166 | 167 | return res.json({ message: "Success" }); 168 | }); 169 | 170 | app.post("/erc721/solutions", async (req, res) => { 171 | const { intent, txs } = req.body as { 172 | intent: IntentERC721; 173 | txs: string[]; 174 | }; 175 | 176 | if (!intent || !txs?.length) { 177 | return res.status(400).json({ message: "Invalid parameters" }); 178 | } 179 | 180 | const result = await solutions.erc721.process(intent, txs); 181 | if (result.status === "error") { 182 | return res.status(400).json({ error: result.error }); 183 | } else if (result.status === "success") { 184 | return res.status(200).json({ 185 | message: "Success", 186 | }); 187 | } 188 | 189 | return res.json({ message: "success" }); 190 | }); 191 | 192 | // Start app 193 | app.listen(config.port, () => {}); 194 | -------------------------------------------------------------------------------- /src/matchmaker/jobs/index.ts: -------------------------------------------------------------------------------- 1 | export * as submissionERC20 from "./submission-erc20"; 2 | export * as submissionERC721 from "./submission-erc721"; 3 | -------------------------------------------------------------------------------- /src/matchmaker/jobs/submission-erc20.ts: -------------------------------------------------------------------------------- 1 | import { Interface } from "@ethersproject/abi"; 2 | import { JsonRpcProvider } from "@ethersproject/providers"; 3 | import { parseUnits } from "@ethersproject/units"; 4 | import { Wallet } from "@ethersproject/wallet"; 5 | import { Queue, Worker } from "bullmq"; 6 | import { randomUUID } from "crypto"; 7 | 8 | import { MEMSWAP_ERC20 } from "../../common/addresses"; 9 | import { logger } from "../../common/logger"; 10 | import { getFlashbotsProvider, relayViaBloxroute } from "../../common/tx"; 11 | import { 12 | MATCHMAKER_AUTHORIZATION_GAS, 13 | getIntentHash, 14 | } from "../../common/utils"; 15 | import { config } from "../config"; 16 | import { redis } from "../redis"; 17 | import { Solution } from "../types"; 18 | 19 | const COMPONENT = "submission-erc20"; 20 | 21 | export const queue = new Queue(COMPONENT, { 22 | connection: redis.duplicate(), 23 | defaultJobOptions: { 24 | attempts: 5, 25 | removeOnComplete: 10000, 26 | removeOnFail: 10000, 27 | }, 28 | }); 29 | 30 | const worker = new Worker( 31 | COMPONENT, 32 | async (job) => { 33 | const { solutionKey } = job.data as { 34 | solutionKey: string; 35 | }; 36 | 37 | try { 38 | const provider = new JsonRpcProvider(config.jsonUrl); 39 | const flashbotsProvider = await getFlashbotsProvider(); 40 | 41 | const matchmaker = new Wallet(config.matchmakerPk); 42 | 43 | const components = solutionKey.split(":"); 44 | const targetBlock = Number(components[components.length - 1]); 45 | const latestBlock = await provider 46 | .getBlock("latest") 47 | .then((b) => b.number); 48 | if (latestBlock >= targetBlock) { 49 | throw new Error("Target block already passed"); 50 | } 51 | 52 | // Fetch the top solution 53 | const [solution] = await redis 54 | .zrange(solutionKey, 0, 0, "REV") 55 | .then((solutions) => solutions.map((s) => JSON.parse(s) as Solution)); 56 | 57 | const maxPriorityFeePerGas = parseUnits("1", "gwei"); 58 | 59 | // Just in case, set to 30% more than the pending block's base fee 60 | const estimatedBaseFee = await provider 61 | .getBlock("pending") 62 | .then((b) => 63 | b!.baseFeePerGas!.add(b!.baseFeePerGas!.mul(3000).div(10000)) 64 | ); 65 | 66 | const authorizationTx = await matchmaker.signTransaction({ 67 | to: MEMSWAP_ERC20[config.chainId], 68 | data: new Interface([ 69 | ` 70 | function authorize( 71 | ( 72 | bool isBuy, 73 | address buyToken, 74 | address sellToken, 75 | address maker, 76 | address solver, 77 | address source, 78 | uint16 feeBps, 79 | uint16 surplusBps, 80 | uint32 startTime, 81 | uint32 endTime, 82 | bool isPartiallyFillable, 83 | bool isSmartOrder, 84 | bool isIncentivized, 85 | uint128 amount, 86 | uint128 endAmount, 87 | uint16 startAmountBps, 88 | uint16 expectedAmountBps, 89 | bytes signature 90 | )[] intents, 91 | ( 92 | uint128 fillAmountToCheck, 93 | uint128 executeAmountToCheck, 94 | uint32 blockDeadline 95 | )[] auths, 96 | address solver 97 | ) 98 | `, 99 | ]).encodeFunctionData("authorize", [ 100 | [solution.intent], 101 | [ 102 | { 103 | fillAmountToCheck: solution.fillAmountToCheck, 104 | executeAmountToCheck: solution.executeAmountToCheck, 105 | blockDeadline: targetBlock, 106 | }, 107 | ], 108 | solution.solver, 109 | ]), 110 | value: 0, 111 | type: 2, 112 | nonce: await provider.getTransactionCount(matchmaker.address), 113 | chainId: config.chainId, 114 | gasLimit: MATCHMAKER_AUTHORIZATION_GAS, 115 | maxFeePerGas: estimatedBaseFee.add(maxPriorityFeePerGas).toString(), 116 | maxPriorityFeePerGas: maxPriorityFeePerGas.toString(), 117 | }); 118 | 119 | await relayViaBloxroute( 120 | getIntentHash(solution.intent), 121 | provider, 122 | flashbotsProvider, 123 | [authorizationTx, ...solution.txs].map((tx) => ({ 124 | signedTransaction: tx, 125 | })), 126 | solution.userTxs.map((tx) => ({ 127 | signedTransaction: tx, 128 | })), 129 | targetBlock, 130 | COMPONENT 131 | ); 132 | } catch (error: any) { 133 | logger.error( 134 | COMPONENT, 135 | JSON.stringify({ 136 | msg: "Job failed", 137 | error, 138 | stack: error.stack, 139 | }) 140 | ); 141 | throw error; 142 | } 143 | }, 144 | { connection: redis.duplicate(), concurrency: 500 } 145 | ); 146 | worker.on("error", (error) => { 147 | logger.error( 148 | COMPONENT, 149 | JSON.stringify({ 150 | msg: "Worker errored", 151 | error, 152 | }) 153 | ); 154 | }); 155 | 156 | export const addToQueue = async (solutionKey: string, delay: number) => { 157 | await lock(solutionKey); 158 | await queue.add( 159 | randomUUID(), 160 | { solutionKey }, 161 | { jobId: solutionKey, delay: delay * 1000 } 162 | ); 163 | }; 164 | 165 | export const lock = async (solutionKey: string) => 166 | redis.set(`${solutionKey}:locked`, "1"); 167 | 168 | export const isLocked = async (solutionKey: string) => 169 | Boolean(await redis.get(`${solutionKey}:locked`)); 170 | -------------------------------------------------------------------------------- /src/matchmaker/jobs/submission-erc721.ts: -------------------------------------------------------------------------------- 1 | import { Interface } from "@ethersproject/abi"; 2 | import { JsonRpcProvider } from "@ethersproject/providers"; 3 | import { parseUnits } from "@ethersproject/units"; 4 | import { Wallet } from "@ethersproject/wallet"; 5 | import { Queue, Worker } from "bullmq"; 6 | import { randomUUID } from "crypto"; 7 | 8 | import { MEMSWAP_ERC721 } from "../../common/addresses"; 9 | import { logger } from "../../common/logger"; 10 | import { getFlashbotsProvider, relayViaBloxroute } from "../../common/tx"; 11 | import { 12 | MATCHMAKER_AUTHORIZATION_GAS, 13 | getIntentHash, 14 | } from "../../common/utils"; 15 | import { config } from "../config"; 16 | import { redis } from "../redis"; 17 | import { Solution } from "../types"; 18 | 19 | const COMPONENT = "submission-erc721"; 20 | 21 | export const queue = new Queue(COMPONENT, { 22 | connection: redis.duplicate(), 23 | defaultJobOptions: { 24 | attempts: 5, 25 | removeOnComplete: 10000, 26 | removeOnFail: 10000, 27 | }, 28 | }); 29 | 30 | const worker = new Worker( 31 | COMPONENT, 32 | async (job) => { 33 | const { solutionKey } = job.data as { 34 | solutionKey: string; 35 | }; 36 | 37 | try { 38 | const provider = new JsonRpcProvider(config.jsonUrl); 39 | const flashbotsProvider = await getFlashbotsProvider(); 40 | 41 | const matchmaker = new Wallet(config.matchmakerPk); 42 | 43 | const components = solutionKey.split(":"); 44 | const targetBlock = Number(components[components.length - 1]); 45 | const latestBlock = await provider 46 | .getBlock("latest") 47 | .then((b) => b.number); 48 | if (latestBlock >= targetBlock) { 49 | throw new Error("Deadline block already passed"); 50 | } 51 | 52 | // Fetch the top solution 53 | const [solution] = await redis 54 | .zrange(solutionKey, 0, 0, "REV") 55 | .then((solutions) => solutions.map((s) => JSON.parse(s) as Solution)); 56 | 57 | const maxPriorityFeePerGas = parseUnits("1", "gwei"); 58 | 59 | // Just in case, set to 30% more than the pending block's base fee 60 | const estimatedBaseFee = await provider 61 | .getBlock("pending") 62 | .then((b) => 63 | b!.baseFeePerGas!.add(b!.baseFeePerGas!.mul(3000).div(10000)) 64 | ); 65 | 66 | const authorizationTx = await matchmaker.signTransaction({ 67 | to: MEMSWAP_ERC721[config.chainId], 68 | data: new Interface([ 69 | ` 70 | function authorize( 71 | ( 72 | bool isBuy, 73 | address buyToken, 74 | address sellToken, 75 | address maker, 76 | address solver, 77 | address source, 78 | uint16 feeBps, 79 | uint16 surplusBps, 80 | uint32 startTime, 81 | uint32 endTime, 82 | bool isPartiallyFillable, 83 | bool isSmartOrder, 84 | bool isIncentivized, 85 | bool isCriteriaOrder, 86 | uint256 tokenIdOrCriteria, 87 | uint128 amount, 88 | uint128 endAmount, 89 | uint16 startAmountBps, 90 | uint16 expectedAmountBps, 91 | bytes signature 92 | )[] intents, 93 | ( 94 | uint128 fillAmountToCheck, 95 | uint128 executeAmountToCheck, 96 | uint32 blockDeadline 97 | )[] auths, 98 | address solver 99 | ) 100 | `, 101 | ]).encodeFunctionData("authorize", [ 102 | [solution.intent], 103 | [ 104 | { 105 | fillAmountToCheck: solution.fillAmountToCheck, 106 | executeAmountToCheck: solution.executeAmountToCheck, 107 | blockDeadline: targetBlock, 108 | }, 109 | ], 110 | solution.solver, 111 | ]), 112 | value: 0, 113 | type: 2, 114 | nonce: await provider.getTransactionCount(matchmaker.address), 115 | chainId: config.chainId, 116 | gasLimit: MATCHMAKER_AUTHORIZATION_GAS, 117 | maxFeePerGas: estimatedBaseFee.add(maxPriorityFeePerGas).toString(), 118 | maxPriorityFeePerGas: maxPriorityFeePerGas.toString(), 119 | }); 120 | 121 | await relayViaBloxroute( 122 | getIntentHash(solution.intent), 123 | provider, 124 | flashbotsProvider, 125 | [authorizationTx, ...solution.txs].map((tx) => ({ 126 | signedTransaction: tx, 127 | })), 128 | solution.userTxs.map((tx) => ({ 129 | signedTransaction: tx, 130 | })), 131 | targetBlock, 132 | COMPONENT 133 | ); 134 | } catch (error: any) { 135 | logger.error( 136 | COMPONENT, 137 | JSON.stringify({ 138 | msg: "Job failed", 139 | error, 140 | stack: error.stack, 141 | }) 142 | ); 143 | throw error; 144 | } 145 | }, 146 | { connection: redis.duplicate(), concurrency: 500 } 147 | ); 148 | worker.on("error", (error) => { 149 | logger.error( 150 | COMPONENT, 151 | JSON.stringify({ 152 | msg: "Worker errored", 153 | error, 154 | }) 155 | ); 156 | }); 157 | 158 | export const addToQueue = async (solutionKey: string, delay: number) => { 159 | await lock(solutionKey); 160 | await queue.add( 161 | randomUUID(), 162 | { solutionKey }, 163 | { jobId: solutionKey, delay: delay * 1000 } 164 | ); 165 | }; 166 | 167 | export const lock = async (solutionKey: string) => 168 | redis.set(`${solutionKey}:locked`, "1"); 169 | 170 | export const isLocked = async (solutionKey: string) => 171 | Boolean(await redis.get(`${solutionKey}:locked`)); 172 | -------------------------------------------------------------------------------- /src/matchmaker/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis"; 2 | 3 | import { config } from "./config"; 4 | 5 | export const redis = new Redis(config.redisUrl, { 6 | maxRetriesPerRequest: null, 7 | enableReadyCheck: false, 8 | }); 9 | -------------------------------------------------------------------------------- /src/matchmaker/solutions/erc721.ts: -------------------------------------------------------------------------------- 1 | import { Interface } from "@ethersproject/abi"; 2 | import { hexValue } from "@ethersproject/bytes"; 3 | import { _TypedDataEncoder } from "@ethersproject/hash"; 4 | import { JsonRpcProvider } from "@ethersproject/providers"; 5 | import { parse, serialize } from "@ethersproject/transactions"; 6 | import { formatEther, parseEther } from "@ethersproject/units"; 7 | import { Wallet } from "@ethersproject/wallet"; 8 | import { getCallTraces, getStateChange } from "@georgeroman/evm-tx-simulator"; 9 | 10 | import { MEMSWAP_ERC721 } from "../../common/addresses"; 11 | import { logger } from "../../common/logger"; 12 | import { getEthConversion } from "../../common/reservoir"; 13 | import { IntentERC721, Protocol } from "../../common/types"; 14 | import { 15 | AVERAGE_BLOCK_TIME, 16 | MATCHMAKER_AUTHORIZATION_GAS, 17 | bn, 18 | getEIP712TypesForIntent, 19 | isIntentFilled, 20 | now, 21 | } from "../../common/utils"; 22 | import { config } from "../config"; 23 | import * as jobs from "../jobs"; 24 | import { redis } from "../redis"; 25 | import { Solution } from "../types"; 26 | 27 | const COMPONENT = "solution-process-erc721"; 28 | 29 | export const process = async ( 30 | intent: IntentERC721, 31 | txs: string[] 32 | ): Promise<{ 33 | status: "success" | "error"; 34 | error?: string; 35 | }> => { 36 | try { 37 | const provider = new JsonRpcProvider(config.jsonUrl); 38 | const matchmaker = new Wallet(config.matchmakerPk); 39 | 40 | // Determine the hash of the intent 41 | const intentHash = _TypedDataEncoder.hashStruct( 42 | "Intent", 43 | getEIP712TypesForIntent(Protocol.ERC721), 44 | intent 45 | ); 46 | 47 | logger.info( 48 | COMPONENT, 49 | JSON.stringify({ 50 | msg: "Processing solution", 51 | intentHash, 52 | intent, 53 | txs, 54 | }) 55 | ); 56 | 57 | const perfTime1 = performance.now(); 58 | 59 | if (!intent.isBuy) { 60 | const msg = "Sell intents not yet supported"; 61 | logger.info( 62 | COMPONENT, 63 | JSON.stringify({ 64 | msg, 65 | intentHash, 66 | }) 67 | ); 68 | 69 | return { 70 | status: "error", 71 | error: msg, 72 | }; 73 | } 74 | 75 | // Return early if the intent is not yet started 76 | if (intent.startTime > now()) { 77 | const msg = "Intent not yet started"; 78 | logger.info( 79 | COMPONENT, 80 | JSON.stringify({ 81 | msg, 82 | intentHash, 83 | }) 84 | ); 85 | 86 | return { 87 | status: "error", 88 | error: msg, 89 | }; 90 | } 91 | 92 | // Return early if the intent is expired 93 | if (intent.endTime <= now()) { 94 | const msg = "Intent is expired"; 95 | logger.info( 96 | COMPONENT, 97 | JSON.stringify({ 98 | msg, 99 | intentHash, 100 | }) 101 | ); 102 | 103 | return { 104 | status: "error", 105 | error: msg, 106 | }; 107 | } 108 | 109 | const perfTime2 = performance.now(); 110 | 111 | if (await isIntentFilled(intent, config.chainId, provider)) { 112 | const msg = "Filled"; 113 | logger.info( 114 | COMPONENT, 115 | JSON.stringify({ 116 | msg, 117 | intentHash, 118 | }) 119 | ); 120 | 121 | return { 122 | status: "error", 123 | error: msg, 124 | }; 125 | } 126 | 127 | const perfTime3 = performance.now(); 128 | 129 | const latestBlock = await provider.getBlock("latest"); 130 | // The inclusion target is two blocks in the future 131 | const targetBlockNumber = latestBlock.number + 2; 132 | // The submission period is only open until one block in the future 133 | const submissionDeadline = latestBlock.timestamp + AVERAGE_BLOCK_TIME; 134 | 135 | const perfTime4 = performance.now(); 136 | 137 | // Return early if the submission period is already over (for the current target block) 138 | const solutionKey = `matchmaker:solutions:${intentHash}:${targetBlockNumber}`; 139 | if (await jobs.submissionERC721.isLocked(solutionKey)) { 140 | const msg = "Submission period is over"; 141 | logger.info( 142 | COMPONENT, 143 | JSON.stringify({ 144 | msg, 145 | intentHash, 146 | }) 147 | ); 148 | 149 | return { 150 | status: "error", 151 | error: msg, 152 | }; 153 | } 154 | 155 | const perfTime5 = performance.now(); 156 | 157 | // Assume the solution transaction is the last one in the list 158 | const parsedSolutionTx = parse(txs[txs.length - 1]); 159 | const solver = 160 | parsedSolutionTx.to!.toLowerCase() === MEMSWAP_ERC721[config.chainId] 161 | ? parsedSolutionTx.from! 162 | : parsedSolutionTx.to!; 163 | 164 | // Get the call traces of the submission + status check transactions 165 | const txsToSimulate = [ 166 | // Authorization transaction 167 | { 168 | from: matchmaker.address, 169 | to: MEMSWAP_ERC721[config.chainId], 170 | data: new Interface([ 171 | ` 172 | function authorize( 173 | ( 174 | bool isBuy, 175 | address buyToken, 176 | address sellToken, 177 | address maker, 178 | address solver, 179 | address source, 180 | uint16 feeBps, 181 | uint16 surplusBps, 182 | uint32 startTime, 183 | uint32 endTime, 184 | bool isPartiallyFillable, 185 | bool isSmartOrder, 186 | bool isIncentivized, 187 | bool isCriteriaOrder, 188 | uint256 tokenIdOrCriteria, 189 | uint128 amount, 190 | uint128 endAmount, 191 | uint16 startAmountBps, 192 | uint16 expectedAmountBps, 193 | bytes signature 194 | )[] intents, 195 | ( 196 | uint128 fillAmountToCheck, 197 | uint128 executeAmountToCheck, 198 | uint32 blockDeadline 199 | )[] auths, 200 | address solver 201 | ) 202 | `, 203 | ]).encodeFunctionData("authorize", [ 204 | [intent], 205 | [ 206 | { 207 | fillAmountToCheck: intent.amount, 208 | executeAmountToCheck: intent.isBuy 209 | ? bn("0x" + "ff".repeat(16)) 210 | : 0, 211 | blockDeadline: targetBlockNumber, 212 | }, 213 | ], 214 | solver, 215 | ]), 216 | value: 0, 217 | gas: parsedSolutionTx.gasLimit, 218 | maxFeePerGas: parsedSolutionTx.maxFeePerGas!, 219 | maxPriorityFeePerGas: parsedSolutionTx.maxPriorityFeePerGas!, 220 | }, 221 | // Submission transactions 222 | ...txs.map((tx) => { 223 | const parsedTx = parse(tx); 224 | return { 225 | from: parsedTx.from!, 226 | to: parsedTx.to!, 227 | data: parsedTx.data!, 228 | value: parsedTx.value, 229 | gas: parsedTx.gasLimit, 230 | maxFeePerGas: parsedTx.maxFeePerGas!, 231 | maxPriorityFeePerGas: parsedTx.maxPriorityFeePerGas!, 232 | }; 233 | }), 234 | ]; 235 | 236 | const perfTime6 = performance.now(); 237 | 238 | const traces = await getCallTraces(txsToSimulate, provider); 239 | 240 | const perfTime7 = performance.now(); 241 | 242 | // Make sure the solution transaction didn't reverted 243 | const solveTrace = traces[traces.length - 1]; 244 | if (solveTrace.error) { 245 | // Simulate via Tenderly to help debugging 246 | let tenderlySimulationResult: any; 247 | if (config.tenderlyGatewayKey) { 248 | const provider = new JsonRpcProvider( 249 | `https://${ 250 | config.chainId === 1 ? "mainnet" : "goerli" 251 | }.gateway.tenderly.co/${config.tenderlyGatewayKey}` 252 | ); 253 | 254 | tenderlySimulationResult = await provider.send( 255 | "tenderly_simulateBundle", 256 | [ 257 | txsToSimulate.map((tx) => ({ 258 | ...tx, 259 | value: hexValue(bn(tx.value).toHexString()), 260 | gas: hexValue(bn(tx.gas).toHexString()), 261 | maxFeePerGas: hexValue(bn(tx.maxFeePerGas).toHexString()), 262 | maxPriorityFeePerGas: hexValue( 263 | bn(tx.maxPriorityFeePerGas).toHexString() 264 | ), 265 | })), 266 | "latest", 267 | ] 268 | ); 269 | } 270 | 271 | const msg = "Solution transaction reverted"; 272 | logger.info( 273 | COMPONENT, 274 | JSON.stringify({ 275 | msg, 276 | intentHash, 277 | error: solveTrace.error, 278 | txsToSimulate, 279 | tenderlySimulationResult, 280 | }) 281 | ); 282 | 283 | return { 284 | status: "error", 285 | error: msg, 286 | }; 287 | } 288 | 289 | const stateChange = getStateChange(solveTrace); 290 | 291 | // Approximation for gas used by matchmaker on-chain authorization transaction 292 | const matchmakerGasFee = bn(MATCHMAKER_AUTHORIZATION_GAS).mul( 293 | latestBlock.baseFeePerGas! 294 | ); 295 | 296 | const token = intent.sellToken.toLowerCase(); 297 | 298 | // Ensure the matchmaker is profitable (or at least not losing money) 299 | const matchmakerProfit = 300 | stateChange[matchmaker.address.toLowerCase()]?.tokenBalanceState[ 301 | `erc20:${token}` 302 | ] ?? "0"; 303 | const matchmakerProfitInEth = bn(matchmakerProfit) 304 | .mul(parseEther("1")) 305 | .div(await getEthConversion(token)); 306 | if (matchmakerProfitInEth.lt(matchmakerGasFee)) { 307 | const msg = "Matchmaker not profitable"; 308 | logger.info( 309 | COMPONENT, 310 | JSON.stringify({ 311 | msg, 312 | intentHash, 313 | matchmakerProfitInEth: matchmakerProfitInEth.toString(), 314 | matchmakerGasFee: matchmakerGasFee.toString(), 315 | }) 316 | ); 317 | 318 | return { 319 | status: "error", 320 | error: msg, 321 | }; 322 | } 323 | 324 | if (intent.isBuy) { 325 | // Compute the amount pulled from the intent maker 326 | const amountPulled = 327 | stateChange[intent.maker.toLowerCase()].tokenBalanceState[ 328 | `erc20:${intent.sellToken.toLowerCase()}` 329 | ]; 330 | 331 | // Adjust by 0.1% to cover any non-determinism 332 | let adjustedAmountPulled = bn(amountPulled).mul(-1); 333 | adjustedAmountPulled = adjustedAmountPulled.add( 334 | adjustedAmountPulled.div(100000) 335 | ); 336 | 337 | // Save the solution 338 | const solution: Solution = { 339 | intent, 340 | fillAmountToCheck: intent.amount, 341 | executeAmountToCheck: adjustedAmountPulled.toString(), 342 | userTxs: txs 343 | .map(parse) 344 | .filter((tx) => tx.from === intent.maker) 345 | .map((tx) => serialize(tx)), 346 | txs, 347 | solver, 348 | }; 349 | await redis.zadd( 350 | solutionKey, 351 | Number(formatEther(amountPulled)), 352 | JSON.stringify(solution) 353 | ); 354 | } 355 | 356 | // Put a delayed job to relay the winning solution 357 | await jobs.submissionERC721.addToQueue( 358 | solutionKey, 359 | submissionDeadline - now() 360 | ); 361 | 362 | const perfTime8 = performance.now(); 363 | 364 | logger.info( 365 | COMPONENT, 366 | JSON.stringify({ 367 | msg: "Performance measurements for process-solution", 368 | time1: (perfTime2 - perfTime1) / 1000, 369 | time2: (perfTime3 - perfTime2) / 1000, 370 | time3: (perfTime4 - perfTime3) / 1000, 371 | time4: (perfTime5 - perfTime4) / 1000, 372 | time5: (perfTime6 - perfTime5) / 1000, 373 | time6: (perfTime7 - perfTime6) / 1000, 374 | time7: (perfTime8 - perfTime7) / 1000, 375 | }) 376 | ); 377 | 378 | return { status: "success" }; 379 | } catch (error: any) { 380 | logger.error( 381 | COMPONENT, 382 | JSON.stringify({ 383 | msg: "Unknown error", 384 | error, 385 | stack: error.stack, 386 | }) 387 | ); 388 | 389 | return { 390 | status: "error", 391 | error: "Unknown error", 392 | }; 393 | } 394 | }; 395 | -------------------------------------------------------------------------------- /src/matchmaker/solutions/index.ts: -------------------------------------------------------------------------------- 1 | export * as erc20 from "./erc20"; 2 | export * as erc721 from "./erc721"; 3 | -------------------------------------------------------------------------------- /src/matchmaker/types.ts: -------------------------------------------------------------------------------- 1 | import { IntentERC20, IntentERC721 } from "../common/types"; 2 | 3 | export type Solution = { 4 | intent: IntentERC20 | IntentERC721; 5 | solver: string; 6 | fillAmountToCheck: string; 7 | executeAmountToCheck: string; 8 | userTxs: string[]; 9 | txs: string[]; 10 | }; 11 | -------------------------------------------------------------------------------- /src/solver/.env.example: -------------------------------------------------------------------------------- 1 | export SERVICE=solver 2 | export CHAIN_ID= 3 | export JSON_URL= 4 | export ALCHEMY_API_KEY= 5 | export REDIS_URL= 6 | export FLASHBOTS_SIGNER_PK= 7 | export SOLVER_PK= 8 | export MATCHMAKER_BASE_URL= 9 | export SOLVER_BASE_URL= 10 | export ZERO_EX_API_KEY= 11 | export RESERVOIR_API_KEY= 12 | export PORT= 13 | export RELAY_DIRECTLY_WHEN_POSSIBLE= 14 | export BLOXROUTE_AUTH= -------------------------------------------------------------------------------- /src/solver/README.md: -------------------------------------------------------------------------------- 1 | # Solver 2 | 3 | This is a reference implementation of a solver, which is responsible for providing solutions to intents. The way it works is by listening to any pending mempool transactions and trying to extract any intents shared this way. Any profitable intents are then attempted to be solved via various configurable [solutions](./solutions) (the current implementation supports solving ERC20 intents via 0x and UniswapV3, and ERC721 intents via Reservoir, but any other solutions can be plugged-in). Implementation-wise, the solver is composed of two main asynchronous jobs: 4 | 5 | - [`tx-listener`](./jobs/tx-listener.ts): responsible for discovering intents from mempool transactions 6 | - [`ts-solver-erc20`](./jobs/tx-solver-erc20.ts): responsible for generating solutions for ERC20 intents (open, private and [matchmaker](../matchmaker) intents are all supported) 7 | - [`ts-solver-erc721`](./jobs/tx-solver-erc721.ts): responsible for generating solutions for ERC721 intents (open, private and [matchmaker](../matchmaker) intents are all supported) 8 | - [`inventory-manager`](./jobs/inventory-manager.ts): responsible for managing the inventory of the solver (eg. liquidating any tokens obtained as profits from solving intents) 9 | -------------------------------------------------------------------------------- /src/solver/config.ts: -------------------------------------------------------------------------------- 1 | import { config as commonConfig } from "../common/config"; 2 | 3 | export const config = { 4 | alchemyApiKey: process.env.ALCHEMY_API_KEY!, 5 | redisUrl: process.env.REDIS_URL!, 6 | solverPk: process.env.SOLVER_PK!, 7 | matchmakerBaseUrl: process.env.MATCHMAKER_BASE_URL!, 8 | solverBaseUrl: process.env.SOLVER_BASE_URL!, 9 | zeroExApiKey: process.env.ZERO_EX_API_KEY!, 10 | port: process.env.PORT!, 11 | ...commonConfig, 12 | }; 13 | -------------------------------------------------------------------------------- /src/solver/index.ts: -------------------------------------------------------------------------------- 1 | import { createBullBoard } from "@bull-board/api"; 2 | import { BullMQAdapter } from "@bull-board/api/bullMQAdapter"; 3 | import { ExpressAdapter } from "@bull-board/express"; 4 | import { JsonRpcProvider } from "@ethersproject/providers"; 5 | import * as Sdk from "@reservoir0x/sdk"; 6 | import cors from "cors"; 7 | import express from "express"; 8 | 9 | import { logger } from "../common/logger"; 10 | import { IntentERC20, IntentERC721 } from "../common/types"; 11 | import { config } from "./config"; 12 | import * as jobs from "./jobs"; 13 | import { redis } from "./redis"; 14 | import { getGasCost } from "./jobs/seaport-solver"; 15 | 16 | // Log unhandled errors 17 | process.on("unhandledRejection", (error) => { 18 | logger.error( 19 | "process", 20 | JSON.stringify({ data: `Unhandled rejection: ${error}` }) 21 | ); 22 | }); 23 | 24 | // Initialize app 25 | const app = express(); 26 | 27 | // Initialize BullMQ dashboard 28 | const serverAdapter = new ExpressAdapter(); 29 | serverAdapter.setBasePath("/admin/bullmq"); 30 | createBullBoard({ 31 | queues: [ 32 | new BullMQAdapter(jobs.inventoryManager.queue), 33 | new BullMQAdapter(jobs.seaportSolver.queue), 34 | new BullMQAdapter(jobs.txListener.queue), 35 | new BullMQAdapter(jobs.txSolverERC20.queue), 36 | new BullMQAdapter(jobs.txSolverERC721.queue), 37 | ], 38 | serverAdapter: serverAdapter, 39 | }); 40 | 41 | app.use(cors()); 42 | app.use(express.json()); 43 | app.use("/admin/bullmq", serverAdapter.getRouter()); 44 | 45 | // Common 46 | 47 | app.get("/lives", (_req, res) => { 48 | return res.json({ message: "Yes" }); 49 | }); 50 | 51 | app.post("/tx-listener", async (req, res) => { 52 | const txHash = req.body.txHash as string; 53 | 54 | const provider = new JsonRpcProvider(config.jsonUrl); 55 | const tx = await provider.getTransaction(txHash); 56 | 57 | await jobs.txListener.addToQueue({ 58 | to: tx.to ?? null, 59 | input: tx.data, 60 | hash: txHash, 61 | }); 62 | 63 | return res.json({ message: "Success" }); 64 | }); 65 | 66 | app.post("/inventory-manager", async (req, res) => { 67 | const address = req.body.address as string; 68 | 69 | await jobs.inventoryManager.addToQueue(address, true); 70 | 71 | return res.json({ message: "Success" }); 72 | }); 73 | 74 | // ERC20 75 | 76 | app.post("/erc20/intents", async (req, res) => { 77 | const intent = req.body.intent as IntentERC20; 78 | const approvalTxOrTxHash = req.body.approvalTxOrTxHash as string | undefined; 79 | 80 | await jobs.txSolverERC20.addToQueue(intent, { approvalTxOrTxHash }); 81 | 82 | return res.json({ message: "Success" }); 83 | }); 84 | 85 | // ERC721 86 | 87 | app.post("/erc721/intents", async (req, res) => { 88 | const intent = req.body.intent as IntentERC721; 89 | const approvalTxOrTxHash = req.body.approvalTxOrTxHash as string | undefined; 90 | 91 | await jobs.txSolverERC721.addToQueue(intent, { approvalTxOrTxHash }); 92 | 93 | return res.json({ message: "Success" }); 94 | }); 95 | 96 | // Seaport 97 | 98 | app.post("/intents/seaport", async (req, res) => { 99 | const order = req.body.order as Sdk.SeaportBase.Types.OrderComponents; 100 | 101 | await jobs.seaportSolver.addToQueue(order); 102 | 103 | return res.json({ message: "Success" }); 104 | }); 105 | 106 | app.get("/intents/seaport/fee", async (req, res) => { 107 | const provider = new JsonRpcProvider(config.jsonUrl); 108 | const gasCost = await getGasCost(provider); 109 | 110 | return res.json({ fee: gasCost.toString() }); 111 | }); 112 | 113 | app.get("/intents/seaport/status", async (req, res) => { 114 | const hash = req.query.hash as string; 115 | 116 | const status = await redis.get(`status:${hash}`); 117 | if (!status) { 118 | return res.json({ status: "unknown" }); 119 | } else { 120 | return res.json(JSON.parse(status)); 121 | } 122 | }); 123 | 124 | // Start app 125 | app.listen(config.port, () => {}); 126 | -------------------------------------------------------------------------------- /src/solver/jobs/index.ts: -------------------------------------------------------------------------------- 1 | export * as inventoryManager from "./inventory-manager"; 2 | export * as seaportSolver from "./seaport-solver"; 3 | export * as txListener from "./tx-listener"; 4 | export * as txSolverERC20 from "./tx-solver-erc20"; 5 | export * as txSolverERC721 from "./tx-solver-erc721"; 6 | -------------------------------------------------------------------------------- /src/solver/jobs/inventory-manager.ts: -------------------------------------------------------------------------------- 1 | import { Interface } from "@ethersproject/abi"; 2 | import { AddressZero, MaxUint256 } from "@ethersproject/constants"; 3 | import { Contract } from "@ethersproject/contracts"; 4 | import { JsonRpcProvider } from "@ethersproject/providers"; 5 | import { parseEther, parseUnits } from "@ethersproject/units"; 6 | import { Wallet } from "@ethersproject/wallet"; 7 | import axios from "axios"; 8 | import { Queue, Worker } from "bullmq"; 9 | import { randomUUID } from "crypto"; 10 | 11 | import { MEMETH } from "../../common/addresses"; 12 | import { logger } from "../../common/logger"; 13 | import { getEthConversion } from "../../common/reservoir"; 14 | import { bn, getToken } from "../../common/utils"; 15 | import { config } from "../config"; 16 | import { redis } from "../redis"; 17 | import * as solutions from "../solutions"; 18 | 19 | const COMPONENT = "inventory-manager"; 20 | 21 | export const queue = new Queue(COMPONENT, { 22 | connection: redis.duplicate(), 23 | defaultJobOptions: { 24 | attempts: 5, 25 | removeOnComplete: true, 26 | removeOnFail: true, 27 | }, 28 | }); 29 | 30 | const worker = new Worker( 31 | COMPONENT, 32 | async (job) => { 33 | const { address } = job.data as { address: string }; 34 | if (address === AddressZero) { 35 | return; 36 | } 37 | 38 | try { 39 | const provider = new JsonRpcProvider(config.jsonUrl); 40 | const solver = new Wallet(config.solverPk).connect(provider); 41 | 42 | const contract = new Contract( 43 | address, 44 | new Interface([ 45 | "function balanceOf(address owner) view returns (uint256)", 46 | "function allowance(address owner, address spender) view returns (uint256)", 47 | "function approve(address spender, uint256 amount)", 48 | "function withdraw(uint256 amount)", 49 | ]), 50 | provider 51 | ); 52 | 53 | const token = await getToken(address, provider); 54 | const ethPrice = await getEthConversion(address); 55 | 56 | const balanceInToken = await contract.balanceOf(solver.address); 57 | const balanceInEth = bn(balanceInToken) 58 | .mul(parseEther("1")) 59 | .div(parseUnits(ethPrice, token.decimals)); 60 | 61 | // Must have at least 0.01 ETH worth of tokens 62 | if (balanceInEth.gte(parseEther("0.01"))) { 63 | const latestBaseFee = await provider 64 | .getBlock("pending") 65 | .then((b) => b.baseFeePerGas!); 66 | // Gas price should be lower than 25 gwei 67 | if (latestBaseFee <= parseUnits("25", "gwei")) { 68 | logger.info( 69 | COMPONENT, 70 | JSON.stringify({ 71 | msg: `Liquidating ${address} inventory`, 72 | address, 73 | balance: balanceInToken.toString(), 74 | }) 75 | ); 76 | 77 | if (address === MEMETH[config.chainId]) { 78 | // Withdraw 79 | await contract.connect(solver).withdraw(balanceInToken); 80 | } else { 81 | // Swap 82 | 83 | const { data: swapData } = await axios.get( 84 | config.chainId === 1 85 | ? "https://api.0x.org/swap/v1/quote" 86 | : "https://goerli.api.0x.org/swap/v1/quote", 87 | { 88 | params: { 89 | buyToken: solutions.zeroex.ZEROEX_ETH, 90 | sellToken: address, 91 | sellAmount: balanceInToken.toString(), 92 | }, 93 | headers: { 94 | "0x-Api-Key": config.zeroExApiKey, 95 | }, 96 | } 97 | ); 98 | 99 | const allowance = await contract.allowance( 100 | solver.address, 101 | swapData.allowanceTarget 102 | ); 103 | if (allowance.lt(balanceInToken)) { 104 | const tx = await contract 105 | .connect(solver) 106 | .approve(swapData.allowanceTarget, MaxUint256); 107 | await tx.wait(); 108 | } 109 | 110 | const txData = { 111 | to: swapData.to, 112 | data: swapData.data, 113 | // Explicit gas limit to avoid "out-of-gas" errors 114 | gasLimit: 700000, 115 | }; 116 | 117 | await solver.estimateGas(txData); 118 | await solver.sendTransaction(txData); 119 | } 120 | } 121 | } 122 | } catch (error: any) { 123 | logger.error( 124 | COMPONENT, 125 | JSON.stringify({ msg: "Job failed", error, stack: error.stack }) 126 | ); 127 | throw error; 128 | } 129 | }, 130 | { connection: redis.duplicate(), concurrency: 2000 } 131 | ); 132 | worker.on("error", (error) => { 133 | logger.error(COMPONENT, JSON.stringify({ msg: "Worker errored", error })); 134 | }); 135 | 136 | export const addToQueue = async (address: string, force?: boolean) => 137 | queue.add( 138 | randomUUID(), 139 | { address }, 140 | { 141 | delay: force ? undefined : 3600 * 1000, 142 | jobId: force ? undefined : address, 143 | } 144 | ); 145 | -------------------------------------------------------------------------------- /src/solver/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis"; 2 | 3 | import { config } from "./config"; 4 | 5 | export const redis = new Redis(config.redisUrl, { 6 | maxRetriesPerRequest: null, 7 | enableReadyCheck: false, 8 | }); 9 | -------------------------------------------------------------------------------- /src/solver/solutions/index.ts: -------------------------------------------------------------------------------- 1 | export * as reservoir from "./reservoir"; 2 | export * as uniswap from "./uniswap"; 3 | export * as zeroex from "./zeroex"; 4 | -------------------------------------------------------------------------------- /src/solver/solutions/reservoir.ts: -------------------------------------------------------------------------------- 1 | import { Interface } from "@ethersproject/abi"; 2 | import { Provider } from "@ethersproject/abstract-provider"; 3 | import { BigNumber } from "@ethersproject/bignumber"; 4 | import { AddressZero } from "@ethersproject/constants"; 5 | import { Contract } from "@ethersproject/contracts"; 6 | import { Wallet } from "@ethersproject/wallet"; 7 | import axios from "axios"; 8 | 9 | import { MEMETH, SOLUTION_PROXY } from "../../common/addresses"; 10 | import { getEthConversion, getReservoirBaseUrl } from "../../common/reservoir"; 11 | import { IntentERC721, TxData } from "../../common/types"; 12 | import { APPROVAL_FOR_ALL_GAS, bn } from "../../common/utils"; 13 | import { config } from "../config"; 14 | import { Call, SolutionDetailsERC721 } from "../types"; 15 | 16 | export const directSolve = async ( 17 | token: string, 18 | amount: string, 19 | provider: Provider 20 | ): Promise<{ saleTx: any; path: any[] }> => { 21 | const solver = new Wallet(config.solverPk); 22 | const quantity = Number(amount); 23 | 24 | const result = await axios 25 | .post( 26 | `${getReservoirBaseUrl()}/execute/buy/v7`, 27 | { 28 | items: [ 29 | { 30 | token, 31 | quantity, 32 | fillType: "trade", 33 | }, 34 | ], 35 | taker: solver.address, 36 | currency: AddressZero, 37 | skipBalanceCheck: true, 38 | }, 39 | { 40 | headers: { 41 | "X-Api-Key": config.reservoirApiKey, 42 | }, 43 | } 44 | ) 45 | .then((r) => r.data); 46 | 47 | // Handle the Blur auth step 48 | const firstStep = result.steps[0]; 49 | if (firstStep.id === "auth") { 50 | const item = firstStep.items[0]; 51 | if (item.status === "incomplete") { 52 | const message = item.data.sign.message; 53 | const messageSignature = await solver.signMessage(message); 54 | 55 | await axios.post( 56 | `${getReservoirBaseUrl()}${ 57 | item.data.post.endpoint 58 | }?signature=${messageSignature}`, 59 | item.data.post.body 60 | ); 61 | 62 | return directSolve(token, amount, provider); 63 | } 64 | } 65 | 66 | for (const step of result.steps.filter((s: any) => s.id !== "sale")) { 67 | if ( 68 | step.items.length && 69 | step.items.some((item: any) => item.status === "incomplete") 70 | ) { 71 | throw new Error("Multi-step sales not supported"); 72 | } 73 | } 74 | 75 | const saleStep = result.steps.find((s: any) => s.id === "sale"); 76 | if (saleStep.items.length > 1) { 77 | throw new Error("Multi-transaction sales not supported"); 78 | } 79 | 80 | const saleTx = saleStep.items[0].data; 81 | const path = result.path; 82 | 83 | return { saleTx, path }; 84 | }; 85 | 86 | export const solve = async ( 87 | intent: IntentERC721, 88 | fillAmount: string, 89 | provider: Provider 90 | ): Promise => { 91 | const solver = new Wallet(config.solverPk); 92 | const quantity = Number(fillAmount); 93 | 94 | const requestOptions = { 95 | items: [ 96 | { 97 | collection: intent.buyToken, 98 | quantity, 99 | fillType: "trade", 100 | }, 101 | ], 102 | taker: solver.address, 103 | currency: AddressZero, 104 | skipBalanceCheck: true, 105 | }; 106 | 107 | let useMultiTxs = intent.sellToken !== MEMETH[config.chainId]; 108 | 109 | // When solving Blur orders, we must use multi-tx filling 110 | const onlyPathResult = await axios 111 | .post( 112 | `${getReservoirBaseUrl()}/execute/buy/v7`, 113 | { 114 | ...requestOptions, 115 | onlyPath: true, 116 | }, 117 | { 118 | headers: { 119 | "X-Api-Key": config.reservoirApiKey, 120 | }, 121 | } 122 | ) 123 | .then((r) => r.data); 124 | if (onlyPathResult.path.some((item: any) => item.source === "blur.io")) { 125 | useMultiTxs = true; 126 | } 127 | 128 | const result = await axios 129 | .post( 130 | `${getReservoirBaseUrl()}/execute/buy/v7`, 131 | { 132 | ...requestOptions, 133 | taker: useMultiTxs ? solver.address : intent.maker, 134 | relayer: useMultiTxs ? undefined : solver.address, 135 | }, 136 | { 137 | headers: { 138 | "X-Api-Key": config.reservoirApiKey, 139 | }, 140 | } 141 | ) 142 | .then((r) => r.data); 143 | if (useMultiTxs) { 144 | // Handle the Blur auth step 145 | const firstStep = result.steps[0]; 146 | if (firstStep.id === "auth") { 147 | const item = firstStep.items[0]; 148 | if (item.status === "incomplete") { 149 | const message = item.data.sign.message; 150 | const messageSignature = await solver.signMessage(message); 151 | 152 | await axios.post( 153 | `${getReservoirBaseUrl()}${ 154 | item.data.post.endpoint 155 | }?signature=${messageSignature}`, 156 | item.data.post.body 157 | ); 158 | 159 | return solve(intent, fillAmount, provider); 160 | } 161 | } 162 | } 163 | 164 | for (const step of result.steps.filter((s: any) => s.id !== "sale")) { 165 | if ( 166 | step.items.length && 167 | step.items.some((item: any) => item.status === "incomplete") 168 | ) { 169 | throw new Error("Multi-step sales not supported"); 170 | } 171 | } 172 | 173 | const saleStep = result.steps.find((s: any) => s.id === "sale"); 174 | if (saleStep.items.length > 1) { 175 | throw new Error("Multi-transaction sales not supported"); 176 | } 177 | 178 | const saleTx = saleStep.items[0].data; 179 | 180 | const tokenIds = result.path.map((item: any) => item.tokenId); 181 | const price = result.path 182 | .map((item: any) => bn(item.buyInRawQuote ?? item.rawQuote)) 183 | .reduce((a: BigNumber, b: BigNumber) => a.add(b)); 184 | 185 | // TODO: Optimizations: 186 | // - transfer directly to the memswap contract where possible 187 | const gasUsed = 100000 + 75000 * quantity + 50000 * quantity; 188 | 189 | const calls: Call[] = []; 190 | const txs: TxData[] = []; 191 | if (useMultiTxs) { 192 | const contract = new Contract( 193 | intent.buyToken, 194 | new Interface([ 195 | "function isApprovedForAll(address owner, address operator) view returns (bool)", 196 | "function setApprovalForAll(address operator, bool approved)", 197 | "function transferFrom(address from, address to, uint256 tokenId)", 198 | ]), 199 | provider 200 | ); 201 | 202 | let approvalTxData: TxData | undefined; 203 | const isApproved = await contract.isApprovedForAll( 204 | solver.address, 205 | SOLUTION_PROXY[config.chainId] 206 | ); 207 | if (!isApproved) { 208 | approvalTxData = { 209 | from: solver.address, 210 | to: intent.buyToken, 211 | data: contract.interface.encodeFunctionData("setApprovalForAll", [ 212 | SOLUTION_PROXY[config.chainId], 213 | true, 214 | ]), 215 | gasLimit: APPROVAL_FOR_ALL_GAS, 216 | }; 217 | } 218 | 219 | // Sale tx 220 | txs.push({ 221 | ...saleTx, 222 | gasLimit: gasUsed, 223 | }); 224 | 225 | // Optional approval tx 226 | if (approvalTxData) { 227 | txs.push(approvalTxData); 228 | } 229 | 230 | // Transfer calls 231 | for (const tokenId of tokenIds) { 232 | calls.push({ 233 | to: intent.buyToken, 234 | data: contract.interface.encodeFunctionData("transferFrom", [ 235 | solver.address, 236 | intent.maker, 237 | tokenId, 238 | ]), 239 | value: "0", 240 | }); 241 | } 242 | } else { 243 | // Withdraw/unwrap tx 244 | calls.push({ 245 | to: intent.sellToken, 246 | data: new Interface([ 247 | "function withdraw(uint256 amount)", 248 | ]).encodeFunctionData("withdraw", [price]), 249 | value: "0", 250 | }); 251 | 252 | // Sale tx 253 | calls.push({ 254 | to: saleTx.to, 255 | data: saleTx.data, 256 | value: saleTx.value, 257 | }); 258 | } 259 | 260 | return { 261 | kind: "buy", 262 | data: { 263 | calls, 264 | txs, 265 | tokenIds, 266 | maxSellAmountInEth: price.toString(), 267 | sellTokenToEthRate: await getEthConversion(intent.sellToken), 268 | gasUsed, 269 | }, 270 | }; 271 | }; 272 | -------------------------------------------------------------------------------- /src/solver/solutions/uniswap.ts: -------------------------------------------------------------------------------- 1 | import { Interface } from "@ethersproject/abi"; 2 | import { Provider } from "@ethersproject/abstract-provider"; 3 | import { AddressZero } from "@ethersproject/constants"; 4 | import { parseUnits } from "@ethersproject/units"; 5 | import { CurrencyAmount, Percent, TradeType } from "@uniswap/sdk-core"; 6 | import { AlphaRouter, SwapType } from "@uniswap/smart-order-router"; 7 | 8 | import { PERMIT2, MEMETH, WETH9 } from "../../common/addresses"; 9 | import { IntentERC20 } from "../../common/types"; 10 | import { getToken, now } from "../../common/utils"; 11 | import { config } from "../config"; 12 | import { Call, SolutionDetailsERC20 } from "../types"; 13 | 14 | export const solve = async ( 15 | intent: IntentERC20, 16 | fillAmount: string, 17 | provider: Provider 18 | ): Promise => { 19 | const router = new AlphaRouter({ 20 | chainId: config.chainId, 21 | provider: provider as any, 22 | }); 23 | 24 | const ethToken = await getToken(AddressZero, provider); 25 | const fromToken = await getToken(intent.sellToken, provider); 26 | const toToken = await getToken(intent.buyToken, provider); 27 | 28 | const inETH = intent.sellToken === MEMETH[config.chainId]; 29 | if (intent.isBuy) { 30 | // Buy fixed amount of `buyToken` for variable amount of `sellToken` 31 | 32 | const [actualRoute, sellTokenToEthRate] = await Promise.all([ 33 | router.route( 34 | CurrencyAmount.fromRawAmount(toToken, fillAmount), 35 | fromToken, 36 | TradeType.EXACT_OUTPUT, 37 | { 38 | type: SwapType.UNIVERSAL_ROUTER, 39 | slippageTolerance: new Percent(1, 100), 40 | } 41 | ), 42 | [MEMETH[config.chainId], WETH9[config.chainId], AddressZero].includes( 43 | intent.sellToken 44 | ) 45 | ? "1" 46 | : router 47 | .route( 48 | CurrencyAmount.fromRawAmount( 49 | ethToken, 50 | parseUnits("1", 18).toString() 51 | ), 52 | fromToken, 53 | TradeType.EXACT_INPUT, 54 | { 55 | type: SwapType.UNIVERSAL_ROUTER, 56 | slippageTolerance: new Percent(1, 100), 57 | } 58 | ) 59 | .then((r) => r!.quote.toFixed()), 60 | ]); 61 | 62 | const maxAmountIn = parseUnits( 63 | actualRoute!.quote.toExact(), 64 | actualRoute!.quote.currency.decimals 65 | ); 66 | 67 | return { 68 | kind: "buy", 69 | data: { 70 | calls: [ 71 | { 72 | to: intent.sellToken, 73 | data: new Interface([ 74 | "function approve(address spender, uint256 amount)", 75 | "function withdraw(uint256 amount)", 76 | ]).encodeFunctionData( 77 | inETH ? "withdraw" : "approve", 78 | inETH ? [maxAmountIn] : [PERMIT2[config.chainId], maxAmountIn] 79 | ), 80 | value: "0", 81 | }, 82 | !inETH 83 | ? { 84 | to: PERMIT2[config.chainId], 85 | data: new Interface([ 86 | "function approve(address token, address spender, uint160 amount, uint48 expiration)", 87 | ]).encodeFunctionData("approve", [ 88 | intent.sellToken, 89 | actualRoute!.methodParameters!.to, 90 | maxAmountIn, 91 | now() + 3600, 92 | ]), 93 | value: "0", 94 | } 95 | : undefined, 96 | { 97 | to: actualRoute!.methodParameters!.to, 98 | data: actualRoute!.methodParameters!.calldata, 99 | value: inETH ? maxAmountIn : "0", 100 | }, 101 | ].filter(Boolean) as Call[], 102 | maxSellAmount: maxAmountIn.toString(), 103 | sellTokenToEthRate, 104 | gasUsed: actualRoute!.estimatedGasUsed.toNumber(), 105 | }, 106 | }; 107 | } else { 108 | // Sell fixed amount of `sellToken` for variable amount of `buyToken` 109 | 110 | const [actualRoute, buyTokenToEthRate] = await Promise.all([ 111 | router.route( 112 | CurrencyAmount.fromRawAmount(fromToken, fillAmount), 113 | toToken, 114 | TradeType.EXACT_INPUT, 115 | { 116 | type: SwapType.UNIVERSAL_ROUTER, 117 | slippageTolerance: new Percent(1, 100), 118 | } 119 | ), 120 | [MEMETH[config.chainId], WETH9[config.chainId], AddressZero].includes( 121 | intent.buyToken 122 | ) 123 | ? "1" 124 | : router 125 | .route( 126 | CurrencyAmount.fromRawAmount( 127 | ethToken, 128 | parseUnits("1", 18).toString() 129 | ), 130 | toToken, 131 | TradeType.EXACT_INPUT, 132 | { 133 | type: SwapType.UNIVERSAL_ROUTER, 134 | slippageTolerance: new Percent(1, 100), 135 | } 136 | ) 137 | .then((r) => r!.quote.toFixed()), 138 | ]); 139 | 140 | return { 141 | kind: "sell", 142 | data: { 143 | calls: [ 144 | { 145 | to: intent.sellToken, 146 | data: new Interface([ 147 | "function approve(address spender, uint256 amount)", 148 | "function withdraw(uint256 amount)", 149 | ]).encodeFunctionData( 150 | inETH ? "withdraw" : "approve", 151 | inETH ? [fillAmount] : [PERMIT2[config.chainId], fillAmount] 152 | ), 153 | value: "0", 154 | }, 155 | !inETH 156 | ? { 157 | to: PERMIT2[config.chainId], 158 | data: new Interface([ 159 | "function approve(address token, address spender, uint160 amount, uint48 expiration)", 160 | ]).encodeFunctionData("approve", [ 161 | intent.sellToken, 162 | actualRoute!.methodParameters!.to, 163 | fillAmount, 164 | now() + 3600, 165 | ]), 166 | value: "0", 167 | } 168 | : undefined, 169 | { 170 | to: actualRoute!.methodParameters!.to, 171 | data: actualRoute!.methodParameters!.calldata, 172 | value: inETH ? fillAmount : "0", 173 | }, 174 | ].filter(Boolean) as Call[], 175 | minBuyAmount: parseUnits( 176 | actualRoute!.quote.toExact(), 177 | actualRoute!.quote.currency.decimals 178 | ).toString(), 179 | buyTokenToEthRate, 180 | gasUsed: actualRoute!.estimatedGasUsed.toNumber(), 181 | }, 182 | }; 183 | } 184 | }; 185 | -------------------------------------------------------------------------------- /src/solver/solutions/zeroex.ts: -------------------------------------------------------------------------------- 1 | import { Interface } from "@ethersproject/abi"; 2 | import { AddressZero } from "@ethersproject/constants"; 3 | import axios from "axios"; 4 | 5 | import { MEMETH } from "../../common/addresses"; 6 | import { IntentERC20 } from "../../common/types"; 7 | import { bn } from "../../common/utils"; 8 | import { config } from "../config"; 9 | import { SolutionDetailsERC20 } from "../types"; 10 | 11 | export const ZEROEX_ETH = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; 12 | 13 | export const solve = async ( 14 | intent: IntentERC20, 15 | fillAmount: string 16 | ): Promise => { 17 | const inETH = intent.sellToken === MEMETH[config.chainId]; 18 | if (intent.isBuy) { 19 | // Buy fixed amount of `buyToken` for variable amount of `sellToken` 20 | 21 | const { data: swapData } = await axios.get( 22 | config.chainId === 1 23 | ? "https://api.0x.org/swap/v1/quote" 24 | : "https://goerli.api.0x.org/swap/v1/quote", 25 | { 26 | params: { 27 | buyToken: 28 | intent.buyToken === AddressZero ? ZEROEX_ETH : intent.buyToken, 29 | sellToken: inETH ? ZEROEX_ETH : intent.sellToken, 30 | buyAmount: fillAmount, 31 | }, 32 | headers: { 33 | "0x-Api-Key": config.zeroExApiKey, 34 | }, 35 | } 36 | ); 37 | 38 | // Adjust the sell amount based on the slippage (which defaults to 1%) 39 | const sellAmount = bn(swapData.sellAmount).add(1).mul(10100).div(10000); 40 | 41 | return { 42 | kind: "buy", 43 | data: { 44 | calls: [ 45 | { 46 | to: intent.sellToken, 47 | data: new Interface([ 48 | "function approve(address spender, uint256 amount)", 49 | "function withdraw(uint256 amount)", 50 | ]).encodeFunctionData( 51 | inETH ? "withdraw" : "approve", 52 | inETH ? [sellAmount] : [swapData.to, sellAmount] 53 | ), 54 | value: "0", 55 | }, 56 | { 57 | to: swapData.to, 58 | data: swapData.data, 59 | value: inETH ? sellAmount.toString() : "0", 60 | }, 61 | ], 62 | maxSellAmount: sellAmount.toString(), 63 | sellTokenToEthRate: swapData.sellTokenToEthRate, 64 | gasUsed: swapData.estimatedGas, 65 | }, 66 | }; 67 | } else { 68 | // Sell fixed amount of `sellToken` for variable amount of `buyToken` 69 | 70 | const { data: swapData } = await axios.get( 71 | config.chainId === 1 72 | ? "https://api.0x.org/swap/v1/quote" 73 | : "https://goerli.api.0x.org/swap/v1/quote", 74 | { 75 | params: { 76 | buyToken: 77 | intent.buyToken === AddressZero ? ZEROEX_ETH : intent.buyToken, 78 | sellToken: inETH ? ZEROEX_ETH : intent.sellToken, 79 | sellAmount: fillAmount, 80 | }, 81 | headers: { 82 | "0x-Api-Key": config.zeroExApiKey, 83 | }, 84 | } 85 | ); 86 | 87 | return { 88 | kind: "sell", 89 | data: { 90 | calls: [ 91 | { 92 | to: intent.sellToken, 93 | data: new Interface([ 94 | "function approve(address spender, uint256 amount)", 95 | "function withdraw(uint256 amount)", 96 | ]).encodeFunctionData( 97 | inETH ? "withdraw" : "approve", 98 | inETH ? [fillAmount] : [swapData.to, fillAmount] 99 | ), 100 | value: "0", 101 | }, 102 | { 103 | to: swapData.to, 104 | data: swapData.data, 105 | value: inETH ? fillAmount : "0", 106 | }, 107 | ], 108 | minBuyAmount: swapData.buyAmount, 109 | buyTokenToEthRate: swapData.buyTokenToEthRate, 110 | gasUsed: swapData.estimatedGas, 111 | }, 112 | }; 113 | } 114 | }; 115 | -------------------------------------------------------------------------------- /src/solver/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IntentERC20, 3 | IntentERC721, 4 | SolutionERC20, 5 | SolutionERC721, 6 | TxData, 7 | } from "../common/types"; 8 | 9 | export type Call = { 10 | to: string; 11 | data: string; 12 | value: string; 13 | }; 14 | 15 | // ERC20 16 | 17 | export type SellSolutionDataERC20 = { 18 | calls: Call[]; 19 | minBuyAmount: string; 20 | buyTokenToEthRate: string; 21 | gasUsed: number; 22 | }; 23 | 24 | export type BuySolutionDataERC20 = { 25 | calls: Call[]; 26 | maxSellAmount: string; 27 | sellTokenToEthRate: string; 28 | gasUsed: number; 29 | }; 30 | 31 | export type SolutionDetailsERC20 = 32 | | { 33 | kind: "sell"; 34 | data: SellSolutionDataERC20; 35 | } 36 | | { 37 | kind: "buy"; 38 | data: BuySolutionDataERC20; 39 | }; 40 | 41 | export type CachedSolutionERC20 = { 42 | intent: IntentERC20; 43 | solution: SolutionERC20; 44 | approvalTxOrTxHash?: string; 45 | }; 46 | 47 | // ERC721 48 | 49 | export type BuySolutionDataERC721 = { 50 | calls: Call[]; 51 | txs: TxData[]; 52 | tokenIds: string[]; 53 | maxSellAmountInEth: string; 54 | sellTokenToEthRate: string; 55 | gasUsed: number; 56 | }; 57 | 58 | export type SolutionDetailsERC721 = { 59 | kind: "buy"; 60 | data: BuySolutionDataERC721; 61 | }; 62 | 63 | export type CachedSolutionERC721 = { 64 | intent: IntentERC721; 65 | solution: SolutionERC721; 66 | approvalTxOrTxHash?: string; 67 | }; 68 | -------------------------------------------------------------------------------- /test/erc20/bulk-signing.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultAbiCoder } from "@ethersproject/abi"; 2 | import { AddressZero } from "@ethersproject/constants"; 3 | import { Contract } from "@ethersproject/contracts"; 4 | import { time } from "@nomicfoundation/hardhat-network-helpers"; 5 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 6 | import { expect } from "chai"; 7 | import { ethers } from "hardhat"; 8 | 9 | import { Intent, getIntentHash, bulkSign } from "./utils"; 10 | import { bn, getCurrentTimestamp, getRandomInteger } from "../utils"; 11 | 12 | describe("[ERC20] Bulk-signing", async () => { 13 | let chainId: number; 14 | 15 | let deployer: SignerWithAddress; 16 | let alice: SignerWithAddress; 17 | let bob: SignerWithAddress; 18 | 19 | let memswap: Contract; 20 | let nft: Contract; 21 | 22 | let solutionProxy: Contract; 23 | let token0: Contract; 24 | let token1: Contract; 25 | 26 | beforeEach(async () => { 27 | chainId = await ethers.provider.getNetwork().then((n) => n.chainId); 28 | 29 | [deployer, alice, bob] = await ethers.getSigners(); 30 | 31 | nft = await ethers 32 | .getContractFactory("MemswapAlphaNFT") 33 | .then((factory) => factory.deploy(deployer.address, "", "")); 34 | memswap = await ethers 35 | .getContractFactory("MemswapERC20") 36 | .then((factory) => factory.deploy(nft.address)); 37 | 38 | solutionProxy = await ethers 39 | .getContractFactory("MockSolutionProxy") 40 | .then((factory) => factory.deploy(memswap.address)); 41 | token0 = await ethers 42 | .getContractFactory("MockERC20") 43 | .then((factory) => factory.deploy()); 44 | token1 = await ethers 45 | .getContractFactory("MockERC20") 46 | .then((factory) => factory.deploy()); 47 | 48 | // Allowed the Memswap contract to mint 49 | await nft.connect(deployer).setIsAllowedToMint([memswap.address], [true]); 50 | 51 | // Send some ETH to solution proxy contract for the tests where `tokenOut` is ETH 52 | await deployer.sendTransaction({ 53 | to: solutionProxy.address, 54 | value: ethers.utils.parseEther("10"), 55 | }); 56 | }); 57 | 58 | const bulkSigning = async (count: number) => { 59 | const currentTime = await getCurrentTimestamp(); 60 | 61 | // Generate intents 62 | const intents: Intent[] = []; 63 | for (let i = 0; i < count; i++) { 64 | intents.push({ 65 | isBuy: false, 66 | buyToken: token1.address, 67 | sellToken: token0.address, 68 | maker: alice.address, 69 | solver: AddressZero, 70 | source: AddressZero, 71 | feeBps: 0, 72 | surplusBps: 0, 73 | startTime: currentTime, 74 | endTime: currentTime + 60, 75 | nonce: 0, 76 | isPartiallyFillable: true, 77 | isSmartOrder: false, 78 | isIncentivized: false, 79 | amount: ethers.utils.parseEther("0.5"), 80 | endAmount: ethers.utils.parseEther("0.3"), 81 | startAmountBps: 0, 82 | expectedAmountBps: 0, 83 | }); 84 | } 85 | 86 | // Bulk-sign 87 | await bulkSign(alice, intents, memswap.address, chainId); 88 | 89 | // Choose a random intent to solve 90 | const intent = intents[getRandomInteger(0, intents.length - 1)]; 91 | 92 | // Mint and approve 93 | await token0.connect(alice).mint(intent.amount); 94 | await token0.connect(alice).approve(memswap.address, intent.amount); 95 | 96 | // Move to a known block timestamp 97 | const nextBlockTime = getRandomInteger(intent.startTime, intent.endTime); 98 | await time.setNextBlockTimestamp( 99 | // Try to avoid `Timestamp is lower than the previous block's timestamp` errors 100 | Math.max( 101 | nextBlockTime, 102 | await ethers.provider.getBlock("latest").then((b) => b.timestamp + 1) 103 | ) 104 | ); 105 | 106 | // Compute start amount 107 | const startAmount = bn(intent.endAmount).add( 108 | bn(intent.endAmount).mul(intent.startAmountBps).div(10000) 109 | ); 110 | 111 | // Compute the required amount at above timestamp 112 | const amount = bn(startAmount).sub( 113 | bn(startAmount) 114 | .sub(intent.endAmount) 115 | .mul(bn(nextBlockTime).sub(intent.startTime)) 116 | .div(bn(intent.endTime).sub(intent.startTime)) 117 | ); 118 | 119 | await expect( 120 | solutionProxy.connect(bob).solveERC20( 121 | intent, 122 | { 123 | data: defaultAbiCoder.encode(["uint128"], [0]), 124 | fillAmount: intent.amount, 125 | }, 126 | [] 127 | ) 128 | ) 129 | .to.emit(memswap, "IntentSolved") 130 | .withArgs( 131 | getIntentHash(intent), 132 | intent.isBuy, 133 | intent.buyToken, 134 | intent.sellToken, 135 | intent.maker, 136 | solutionProxy.address, 137 | amount, 138 | intent.amount 139 | ); 140 | }; 141 | 142 | const RUNS = 30; 143 | for (let i = 0; i < RUNS; i++) { 144 | const count = getRandomInteger(1, 200); 145 | it(`Bulk-sign (${count} intents)`, async () => bulkSigning(count)); 146 | } 147 | }); 148 | -------------------------------------------------------------------------------- /test/erc20/incentivization.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultAbiCoder } from "@ethersproject/abi"; 2 | import { AddressZero } from "@ethersproject/constants"; 3 | import { Contract } from "@ethersproject/contracts"; 4 | import { parseUnits } from "@ethersproject/units"; 5 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 6 | import { expect } from "chai"; 7 | import hre, { ethers } from "hardhat"; 8 | 9 | import { Intent, signIntent } from "./utils"; 10 | import { getCurrentTimestamp, getIncentivizationTip } from "../utils"; 11 | 12 | describe("[ERC20] Incentivization", async () => { 13 | let deployer: SignerWithAddress; 14 | let alice: SignerWithAddress; 15 | let bob: SignerWithAddress; 16 | 17 | let memswap: Contract; 18 | let nft: Contract; 19 | 20 | let solutionProxy: Contract; 21 | let token0: Contract; 22 | let token1: Contract; 23 | 24 | beforeEach(async () => { 25 | [deployer, alice, bob] = await ethers.getSigners(); 26 | 27 | nft = await ethers 28 | .getContractFactory("MemswapAlphaNFT") 29 | .then((factory) => factory.deploy(deployer.address, "", "")); 30 | memswap = await ethers 31 | .getContractFactory("MemswapERC20") 32 | .then((factory) => factory.deploy(nft.address)); 33 | 34 | solutionProxy = await ethers 35 | .getContractFactory("MockSolutionProxy") 36 | .then((factory) => factory.deploy(memswap.address)); 37 | token0 = await ethers 38 | .getContractFactory("MockERC20") 39 | .then((factory) => factory.deploy()); 40 | token1 = await ethers 41 | .getContractFactory("MockERC20") 42 | .then((factory) => factory.deploy()); 43 | 44 | // Allowed the Memswap contract to mint 45 | await nft.connect(deployer).setIsAllowedToMint([memswap.address], [true]); 46 | 47 | // Send some ETH to solution proxy contract for the tests where `tokenOut` is ETH 48 | await deployer.sendTransaction({ 49 | to: solutionProxy.address, 50 | value: ethers.utils.parseEther("10"), 51 | }); 52 | }); 53 | 54 | it("Cannot use anything other than the required priority fee", async () => { 55 | const currentTime = await getCurrentTimestamp(); 56 | 57 | // Generate intent 58 | const intent: Intent = { 59 | isBuy: false, 60 | buyToken: token0.address, 61 | sellToken: token1.address, 62 | maker: alice.address, 63 | solver: AddressZero, 64 | source: AddressZero, 65 | feeBps: 0, 66 | surplusBps: 0, 67 | startTime: currentTime, 68 | endTime: currentTime + 60, 69 | nonce: 0, 70 | isPartiallyFillable: true, 71 | isSmartOrder: false, 72 | isIncentivized: true, 73 | amount: ethers.utils.parseEther("0.5"), 74 | endAmount: ethers.utils.parseEther("0.3"), 75 | startAmountBps: 0, 76 | expectedAmountBps: 0, 77 | }; 78 | intent.signature = await signIntent(alice, memswap.address, intent); 79 | 80 | // Mint and approve 81 | await token1.connect(alice).mint(intent.amount); 82 | await token1.connect(alice).approve(memswap.address, intent.amount); 83 | 84 | // The priority fee cannot be lower than required 85 | { 86 | const nextBaseFee = parseUnits("10", "gwei"); 87 | await hre.network.provider.send("hardhat_setNextBlockBaseFeePerGas", [ 88 | "0x" + nextBaseFee.toNumber().toString(16), 89 | ]); 90 | 91 | const requiredPriorityFee = await memswap.requiredPriorityFee(); 92 | await expect( 93 | solutionProxy.connect(bob).solveERC20( 94 | intent, 95 | { 96 | data: defaultAbiCoder.encode(["uint128"], [0]), 97 | fillAmount: intent.amount, 98 | }, 99 | [], 100 | { 101 | maxFeePerGas: nextBaseFee.add(requiredPriorityFee).sub(1), 102 | maxPriorityFeePerGas: requiredPriorityFee, 103 | } 104 | ) 105 | ).to.be.revertedWith("InvalidPriorityFee"); 106 | } 107 | 108 | // The priority fee cannot be higher than required 109 | { 110 | const nextBaseFee = parseUnits("10", "gwei"); 111 | await hre.network.provider.send("hardhat_setNextBlockBaseFeePerGas", [ 112 | "0x" + nextBaseFee.toNumber().toString(16), 113 | ]); 114 | 115 | const requiredPriorityFee = await memswap.requiredPriorityFee(); 116 | await expect( 117 | solutionProxy.connect(bob).solveERC20( 118 | intent, 119 | { 120 | data: defaultAbiCoder.encode(["uint128"], [0]), 121 | fillAmount: intent.amount, 122 | }, 123 | [], 124 | { 125 | maxFeePerGas: nextBaseFee.add(requiredPriorityFee).add(1), 126 | maxPriorityFeePerGas: requiredPriorityFee.add(1), 127 | } 128 | ) 129 | ).to.be.revertedWith("InvalidPriorityFee"); 130 | } 131 | 132 | // The priority fee should match the required value 133 | await solutionProxy.connect(bob).solveERC20( 134 | intent, 135 | { 136 | data: defaultAbiCoder.encode(["uint128"], [0]), 137 | fillAmount: intent.amount, 138 | }, 139 | [], 140 | { 141 | value: await getIncentivizationTip( 142 | memswap, 143 | intent.isBuy, 144 | intent.endAmount, 145 | intent.expectedAmountBps, 146 | intent.endAmount 147 | ), 148 | maxPriorityFeePerGas: await memswap.requiredPriorityFee(), 149 | } 150 | ); 151 | }); 152 | 153 | it("Cannot pay builder out-of-band", async () => { 154 | const currentTime = await getCurrentTimestamp(); 155 | 156 | // Generate intent 157 | const intent: Intent = { 158 | isBuy: false, 159 | buyToken: token0.address, 160 | sellToken: token1.address, 161 | maker: alice.address, 162 | solver: AddressZero, 163 | source: AddressZero, 164 | feeBps: 0, 165 | surplusBps: 0, 166 | startTime: currentTime, 167 | endTime: currentTime + 60, 168 | nonce: 0, 169 | isPartiallyFillable: true, 170 | isSmartOrder: false, 171 | isIncentivized: true, 172 | amount: ethers.utils.parseEther("0.5"), 173 | endAmount: ethers.utils.parseEther("0.3"), 174 | startAmountBps: 0, 175 | expectedAmountBps: 0, 176 | }; 177 | intent.signature = await signIntent(alice, memswap.address, intent); 178 | 179 | // Mint and approve 180 | await token1.connect(alice).mint(intent.amount); 181 | await token1.connect(alice).approve(memswap.address, intent.amount); 182 | 183 | // Enable out-of-band payments to the builder 184 | await solutionProxy.connect(bob).setPayBuilderOnRefund(true); 185 | 186 | // The solution will fail if the tip the builder was too high 187 | await expect( 188 | solutionProxy.connect(bob).solveERC20( 189 | intent, 190 | { 191 | data: defaultAbiCoder.encode(["uint128"], [0]), 192 | fillAmount: intent.amount, 193 | }, 194 | [], 195 | { 196 | value: await getIncentivizationTip( 197 | memswap, 198 | intent.isBuy, 199 | intent.endAmount, 200 | intent.expectedAmountBps, 201 | intent.endAmount 202 | ).then((tip) => tip.add(1)), 203 | maxPriorityFeePerGas: await memswap.requiredPriorityFee(), 204 | } 205 | ) 206 | ).to.be.revertedWith("InvalidTip"); 207 | }); 208 | 209 | it("Insufficient tip", async () => { 210 | const currentTime = await getCurrentTimestamp(); 211 | 212 | // Generate intent 213 | const intent: Intent = { 214 | isBuy: false, 215 | buyToken: token0.address, 216 | sellToken: token1.address, 217 | maker: alice.address, 218 | solver: AddressZero, 219 | source: AddressZero, 220 | feeBps: 0, 221 | surplusBps: 0, 222 | startTime: currentTime, 223 | endTime: currentTime + 60, 224 | nonce: 0, 225 | isPartiallyFillable: true, 226 | isSmartOrder: false, 227 | isIncentivized: true, 228 | amount: ethers.utils.parseEther("0.5"), 229 | endAmount: ethers.utils.parseEther("0.3"), 230 | startAmountBps: 0, 231 | expectedAmountBps: 0, 232 | }; 233 | intent.signature = await signIntent(alice, memswap.address, intent); 234 | 235 | // Mint and approve 236 | await token1.connect(alice).mint(intent.amount); 237 | await token1.connect(alice).approve(memswap.address, intent.amount); 238 | 239 | // The solution will fail if the tip the builder was too low 240 | await expect( 241 | solutionProxy.connect(bob).solveERC20( 242 | intent, 243 | { 244 | data: defaultAbiCoder.encode(["uint128"], [0]), 245 | fillAmount: intent.amount, 246 | }, 247 | [], 248 | { 249 | value: await getIncentivizationTip( 250 | memswap, 251 | intent.isBuy, 252 | intent.endAmount, 253 | intent.expectedAmountBps, 254 | intent.endAmount 255 | ).then((tip) => tip.sub(1)), 256 | maxPriorityFeePerGas: await memswap.requiredPriorityFee(), 257 | } 258 | ) 259 | ).to.be.revertedWith("InvalidTip"); 260 | }); 261 | }); 262 | -------------------------------------------------------------------------------- /test/erc20/utils.ts: -------------------------------------------------------------------------------- 1 | import { defaultAbiCoder } from "@ethersproject/abi"; 2 | import { TypedDataSigner } from "@ethersproject/abstract-signer"; 3 | import { BigNumberish, BigNumber } from "@ethersproject/bignumber"; 4 | import { hexConcat } from "@ethersproject/bytes"; 5 | import { AddressZero } from "@ethersproject/constants"; 6 | import { Contract } from "@ethersproject/contracts"; 7 | import { _TypedDataEncoder } from "@ethersproject/hash"; 8 | import { keccak256 } from "@ethersproject/keccak256"; 9 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 10 | import { MerkleTree } from "merkletreejs"; 11 | 12 | import { bn } from "../utils"; 13 | 14 | // Contract utilities 15 | 16 | export type Intent = { 17 | isBuy: boolean; 18 | buyToken: string; 19 | sellToken: string; 20 | maker: string; 21 | solver: string; 22 | source: string; 23 | feeBps: number; 24 | surplusBps: number; 25 | startTime: number; 26 | endTime: number; 27 | nonce: BigNumberish; 28 | isPartiallyFillable: boolean; 29 | isSmartOrder: boolean; 30 | isIncentivized: boolean; 31 | amount: BigNumberish; 32 | endAmount: BigNumberish; 33 | startAmountBps: number; 34 | expectedAmountBps: number; 35 | signature?: string; 36 | }; 37 | 38 | export type Authorization = { 39 | intentHash: string; 40 | solver: string; 41 | fillAmountToCheck: BigNumberish; 42 | executeAmountToCheck: BigNumberish; 43 | blockDeadline: number; 44 | signature?: string; 45 | }; 46 | 47 | export const getIntentHash = (intent: any) => 48 | _TypedDataEncoder.hashStruct("Intent", INTENT_EIP712_TYPES, intent); 49 | 50 | export const signAuthorization = async ( 51 | signer: SignerWithAddress, 52 | contract: string, 53 | authorization: any 54 | ) => 55 | signer._signTypedData( 56 | EIP712_DOMAIN(contract, await signer.getChainId()), 57 | AUTHORIZATION_EIP712_TYPES, 58 | authorization 59 | ); 60 | 61 | export const signIntent = async ( 62 | signer: SignerWithAddress, 63 | contract: string, 64 | intent: any 65 | ) => 66 | signer._signTypedData( 67 | EIP712_DOMAIN(contract, await signer.getChainId()), 68 | INTENT_EIP712_TYPES, 69 | intent 70 | ); 71 | 72 | export const EIP712_DOMAIN = (contract: string, chainId: number) => ({ 73 | name: "MemswapERC20", 74 | version: "1.0", 75 | chainId, 76 | verifyingContract: contract, 77 | }); 78 | 79 | export const AUTHORIZATION_EIP712_TYPES = { 80 | Authorization: [ 81 | { 82 | name: "intentHash", 83 | type: "bytes32", 84 | }, 85 | { 86 | name: "solver", 87 | type: "address", 88 | }, 89 | { 90 | name: "fillAmountToCheck", 91 | type: "uint128", 92 | }, 93 | { 94 | name: "executeAmountToCheck", 95 | type: "uint128", 96 | }, 97 | { 98 | name: "blockDeadline", 99 | type: "uint32", 100 | }, 101 | ], 102 | }; 103 | 104 | export const INTENT_EIP712_TYPES = { 105 | Intent: [ 106 | { 107 | name: "isBuy", 108 | type: "bool", 109 | }, 110 | { 111 | name: "buyToken", 112 | type: "address", 113 | }, 114 | { 115 | name: "sellToken", 116 | type: "address", 117 | }, 118 | { 119 | name: "maker", 120 | type: "address", 121 | }, 122 | { 123 | name: "solver", 124 | type: "address", 125 | }, 126 | { 127 | name: "source", 128 | type: "address", 129 | }, 130 | { 131 | name: "feeBps", 132 | type: "uint16", 133 | }, 134 | { 135 | name: "surplusBps", 136 | type: "uint16", 137 | }, 138 | { 139 | name: "startTime", 140 | type: "uint32", 141 | }, 142 | { 143 | name: "endTime", 144 | type: "uint32", 145 | }, 146 | { 147 | name: "nonce", 148 | type: "uint256", 149 | }, 150 | { 151 | name: "isPartiallyFillable", 152 | type: "bool", 153 | }, 154 | { 155 | name: "isSmartOrder", 156 | type: "bool", 157 | }, 158 | { 159 | name: "isIncentivized", 160 | type: "bool", 161 | }, 162 | { 163 | name: "amount", 164 | type: "uint128", 165 | }, 166 | { 167 | name: "endAmount", 168 | type: "uint128", 169 | }, 170 | { 171 | name: "startAmountBps", 172 | type: "uint16", 173 | }, 174 | { 175 | name: "expectedAmountBps", 176 | type: "uint16", 177 | }, 178 | ], 179 | }; 180 | 181 | // Bulk-signing utilities 182 | 183 | export const bulkSign = async ( 184 | signer: TypedDataSigner, 185 | intents: any[], 186 | contract: string, 187 | chainId: number 188 | ) => { 189 | const { signatureData, proofs } = getBulkSignatureDataWithProofs( 190 | intents, 191 | contract, 192 | chainId 193 | ); 194 | 195 | const signature = await signer._signTypedData( 196 | signatureData.domain, 197 | signatureData.types, 198 | signatureData.value 199 | ); 200 | 201 | intents.forEach((intent, i) => { 202 | intent.signature = encodeBulkOrderProofAndSignature( 203 | i, 204 | proofs[i], 205 | signature 206 | ); 207 | }); 208 | }; 209 | 210 | const getBulkSignatureDataWithProofs = ( 211 | intents: any[], 212 | contract: string, 213 | chainId: number 214 | ) => { 215 | const height = Math.max(Math.ceil(Math.log2(intents.length)), 1); 216 | const size = Math.pow(2, height); 217 | 218 | const types = { ...INTENT_EIP712_TYPES }; 219 | (types as any).BatchIntent = [ 220 | { name: "tree", type: `Intent${`[2]`.repeat(height)}` }, 221 | ]; 222 | const encoder = _TypedDataEncoder.from(types); 223 | 224 | const hashElement = (element: any) => encoder.hashStruct("Intent", element); 225 | const elements = [...intents]; 226 | const leaves = elements.map((i) => hashElement(i)); 227 | 228 | const defaultElement: Intent = { 229 | isBuy: false, 230 | buyToken: AddressZero, 231 | sellToken: AddressZero, 232 | maker: AddressZero, 233 | solver: AddressZero, 234 | source: AddressZero, 235 | feeBps: 0, 236 | surplusBps: 0, 237 | startTime: 0, 238 | endTime: 0, 239 | nonce: 0, 240 | isPartiallyFillable: false, 241 | isSmartOrder: false, 242 | isIncentivized: false, 243 | amount: 0, 244 | endAmount: 0, 245 | startAmountBps: 0, 246 | expectedAmountBps: 0, 247 | }; 248 | const defaultLeaf = hashElement(defaultElement); 249 | 250 | // Ensure the tree is complete 251 | while (elements.length < size) { 252 | elements.push(defaultElement); 253 | leaves.push(defaultLeaf); 254 | } 255 | 256 | const hexToBuffer = (value: string) => Buffer.from(value.slice(2), "hex"); 257 | const bufferKeccak = (value: string) => hexToBuffer(keccak256(value)); 258 | 259 | const tree = new MerkleTree(leaves.map(hexToBuffer), bufferKeccak, { 260 | complete: true, 261 | sort: false, 262 | hashLeaves: false, 263 | fillDefaultHash: hexToBuffer(defaultLeaf), 264 | }); 265 | 266 | let chunks: object[] = [...elements]; 267 | while (chunks.length > 2) { 268 | const newSize = Math.ceil(chunks.length / 2); 269 | chunks = Array(newSize) 270 | .fill(0) 271 | .map((_, i) => chunks.slice(i * 2, (i + 1) * 2)); 272 | } 273 | 274 | return { 275 | signatureData: { 276 | signatureKind: "eip712", 277 | domain: EIP712_DOMAIN(contract, chainId), 278 | types, 279 | value: { tree: chunks }, 280 | primaryType: _TypedDataEncoder.getPrimaryType(types), 281 | }, 282 | proofs: intents.map((_, i) => tree.getHexProof(leaves[i], i)), 283 | }; 284 | }; 285 | 286 | const encodeBulkOrderProofAndSignature = ( 287 | orderIndex: number, 288 | merkleProof: string[], 289 | signature: string 290 | ) => { 291 | return hexConcat([ 292 | signature, 293 | `0x${orderIndex.toString(16).padStart(6, "0")}`, 294 | defaultAbiCoder.encode([`uint256[${merkleProof.length}]`], [merkleProof]), 295 | ]); 296 | }; 297 | -------------------------------------------------------------------------------- /test/erc721/bulk-signing.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultAbiCoder } from "@ethersproject/abi"; 2 | import { AddressZero } from "@ethersproject/constants"; 3 | import { Contract } from "@ethersproject/contracts"; 4 | import { time } from "@nomicfoundation/hardhat-network-helpers"; 5 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 6 | import { expect } from "chai"; 7 | import { ethers } from "hardhat"; 8 | 9 | import { Intent, getIntentHash, bulkSign } from "./utils"; 10 | import { bn, getCurrentTimestamp, getRandomInteger } from "../utils"; 11 | 12 | describe("[ERC721] Bulk-signing", async () => { 13 | let chainId: number; 14 | 15 | let deployer: SignerWithAddress; 16 | let alice: SignerWithAddress; 17 | let bob: SignerWithAddress; 18 | 19 | let memswap: Contract; 20 | let nft: Contract; 21 | 22 | let solutionProxy: Contract; 23 | let token0: Contract; 24 | let token1: Contract; 25 | 26 | beforeEach(async () => { 27 | chainId = await ethers.provider.getNetwork().then((n) => n.chainId); 28 | 29 | [deployer, alice, bob] = await ethers.getSigners(); 30 | 31 | nft = await ethers 32 | .getContractFactory("MemswapAlphaNFT") 33 | .then((factory) => factory.deploy(deployer.address, "", "")); 34 | memswap = await ethers 35 | .getContractFactory("MemswapERC721") 36 | .then((factory) => factory.deploy(nft.address)); 37 | 38 | solutionProxy = await ethers 39 | .getContractFactory("MockSolutionProxy") 40 | .then((factory) => factory.deploy(memswap.address)); 41 | token0 = await ethers 42 | .getContractFactory("MockERC20") 43 | .then((factory) => factory.deploy()); 44 | token1 = await ethers 45 | .getContractFactory("MockERC721") 46 | .then((factory) => factory.deploy()); 47 | 48 | // Allowed the Memswap contract to mint 49 | await nft.connect(deployer).setIsAllowedToMint([memswap.address], [true]); 50 | 51 | // Send some ETH to solution proxy contract for the tests where `tokenOut` is ETH 52 | await deployer.sendTransaction({ 53 | to: solutionProxy.address, 54 | value: ethers.utils.parseEther("10"), 55 | }); 56 | }); 57 | 58 | const bulkSigning = async (count: number) => { 59 | const currentTime = await getCurrentTimestamp(); 60 | 61 | // Generate intents 62 | const intents: Intent[] = []; 63 | for (let i = 0; i < count; i++) { 64 | intents.push({ 65 | isBuy: true, 66 | buyToken: token1.address, 67 | sellToken: token0.address, 68 | maker: alice.address, 69 | solver: AddressZero, 70 | source: AddressZero, 71 | feeBps: 0, 72 | surplusBps: 0, 73 | startTime: currentTime, 74 | endTime: currentTime + 60, 75 | nonce: 0, 76 | isPartiallyFillable: true, 77 | isSmartOrder: false, 78 | isIncentivized: false, 79 | isCriteriaOrder: true, 80 | tokenIdOrCriteria: 0, 81 | amount: 1, 82 | endAmount: ethers.utils.parseEther("0.3"), 83 | startAmountBps: 0, 84 | expectedAmountBps: 0, 85 | }); 86 | } 87 | 88 | // Bulk-sign 89 | await bulkSign(alice, intents, memswap.address, chainId); 90 | 91 | // Choose a random intent to solve 92 | const intent = intents[getRandomInteger(0, intents.length - 1)]; 93 | 94 | // Mint and approve 95 | await token0.connect(alice).mint(intent.endAmount); 96 | await token0.connect(alice).approve(memswap.address, intent.endAmount); 97 | 98 | // Move to a known block timestamp 99 | const nextBlockTime = getRandomInteger(intent.startTime, intent.endTime); 100 | await time.setNextBlockTimestamp( 101 | // Try to avoid `Timestamp is lower than the previous block's timestamp` errors 102 | Math.max( 103 | nextBlockTime, 104 | await ethers.provider.getBlock("latest").then((b) => b.timestamp + 1) 105 | ) 106 | ); 107 | 108 | // Compute start amount 109 | const startAmount = bn(intent.endAmount).add( 110 | bn(intent.endAmount).mul(intent.startAmountBps).div(10000) 111 | ); 112 | 113 | // Compute the required amount at above timestamp 114 | const amount = bn(startAmount).sub( 115 | bn(startAmount) 116 | .sub(intent.endAmount) 117 | .mul(bn(nextBlockTime).sub(intent.startTime)) 118 | .div(bn(intent.endTime).sub(intent.startTime)) 119 | ); 120 | 121 | const tokenIdsToFill = [0]; 122 | await expect( 123 | solutionProxy.connect(bob).solveERC721( 124 | intent, 125 | { 126 | data: defaultAbiCoder.encode(["uint128"], [0]), 127 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 128 | tokenId, 129 | criteriaProof: [], 130 | })), 131 | }, 132 | [] 133 | ) 134 | ) 135 | .to.emit(memswap, "IntentSolved") 136 | .withArgs( 137 | getIntentHash(intent), 138 | intent.isBuy, 139 | intent.buyToken, 140 | intent.sellToken, 141 | intent.maker, 142 | solutionProxy.address, 143 | amount, 144 | tokenIdsToFill 145 | ); 146 | }; 147 | 148 | const RUNS = 30; 149 | for (let i = 0; i < RUNS; i++) { 150 | const count = getRandomInteger(1, 200); 151 | it(`Bulk-sign (${count} intents)`, async () => bulkSigning(count)); 152 | } 153 | }); 154 | -------------------------------------------------------------------------------- /test/erc721/criteria.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultAbiCoder } from "@ethersproject/abi"; 2 | import { AddressZero } from "@ethersproject/constants"; 3 | import { Contract } from "@ethersproject/contracts"; 4 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 5 | import { expect } from "chai"; 6 | import { ethers } from "hardhat"; 7 | 8 | import { 9 | Intent, 10 | generateMerkleProof, 11 | generateMerkleTree, 12 | signIntent, 13 | } from "./utils"; 14 | import { 15 | getCurrentTimestamp, 16 | getRandomBoolean, 17 | getRandomInteger, 18 | } from "../utils"; 19 | 20 | describe("[ERC721] Criteria", async () => { 21 | let deployer: SignerWithAddress; 22 | let alice: SignerWithAddress; 23 | let bob: SignerWithAddress; 24 | 25 | let memswap: Contract; 26 | let nft: Contract; 27 | 28 | let solutionProxy: Contract; 29 | let token0: Contract; 30 | let token1: Contract; 31 | 32 | beforeEach(async () => { 33 | [deployer, alice, bob] = await ethers.getSigners(); 34 | 35 | nft = await ethers 36 | .getContractFactory("MemswapAlphaNFT") 37 | .then((factory) => factory.deploy(deployer.address, "", "")); 38 | memswap = await ethers 39 | .getContractFactory("MemswapERC721") 40 | .then((factory) => factory.deploy(nft.address)); 41 | 42 | solutionProxy = await ethers 43 | .getContractFactory("MockSolutionProxy") 44 | .then((factory) => factory.deploy(memswap.address)); 45 | token0 = await ethers 46 | .getContractFactory("MockERC20") 47 | .then((factory) => factory.deploy()); 48 | token1 = await ethers 49 | .getContractFactory("MockERC721") 50 | .then((factory) => factory.deploy()); 51 | 52 | // Allowed the Memswap contract to mint 53 | await nft.connect(deployer).setIsAllowedToMint([memswap.address], [true]); 54 | 55 | // Send some ETH to solution proxy contract for the tests where `tokenOut` is ETH 56 | await deployer.sendTransaction({ 57 | to: solutionProxy.address, 58 | value: ethers.utils.parseEther("10"), 59 | }); 60 | }); 61 | 62 | it("No criteria", async () => { 63 | const currentTime = await getCurrentTimestamp(); 64 | 65 | // Generate intent 66 | const intent: Intent = { 67 | isBuy: true, 68 | buyToken: token1.address, 69 | sellToken: token0.address, 70 | maker: alice.address, 71 | solver: AddressZero, 72 | source: AddressZero, 73 | feeBps: 0, 74 | surplusBps: 0, 75 | startTime: currentTime, 76 | endTime: currentTime + 60, 77 | nonce: 0, 78 | isPartiallyFillable: true, 79 | isSmartOrder: false, 80 | isIncentivized: false, 81 | isCriteriaOrder: false, 82 | tokenIdOrCriteria: 999, 83 | amount: 2, 84 | endAmount: ethers.utils.parseEther("0.3"), 85 | startAmountBps: 0, 86 | expectedAmountBps: 0, 87 | }; 88 | intent.signature = await signIntent(alice, memswap.address, intent); 89 | 90 | // Mint and approve 91 | await token0.connect(alice).mint(intent.endAmount); 92 | await token0.connect(alice).approve(memswap.address, intent.endAmount); 93 | 94 | // When the intent has no criteria, a single token id can be used for filling 95 | await expect( 96 | solutionProxy.connect(bob).solveERC721( 97 | intent, 98 | { 99 | data: defaultAbiCoder.encode(["uint128"], [0]), 100 | fillTokenDetails: [ 101 | { 102 | tokenId: 888, 103 | criteriaProof: [], 104 | }, 105 | ], 106 | }, 107 | [] 108 | ) 109 | ).to.be.revertedWith("InvalidTokenId"); 110 | 111 | // Succeeds when the fill token id matches `tokenIdOrCriteria` 112 | await solutionProxy.connect(bob).solveERC721( 113 | intent, 114 | { 115 | data: defaultAbiCoder.encode(["uint128"], [0]), 116 | fillTokenDetails: [ 117 | { 118 | tokenId: intent.tokenIdOrCriteria, 119 | criteriaProof: [], 120 | }, 121 | ], 122 | }, 123 | [] 124 | ); 125 | }); 126 | 127 | it("Empty criteria", async () => { 128 | const currentTime = await getCurrentTimestamp(); 129 | 130 | // Generate intent 131 | const intent: Intent = { 132 | isBuy: true, 133 | buyToken: token1.address, 134 | sellToken: token0.address, 135 | maker: alice.address, 136 | solver: AddressZero, 137 | source: AddressZero, 138 | feeBps: 0, 139 | surplusBps: 0, 140 | startTime: currentTime, 141 | endTime: currentTime + 60, 142 | nonce: 0, 143 | isPartiallyFillable: true, 144 | isSmartOrder: false, 145 | isIncentivized: false, 146 | isCriteriaOrder: true, 147 | tokenIdOrCriteria: 0, 148 | amount: 4, 149 | endAmount: ethers.utils.parseEther("0.3"), 150 | startAmountBps: 0, 151 | expectedAmountBps: 0, 152 | }; 153 | intent.signature = await signIntent(alice, memswap.address, intent); 154 | 155 | // Mint and approve 156 | await token0.connect(alice).mint(intent.endAmount); 157 | await token0.connect(alice).approve(memswap.address, intent.endAmount); 158 | 159 | // When the criteria is `0`, any token id can be used for filling 160 | const randomTokenId = getRandomInteger(1, 100000); 161 | await solutionProxy.connect(bob).solveERC721( 162 | intent, 163 | { 164 | data: defaultAbiCoder.encode(["uint128"], [0]), 165 | fillTokenDetails: [ 166 | { 167 | tokenId: randomTokenId, 168 | criteriaProof: [], 169 | }, 170 | ], 171 | }, 172 | [] 173 | ); 174 | }); 175 | 176 | const fullCriteria = async () => { 177 | const currentTime = await getCurrentTimestamp(); 178 | 179 | const criteriaTokenIds = [ 180 | ...new Set( 181 | [...Array(getRandomInteger(1, 1000)).keys()].map(() => 182 | getRandomInteger(1, 100000) 183 | ) 184 | ), 185 | ]; 186 | const tree = generateMerkleTree(criteriaTokenIds); 187 | const criteria = tree.getHexRoot(); 188 | 189 | const isBuy = getRandomBoolean(); 190 | if (isBuy) { 191 | // Generate intent 192 | const intent: Intent = { 193 | isBuy, 194 | buyToken: token1.address, 195 | sellToken: token0.address, 196 | maker: alice.address, 197 | solver: AddressZero, 198 | source: AddressZero, 199 | feeBps: 0, 200 | surplusBps: 0, 201 | startTime: currentTime, 202 | endTime: currentTime + 60, 203 | nonce: 0, 204 | isPartiallyFillable: true, 205 | isSmartOrder: false, 206 | isIncentivized: false, 207 | isCriteriaOrder: true, 208 | tokenIdOrCriteria: criteria, 209 | amount: 1, 210 | endAmount: ethers.utils.parseEther("0.3"), 211 | startAmountBps: 0, 212 | expectedAmountBps: 0, 213 | }; 214 | intent.signature = await signIntent(alice, memswap.address, intent); 215 | 216 | // Mint and approve 217 | await token0.connect(alice).mint(intent.endAmount); 218 | await token0.connect(alice).approve(memswap.address, intent.endAmount); 219 | 220 | const randomTokenId = getRandomBoolean() 221 | ? criteriaTokenIds[getRandomInteger(0, criteriaTokenIds.length - 1)] 222 | : getRandomInteger(1, 100000); 223 | if (criteriaTokenIds.includes(randomTokenId)) { 224 | await solutionProxy.connect(bob).solveERC721( 225 | intent, 226 | { 227 | data: defaultAbiCoder.encode(["uint128"], [0]), 228 | fillTokenDetails: [ 229 | { 230 | tokenId: randomTokenId, 231 | criteriaProof: generateMerkleProof(tree, randomTokenId), 232 | }, 233 | ], 234 | }, 235 | [] 236 | ); 237 | } else { 238 | await expect( 239 | solutionProxy.connect(bob).solveERC721( 240 | intent, 241 | { 242 | data: defaultAbiCoder.encode(["uint128"], [0]), 243 | fillTokenDetails: [ 244 | { 245 | tokenId: randomTokenId, 246 | criteriaProof: generateMerkleProof(tree, randomTokenId), 247 | }, 248 | ], 249 | }, 250 | [] 251 | ) 252 | ).to.be.revertedWith("InvalidCriteriaProof"); 253 | } 254 | } 255 | }; 256 | 257 | const RUNS = 50; 258 | for (let i = 0; i < RUNS; i++) { 259 | it(`Full criteria (run ${i + 1})`, async () => fullCriteria()); 260 | } 261 | }); 262 | -------------------------------------------------------------------------------- /test/erc721/incentivization.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultAbiCoder } from "@ethersproject/abi"; 2 | import { AddressZero } from "@ethersproject/constants"; 3 | import { Contract } from "@ethersproject/contracts"; 4 | import { parseUnits } from "@ethersproject/units"; 5 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 6 | import { expect } from "chai"; 7 | import hre, { ethers } from "hardhat"; 8 | 9 | import { Intent, signIntent } from "./utils"; 10 | import { getCurrentTimestamp, getIncentivizationTip } from "../utils"; 11 | 12 | describe("[ERC721] Incentivization", async () => { 13 | let deployer: SignerWithAddress; 14 | let alice: SignerWithAddress; 15 | let bob: SignerWithAddress; 16 | 17 | let memswap: Contract; 18 | let nft: Contract; 19 | 20 | let solutionProxy: Contract; 21 | let token0: Contract; 22 | let token1: Contract; 23 | 24 | beforeEach(async () => { 25 | [deployer, alice, bob] = await ethers.getSigners(); 26 | 27 | nft = await ethers 28 | .getContractFactory("MemswapAlphaNFT") 29 | .then((factory) => factory.deploy(deployer.address, "", "")); 30 | memswap = await ethers 31 | .getContractFactory("MemswapERC721") 32 | .then((factory) => factory.deploy(nft.address)); 33 | 34 | solutionProxy = await ethers 35 | .getContractFactory("MockSolutionProxy") 36 | .then((factory) => factory.deploy(memswap.address)); 37 | token0 = await ethers 38 | .getContractFactory("MockERC20") 39 | .then((factory) => factory.deploy()); 40 | token1 = await ethers 41 | .getContractFactory("MockERC721") 42 | .then((factory) => factory.deploy()); 43 | 44 | // Allowed the Memswap contract to mint 45 | await nft.connect(deployer).setIsAllowedToMint([memswap.address], [true]); 46 | 47 | // Send some ETH to solution proxy contract for the tests where `tokenOut` is ETH 48 | await deployer.sendTransaction({ 49 | to: solutionProxy.address, 50 | value: ethers.utils.parseEther("10"), 51 | }); 52 | }); 53 | 54 | it("Cannot use anything other than the required priority fee", async () => { 55 | const currentTime = await getCurrentTimestamp(); 56 | 57 | // Generate intent 58 | const intent: Intent = { 59 | isBuy: true, 60 | buyToken: token1.address, 61 | sellToken: token0.address, 62 | maker: alice.address, 63 | solver: AddressZero, 64 | source: AddressZero, 65 | feeBps: 0, 66 | surplusBps: 0, 67 | startTime: currentTime, 68 | endTime: currentTime + 60, 69 | nonce: 0, 70 | isPartiallyFillable: true, 71 | isSmartOrder: false, 72 | isIncentivized: true, 73 | isCriteriaOrder: true, 74 | tokenIdOrCriteria: 0, 75 | amount: 2, 76 | endAmount: ethers.utils.parseEther("0.3"), 77 | startAmountBps: 0, 78 | expectedAmountBps: 0, 79 | signature: "0x", 80 | }; 81 | intent.signature = await signIntent(alice, memswap.address, intent); 82 | 83 | // Mint and approve 84 | await token0.connect(alice).mint(intent.endAmount); 85 | await token0.connect(alice).approve(memswap.address, intent.endAmount); 86 | 87 | const tokenIdsToFill = [...Array(Number(intent.amount)).keys()]; 88 | 89 | // The priority fee cannot be lower than required 90 | { 91 | const nextBaseFee = parseUnits("10", "gwei"); 92 | await hre.network.provider.send("hardhat_setNextBlockBaseFeePerGas", [ 93 | "0x" + nextBaseFee.toNumber().toString(16), 94 | ]); 95 | 96 | const requiredPriorityFee = await memswap.requiredPriorityFee(); 97 | await expect( 98 | solutionProxy.connect(bob).solveERC721( 99 | intent, 100 | { 101 | data: defaultAbiCoder.encode(["uint128"], [0]), 102 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 103 | tokenId, 104 | criteriaProof: [], 105 | })), 106 | }, 107 | [], 108 | { 109 | maxFeePerGas: nextBaseFee.add(requiredPriorityFee).sub(1), 110 | maxPriorityFeePerGas: requiredPriorityFee, 111 | } 112 | ) 113 | ).to.be.revertedWith("InvalidPriorityFee"); 114 | } 115 | 116 | // The priority fee cannot be higher than required 117 | { 118 | const nextBaseFee = parseUnits("10", "gwei"); 119 | await hre.network.provider.send("hardhat_setNextBlockBaseFeePerGas", [ 120 | "0x" + nextBaseFee.toNumber().toString(16), 121 | ]); 122 | 123 | const requiredPriorityFee = await memswap.requiredPriorityFee(); 124 | await expect( 125 | solutionProxy.connect(bob).solveERC721( 126 | intent, 127 | { 128 | data: defaultAbiCoder.encode(["uint128"], [0]), 129 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 130 | tokenId, 131 | criteriaProof: [], 132 | })), 133 | }, 134 | [], 135 | { 136 | maxFeePerGas: nextBaseFee.add(requiredPriorityFee).add(1), 137 | maxPriorityFeePerGas: requiredPriorityFee.add(1), 138 | } 139 | ) 140 | ).to.be.revertedWith("InvalidPriorityFee"); 141 | } 142 | 143 | // The priority fee should match the required value 144 | await solutionProxy.connect(bob).solveERC721( 145 | intent, 146 | { 147 | data: defaultAbiCoder.encode(["uint128"], [0]), 148 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 149 | tokenId, 150 | criteriaProof: [], 151 | })), 152 | }, 153 | [], 154 | { 155 | value: await getIncentivizationTip( 156 | memswap, 157 | intent.isBuy, 158 | intent.endAmount, 159 | intent.expectedAmountBps, 160 | intent.endAmount 161 | ), 162 | maxPriorityFeePerGas: await memswap.requiredPriorityFee(), 163 | } 164 | ); 165 | }); 166 | 167 | it("Cannot pay builder out-of-band", async () => { 168 | const currentTime = await getCurrentTimestamp(); 169 | 170 | // Generate intent 171 | const intent: Intent = { 172 | isBuy: true, 173 | buyToken: token1.address, 174 | sellToken: token0.address, 175 | maker: alice.address, 176 | solver: AddressZero, 177 | source: AddressZero, 178 | feeBps: 0, 179 | surplusBps: 0, 180 | startTime: currentTime, 181 | endTime: currentTime + 60, 182 | nonce: 0, 183 | isPartiallyFillable: true, 184 | isSmartOrder: false, 185 | isIncentivized: true, 186 | isCriteriaOrder: true, 187 | tokenIdOrCriteria: 0, 188 | amount: 2, 189 | endAmount: ethers.utils.parseEther("0.3"), 190 | startAmountBps: 0, 191 | expectedAmountBps: 0, 192 | signature: "0x", 193 | }; 194 | intent.signature = await signIntent(alice, memswap.address, intent); 195 | 196 | // Mint and approve 197 | await token0.connect(alice).mint(intent.endAmount); 198 | await token0.connect(alice).approve(memswap.address, intent.endAmount); 199 | 200 | const tokenIdsToFill = [...Array(Number(intent.amount)).keys()]; 201 | 202 | // Enable out-of-band payments to the builder 203 | await solutionProxy.connect(bob).setPayBuilderOnRefund(true); 204 | 205 | // The solution will fail if the tip the builder was too high 206 | await expect( 207 | solutionProxy.connect(bob).solveERC721( 208 | intent, 209 | { 210 | data: defaultAbiCoder.encode(["uint128"], [0]), 211 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 212 | tokenId, 213 | criteriaProof: [], 214 | })), 215 | }, 216 | [], 217 | { 218 | value: await getIncentivizationTip( 219 | memswap, 220 | intent.isBuy, 221 | intent.endAmount, 222 | intent.expectedAmountBps, 223 | intent.endAmount 224 | ).then((tip) => tip.add(1)), 225 | maxPriorityFeePerGas: await memswap.requiredPriorityFee(), 226 | } 227 | ) 228 | ).to.be.revertedWith("InvalidTip"); 229 | }); 230 | 231 | it("Insufficient tip", async () => { 232 | const currentTime = await getCurrentTimestamp(); 233 | 234 | // Generate intent 235 | const intent: Intent = { 236 | isBuy: true, 237 | buyToken: token1.address, 238 | sellToken: token0.address, 239 | maker: alice.address, 240 | solver: AddressZero, 241 | source: AddressZero, 242 | feeBps: 0, 243 | surplusBps: 0, 244 | startTime: currentTime, 245 | endTime: currentTime + 60, 246 | nonce: 0, 247 | isPartiallyFillable: true, 248 | isSmartOrder: false, 249 | isIncentivized: true, 250 | isCriteriaOrder: true, 251 | tokenIdOrCriteria: 0, 252 | amount: 2, 253 | endAmount: ethers.utils.parseEther("0.3"), 254 | startAmountBps: 0, 255 | expectedAmountBps: 0, 256 | signature: "0x", 257 | }; 258 | intent.signature = await signIntent(alice, memswap.address, intent); 259 | 260 | // Mint and approve 261 | await token0.connect(alice).mint(intent.endAmount); 262 | await token0.connect(alice).approve(memswap.address, intent.endAmount); 263 | 264 | const tokenIdsToFill = [...Array(Number(intent.amount)).keys()]; 265 | 266 | // The solution will fail if the tip the builder was too low 267 | await expect( 268 | solutionProxy.connect(bob).solveERC721( 269 | intent, 270 | { 271 | data: defaultAbiCoder.encode(["uint128"], [0]), 272 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 273 | tokenId, 274 | criteriaProof: [], 275 | })), 276 | }, 277 | [], 278 | { 279 | value: await getIncentivizationTip( 280 | memswap, 281 | intent.isBuy, 282 | intent.endAmount, 283 | intent.expectedAmountBps, 284 | intent.endAmount 285 | ).then((tip) => tip.sub(1)), 286 | maxPriorityFeePerGas: await memswap.requiredPriorityFee(), 287 | } 288 | ) 289 | ).to.be.revertedWith("InvalidTip"); 290 | }); 291 | }); 292 | -------------------------------------------------------------------------------- /test/erc721/misc.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultAbiCoder } from "@ethersproject/abi"; 2 | import { AddressZero } from "@ethersproject/constants"; 3 | import { Contract } from "@ethersproject/contracts"; 4 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 5 | import { expect } from "chai"; 6 | import { ethers } from "hardhat"; 7 | 8 | import { Intent, getIntentHash, signIntent } from "./utils"; 9 | import { PermitKind, getCurrentTimestamp, signPermit2 } from "../utils"; 10 | import { PERMIT2 } from "../../src/common/addresses"; 11 | 12 | describe("[ERC721] Misc", async () => { 13 | let chainId: number; 14 | 15 | let deployer: SignerWithAddress; 16 | let alice: SignerWithAddress; 17 | let bob: SignerWithAddress; 18 | 19 | let memswap: Contract; 20 | let nft: Contract; 21 | 22 | let solutionProxy: Contract; 23 | let token0: Contract; 24 | let token1: Contract; 25 | 26 | beforeEach(async () => { 27 | chainId = await ethers.provider.getNetwork().then((n) => n.chainId); 28 | 29 | [deployer, alice, bob] = await ethers.getSigners(); 30 | 31 | nft = await ethers 32 | .getContractFactory("MemswapAlphaNFT") 33 | .then((factory) => factory.deploy(deployer.address, "", "")); 34 | memswap = await ethers 35 | .getContractFactory("MemswapERC721") 36 | .then((factory) => factory.deploy(nft.address)); 37 | 38 | solutionProxy = await ethers 39 | .getContractFactory("MockSolutionProxy") 40 | .then((factory) => factory.deploy(memswap.address)); 41 | token0 = await ethers 42 | .getContractFactory("MockERC20") 43 | .then((factory) => factory.deploy()); 44 | token1 = await ethers 45 | .getContractFactory("MockERC721") 46 | .then((factory) => factory.deploy()); 47 | 48 | // Allowed the Memswap contract to mint 49 | await nft.connect(deployer).setIsAllowedToMint([memswap.address], [true]); 50 | 51 | // Send some ETH to solution proxy contract for the tests where `tokenOut` is ETH 52 | await deployer.sendTransaction({ 53 | to: solutionProxy.address, 54 | value: ethers.utils.parseEther("10"), 55 | }); 56 | }); 57 | 58 | it("Prevalidation", async () => { 59 | const currentTime = await getCurrentTimestamp(); 60 | 61 | // Generate intent 62 | const intent: Intent = { 63 | isBuy: true, 64 | buyToken: token1.address, 65 | sellToken: token0.address, 66 | maker: alice.address, 67 | solver: AddressZero, 68 | source: AddressZero, 69 | feeBps: 0, 70 | surplusBps: 0, 71 | startTime: currentTime, 72 | endTime: currentTime + 60, 73 | nonce: 0, 74 | isPartiallyFillable: true, 75 | isSmartOrder: false, 76 | isIncentivized: false, 77 | isCriteriaOrder: true, 78 | tokenIdOrCriteria: 0, 79 | amount: 2, 80 | endAmount: ethers.utils.parseEther("0.3"), 81 | startAmountBps: 0, 82 | expectedAmountBps: 0, 83 | signature: "0x", 84 | }; 85 | 86 | // Mint and approve 87 | await token0.connect(alice).mint(intent.endAmount); 88 | await token0.connect(alice).approve(memswap.address, intent.endAmount); 89 | 90 | const tokenIdsToFill = [...Array(Number(intent.amount)).keys()]; 91 | 92 | // Only the maker can prevalidate 93 | await expect(memswap.connect(bob).prevalidate([intent])).to.be.revertedWith( 94 | "InvalidSignature" 95 | ); 96 | 97 | // Cannot prevalidate smart order intents 98 | intent.isSmartOrder = true; 99 | await expect( 100 | memswap.connect(alice).prevalidate([intent]) 101 | ).to.be.revertedWith("IntentCannotBePrevalidated"); 102 | 103 | // Prevalidate 104 | intent.isSmartOrder = false; 105 | await expect(memswap.connect(alice).prevalidate([intent])) 106 | .to.emit(memswap, "IntentPrevalidated") 107 | .withArgs(getIntentHash(intent)); 108 | 109 | // Once prevalidated, solving can be done without a maker signature 110 | await solutionProxy.connect(bob).solveERC721( 111 | intent, 112 | { 113 | data: defaultAbiCoder.encode(["uint128"], [0]), 114 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 115 | tokenId, 116 | criteriaProof: [], 117 | })), 118 | }, 119 | [] 120 | ); 121 | }); 122 | 123 | it("Cancellation", async () => { 124 | const currentTime = await getCurrentTimestamp(); 125 | 126 | // Generate intent 127 | const intent: Intent = { 128 | isBuy: true, 129 | buyToken: token1.address, 130 | sellToken: token0.address, 131 | maker: alice.address, 132 | solver: AddressZero, 133 | source: AddressZero, 134 | feeBps: 0, 135 | surplusBps: 0, 136 | startTime: currentTime, 137 | endTime: currentTime + 60, 138 | nonce: 0, 139 | isPartiallyFillable: true, 140 | isSmartOrder: false, 141 | isIncentivized: false, 142 | isCriteriaOrder: true, 143 | tokenIdOrCriteria: 0, 144 | amount: 2, 145 | endAmount: ethers.utils.parseEther("0.3"), 146 | startAmountBps: 0, 147 | expectedAmountBps: 0, 148 | }; 149 | intent.signature = await signIntent(alice, memswap.address, intent); 150 | 151 | // Mint and approve 152 | await token0.connect(alice).mint(intent.endAmount); 153 | await token0.connect(alice).approve(memswap.address, intent.endAmount); 154 | 155 | const tokenIdsToFill = [...Array(Number(intent.amount)).keys()]; 156 | 157 | // Only the maker can cancel 158 | await expect(memswap.connect(bob).cancel([intent])).to.be.revertedWith( 159 | "Unauthorized" 160 | ); 161 | 162 | // Cancel 163 | await expect(memswap.connect(alice).cancel([intent])) 164 | .to.emit(memswap, "IntentCancelled") 165 | .withArgs(getIntentHash(intent)); 166 | 167 | // Once cancelled, intent cannot be solved 168 | await expect( 169 | solutionProxy.connect(bob).solveERC721( 170 | intent, 171 | { 172 | data: defaultAbiCoder.encode(["uint128"], [0]), 173 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 174 | tokenId, 175 | criteriaProof: [], 176 | })), 177 | }, 178 | [] 179 | ) 180 | ).to.be.revertedWith("IntentIsCancelled"); 181 | }); 182 | 183 | it("Increment nonce", async () => { 184 | const currentTime = await getCurrentTimestamp(); 185 | 186 | // Generate intent 187 | const intent: Intent = { 188 | isBuy: true, 189 | buyToken: token1.address, 190 | sellToken: token0.address, 191 | maker: alice.address, 192 | solver: AddressZero, 193 | source: AddressZero, 194 | feeBps: 0, 195 | surplusBps: 0, 196 | startTime: currentTime, 197 | endTime: currentTime + 60, 198 | nonce: 0, 199 | isPartiallyFillable: true, 200 | isSmartOrder: false, 201 | isIncentivized: false, 202 | isCriteriaOrder: true, 203 | tokenIdOrCriteria: 0, 204 | amount: 2, 205 | endAmount: ethers.utils.parseEther("0.3"), 206 | startAmountBps: 0, 207 | expectedAmountBps: 0, 208 | }; 209 | intent.signature = await signIntent(alice, memswap.address, intent); 210 | 211 | // Mint and approve 212 | await token0.connect(alice).mint(intent.endAmount); 213 | await token0.connect(alice).approve(memswap.address, intent.endAmount); 214 | 215 | const tokenIdsToFill = [...Array(Number(intent.amount)).keys()]; 216 | 217 | // Increment nonce 218 | await expect(memswap.connect(alice).incrementNonce()) 219 | .to.emit(memswap, "NonceIncremented") 220 | .withArgs(alice.address, 1); 221 | 222 | // Once the nonce was incremented, intents signed on old nonces cannot be solved anymore 223 | // (the signature check will fail since the intent hash will be computed on latest nonce 224 | // value, and not on the nonce value the intent was signed with) 225 | await expect( 226 | solutionProxy.connect(bob).solveERC721( 227 | intent, 228 | { 229 | data: defaultAbiCoder.encode(["uint128"], [0]), 230 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 231 | tokenId, 232 | criteriaProof: [], 233 | })), 234 | }, 235 | [] 236 | ) 237 | ).to.be.revertedWith("InvalidSignature"); 238 | }); 239 | 240 | it("Permit2 permit", async () => { 241 | const currentTime = await getCurrentTimestamp(); 242 | 243 | // Generate intent 244 | const intent: Intent = { 245 | isBuy: true, 246 | buyToken: token1.address, 247 | sellToken: token0.address, 248 | maker: alice.address, 249 | solver: AddressZero, 250 | source: AddressZero, 251 | feeBps: 0, 252 | surplusBps: 0, 253 | startTime: currentTime, 254 | endTime: currentTime + 60, 255 | nonce: 0, 256 | isPartiallyFillable: true, 257 | isSmartOrder: false, 258 | isIncentivized: false, 259 | isCriteriaOrder: true, 260 | tokenIdOrCriteria: 0, 261 | amount: 2, 262 | endAmount: ethers.utils.parseEther("0.3"), 263 | startAmountBps: 0, 264 | expectedAmountBps: 0, 265 | }; 266 | intent.signature = await signIntent(alice, memswap.address, intent); 267 | 268 | // Mint and approve Permit2 269 | await token0.connect(alice).mint(intent.endAmount); 270 | await token0.connect(alice).approve(PERMIT2[chainId], intent.endAmount); 271 | 272 | const tokenIdsToFill = [...Array(Number(intent.amount)).keys()]; 273 | 274 | // If not permit was passed, the solution transaction will revert 275 | await expect( 276 | solutionProxy.connect(bob).solveERC721( 277 | intent, 278 | { 279 | data: defaultAbiCoder.encode(["uint128"], [0]), 280 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 281 | tokenId, 282 | criteriaProof: [], 283 | })), 284 | }, 285 | [] 286 | ) 287 | ).to.be.reverted; 288 | 289 | // Build and sign permit 290 | const permit = { 291 | details: { 292 | token: intent.sellToken, 293 | amount: intent.endAmount, 294 | expiration: currentTime + 3600, 295 | nonce: 0, 296 | }, 297 | spender: memswap.address, 298 | sigDeadline: currentTime + 3600, 299 | }; 300 | const permitSignature = await signPermit2(alice, PERMIT2[chainId], permit); 301 | 302 | await solutionProxy.connect(bob).solveERC721( 303 | intent, 304 | { 305 | data: defaultAbiCoder.encode(["uint128"], [0]), 306 | fillTokenDetails: tokenIdsToFill.map((tokenId) => ({ 307 | tokenId, 308 | criteriaProof: [], 309 | })), 310 | }, 311 | [ 312 | { 313 | kind: PermitKind.PERMIT2, 314 | data: defaultAbiCoder.encode( 315 | [ 316 | "address", 317 | "((address token, uint160 amount, uint48 expiration, uint48 nonce) details, address spender, uint256 sigDeadline)", 318 | "bytes", 319 | ], 320 | [alice.address, permit, permitSignature] 321 | ), 322 | }, 323 | ] 324 | ); 325 | }); 326 | }); 327 | -------------------------------------------------------------------------------- /test/erc721/utils.ts: -------------------------------------------------------------------------------- 1 | import { defaultAbiCoder } from "@ethersproject/abi"; 2 | import { TypedDataSigner } from "@ethersproject/abstract-signer"; 3 | import { BigNumberish } from "@ethersproject/bignumber"; 4 | import { hexConcat } from "@ethersproject/bytes"; 5 | import { AddressZero } from "@ethersproject/constants"; 6 | import { _TypedDataEncoder } from "@ethersproject/hash"; 7 | import { keccak256 } from "@ethersproject/keccak256"; 8 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 9 | import { MerkleTree } from "merkletreejs"; 10 | import { bn } from "../utils"; 11 | 12 | // Contract utilities 13 | 14 | export type Intent = { 15 | isBuy: boolean; 16 | buyToken: string; 17 | sellToken: string; 18 | maker: string; 19 | solver: string; 20 | source: string; 21 | feeBps: number; 22 | surplusBps: number; 23 | startTime: number; 24 | endTime: number; 25 | nonce: BigNumberish; 26 | isPartiallyFillable: boolean; 27 | isSmartOrder: boolean; 28 | isIncentivized: boolean; 29 | isCriteriaOrder: boolean; 30 | tokenIdOrCriteria: BigNumberish; 31 | amount: BigNumberish; 32 | endAmount: BigNumberish; 33 | startAmountBps: number; 34 | expectedAmountBps: number; 35 | signature?: string; 36 | }; 37 | 38 | export type Authorization = { 39 | intentHash: string; 40 | solver: string; 41 | fillAmountToCheck: BigNumberish; 42 | executeAmountToCheck: BigNumberish; 43 | blockDeadline: number; 44 | signature?: string; 45 | }; 46 | 47 | export const getIntentHash = (intent: any) => 48 | _TypedDataEncoder.hashStruct("Intent", INTENT_EIP712_TYPES, intent); 49 | 50 | export const signIntent = async ( 51 | signer: SignerWithAddress, 52 | contract: string, 53 | intent: any 54 | ) => 55 | signer._signTypedData( 56 | EIP712_DOMAIN(contract, await signer.getChainId()), 57 | INTENT_EIP712_TYPES, 58 | intent 59 | ); 60 | 61 | export const signAuthorization = async ( 62 | signer: SignerWithAddress, 63 | contract: string, 64 | authorization: any 65 | ) => 66 | signer._signTypedData( 67 | EIP712_DOMAIN(contract, await signer.getChainId()), 68 | AUTHORIZATION_EIP712_TYPES, 69 | authorization 70 | ); 71 | 72 | export const EIP712_DOMAIN = (contract: string, chainId: number) => ({ 73 | name: "MemswapERC721", 74 | version: "1.0", 75 | chainId, 76 | verifyingContract: contract, 77 | }); 78 | 79 | export const AUTHORIZATION_EIP712_TYPES = { 80 | Authorization: [ 81 | { 82 | name: "intentHash", 83 | type: "bytes32", 84 | }, 85 | { 86 | name: "solver", 87 | type: "address", 88 | }, 89 | { 90 | name: "fillAmountToCheck", 91 | type: "uint128", 92 | }, 93 | { 94 | name: "executeAmountToCheck", 95 | type: "uint128", 96 | }, 97 | { 98 | name: "blockDeadline", 99 | type: "uint32", 100 | }, 101 | ], 102 | }; 103 | 104 | export const INTENT_EIP712_TYPES = { 105 | Intent: [ 106 | { 107 | name: "isBuy", 108 | type: "bool", 109 | }, 110 | { 111 | name: "buyToken", 112 | type: "address", 113 | }, 114 | { 115 | name: "sellToken", 116 | type: "address", 117 | }, 118 | { 119 | name: "maker", 120 | type: "address", 121 | }, 122 | { 123 | name: "solver", 124 | type: "address", 125 | }, 126 | { 127 | name: "source", 128 | type: "address", 129 | }, 130 | { 131 | name: "feeBps", 132 | type: "uint16", 133 | }, 134 | { 135 | name: "surplusBps", 136 | type: "uint16", 137 | }, 138 | { 139 | name: "startTime", 140 | type: "uint32", 141 | }, 142 | { 143 | name: "endTime", 144 | type: "uint32", 145 | }, 146 | { 147 | name: "nonce", 148 | type: "uint256", 149 | }, 150 | { 151 | name: "isPartiallyFillable", 152 | type: "bool", 153 | }, 154 | { 155 | name: "isSmartOrder", 156 | type: "bool", 157 | }, 158 | { 159 | name: "isIncentivized", 160 | type: "bool", 161 | }, 162 | { 163 | name: "isCriteriaOrder", 164 | type: "bool", 165 | }, 166 | { 167 | name: "tokenIdOrCriteria", 168 | type: "uint256", 169 | }, 170 | { 171 | name: "amount", 172 | type: "uint128", 173 | }, 174 | { 175 | name: "endAmount", 176 | type: "uint128", 177 | }, 178 | { 179 | name: "startAmountBps", 180 | type: "uint16", 181 | }, 182 | { 183 | name: "expectedAmountBps", 184 | type: "uint16", 185 | }, 186 | ], 187 | }; 188 | 189 | // Bulk-signing utilities 190 | 191 | export const bulkSign = async ( 192 | signer: TypedDataSigner, 193 | intents: any[], 194 | contract: string, 195 | chainId: number 196 | ) => { 197 | const { signatureData, proofs } = getBulkSignatureDataWithProofs( 198 | intents, 199 | contract, 200 | chainId 201 | ); 202 | 203 | const signature = await signer._signTypedData( 204 | signatureData.domain, 205 | signatureData.types, 206 | signatureData.value 207 | ); 208 | 209 | intents.forEach((intent, i) => { 210 | intent.signature = encodeBulkOrderProofAndSignature( 211 | i, 212 | proofs[i], 213 | signature 214 | ); 215 | }); 216 | }; 217 | 218 | const getBulkSignatureDataWithProofs = ( 219 | intents: any[], 220 | contract: string, 221 | chainId: number 222 | ) => { 223 | const height = Math.max(Math.ceil(Math.log2(intents.length)), 1); 224 | const size = Math.pow(2, height); 225 | 226 | const types = { ...INTENT_EIP712_TYPES }; 227 | (types as any).BatchIntent = [ 228 | { name: "tree", type: `Intent${`[2]`.repeat(height)}` }, 229 | ]; 230 | const encoder = _TypedDataEncoder.from(types); 231 | 232 | const hashElement = (element: any) => encoder.hashStruct("Intent", element); 233 | const elements = [...intents]; 234 | const leaves = elements.map((i) => hashElement(i)); 235 | 236 | const defaultElement: Intent = { 237 | isBuy: false, 238 | buyToken: AddressZero, 239 | sellToken: AddressZero, 240 | maker: AddressZero, 241 | solver: AddressZero, 242 | source: AddressZero, 243 | feeBps: 0, 244 | surplusBps: 0, 245 | startTime: 0, 246 | endTime: 0, 247 | nonce: 0, 248 | isPartiallyFillable: false, 249 | isSmartOrder: false, 250 | isIncentivized: false, 251 | isCriteriaOrder: false, 252 | tokenIdOrCriteria: 0, 253 | amount: 0, 254 | endAmount: 0, 255 | startAmountBps: 0, 256 | expectedAmountBps: 0, 257 | }; 258 | const defaultLeaf = hashElement(defaultElement); 259 | 260 | // Ensure the tree is complete 261 | while (elements.length < size) { 262 | elements.push(defaultElement); 263 | leaves.push(defaultLeaf); 264 | } 265 | 266 | const hexToBuffer = (value: string) => Buffer.from(value.slice(2), "hex"); 267 | const bufferKeccak = (value: string) => hexToBuffer(keccak256(value)); 268 | 269 | const tree = new MerkleTree(leaves.map(hexToBuffer), bufferKeccak, { 270 | complete: true, 271 | sort: false, 272 | hashLeaves: false, 273 | fillDefaultHash: hexToBuffer(defaultLeaf), 274 | }); 275 | 276 | let chunks: object[] = [...elements]; 277 | while (chunks.length > 2) { 278 | const newSize = Math.ceil(chunks.length / 2); 279 | chunks = Array(newSize) 280 | .fill(0) 281 | .map((_, i) => chunks.slice(i * 2, (i + 1) * 2)); 282 | } 283 | 284 | return { 285 | signatureData: { 286 | signatureKind: "eip712", 287 | domain: EIP712_DOMAIN(contract, chainId), 288 | types, 289 | value: { tree: chunks }, 290 | primaryType: _TypedDataEncoder.getPrimaryType(types), 291 | }, 292 | proofs: intents.map((_, i) => tree.getHexProof(leaves[i], i)), 293 | }; 294 | }; 295 | 296 | const encodeBulkOrderProofAndSignature = ( 297 | orderIndex: number, 298 | merkleProof: string[], 299 | signature: string 300 | ) => { 301 | return hexConcat([ 302 | signature, 303 | `0x${orderIndex.toString(16).padStart(6, "0")}`, 304 | defaultAbiCoder.encode([`uint256[${merkleProof.length}]`], [merkleProof]), 305 | ]); 306 | }; 307 | 308 | export const generateMerkleTree = (tokenIds: BigNumberish[]) => { 309 | if (!tokenIds.length) { 310 | throw new Error("Could not generate merkle tree"); 311 | } 312 | 313 | const leaves = tokenIds.map(hashFn); 314 | return new MerkleTree(leaves, keccak256, { sort: true }); 315 | }; 316 | 317 | export const generateMerkleProof = ( 318 | merkleTree: MerkleTree, 319 | tokenId: BigNumberish 320 | ) => merkleTree.getHexProof(hashFn(tokenId)); 321 | 322 | const hashFn = (tokenId: BigNumberish) => 323 | keccak256( 324 | Buffer.from(bn(tokenId).toHexString().slice(2).padStart(64, "0"), "hex") 325 | ); 326 | -------------------------------------------------------------------------------- /test/memeth.test.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "@ethersproject/contracts"; 2 | import { parseEther } from "@ethersproject/units"; 3 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 4 | import { expect } from "chai"; 5 | import { ethers } from "hardhat"; 6 | 7 | describe("MEMETH", async () => { 8 | let chainId: number; 9 | 10 | let deployer: SignerWithAddress; 11 | let alice: SignerWithAddress; 12 | let bob: SignerWithAddress; 13 | 14 | let memeth: Contract; 15 | 16 | beforeEach(async () => { 17 | chainId = await ethers.provider.getNetwork().then((n) => n.chainId); 18 | 19 | [deployer, alice, bob] = await ethers.getSigners(); 20 | 21 | memeth = await ethers 22 | .getContractFactory("MEMETH") 23 | .then((factory) => factory.deploy()); 24 | }); 25 | 26 | it("Deposit and approve", async () => { 27 | const depositAmount = parseEther("0.1"); 28 | const approveAmount = parseEther("0.09"); 29 | 30 | await expect( 31 | memeth 32 | .connect(alice) 33 | .depositAndApprove(bob.address, approveAmount, { value: depositAmount }) 34 | ) 35 | .to.emit(memeth, "Deposit") 36 | .withArgs(bob.address, depositAmount) 37 | .to.emit(memeth, "Approval") 38 | .withArgs(alice.address, bob.address, approveAmount); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { Interface } from "@ethersproject/abi"; 2 | import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; 3 | import { Contract } from "@ethersproject/contracts"; 4 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 5 | import { ethers } from "hardhat"; 6 | 7 | // Misc utilities 8 | 9 | export const bn = (value: BigNumberish) => BigNumber.from(value); 10 | 11 | export const getCurrentTimestamp = async () => 12 | ethers.provider.getBlock("latest").then((b) => b!.timestamp); 13 | 14 | export const getRandomBoolean = () => Math.random() < 0.5; 15 | 16 | export const getRandomInteger = (min: number, max: number) => { 17 | min = Math.ceil(min); 18 | max = Math.floor(max); 19 | return Math.floor(Math.random() * (max - min + 1)) + min; 20 | }; 21 | 22 | export const getRandomFloat = (min: number, max: number) => 23 | (Math.random() * (max - min) + min).toFixed(6); 24 | 25 | // Contract utilities 26 | 27 | export enum PermitKind { 28 | EIP2612, 29 | PERMIT2, 30 | } 31 | 32 | export const signPermit2 = async ( 33 | signer: SignerWithAddress, 34 | contract: string, 35 | permit: any 36 | ) => 37 | signer._signTypedData( 38 | EIP712_DOMAIN_FOR_PERMIT2(contract, await signer.getChainId()), 39 | PERMIT2_EIP712_TYPES, 40 | permit 41 | ); 42 | 43 | export const signPermitEIP2612 = async ( 44 | signer: SignerWithAddress, 45 | contract: string, 46 | permit: any 47 | ) => 48 | signer._signTypedData( 49 | await EIP712_DOMAIN_FOR_EIP2612(contract, await signer.getChainId()), 50 | EIP2612_EIP712_TYPES, 51 | permit 52 | ); 53 | 54 | export const EIP712_DOMAIN_FOR_PERMIT2 = ( 55 | contract: string, 56 | chainId: number 57 | ) => ({ 58 | name: "Permit2", 59 | chainId, 60 | verifyingContract: contract, 61 | }); 62 | 63 | export const EIP712_DOMAIN_FOR_EIP2612 = async ( 64 | contract: string, 65 | chainId: number 66 | ) => { 67 | const c = new Contract( 68 | contract, 69 | new Interface([ 70 | "function name() view returns (string)", 71 | "function version() view returns (string)", 72 | ]), 73 | ethers.provider 74 | ); 75 | return { 76 | name: await c.name(), 77 | version: await c.version(), 78 | chainId, 79 | verifyingContract: contract, 80 | }; 81 | }; 82 | 83 | export const PERMIT2_EIP712_TYPES = { 84 | PermitSingle: [ 85 | { 86 | name: "details", 87 | type: "PermitDetails", 88 | }, 89 | { 90 | name: "spender", 91 | type: "address", 92 | }, 93 | { 94 | name: "sigDeadline", 95 | type: "uint256", 96 | }, 97 | ], 98 | PermitDetails: [ 99 | { 100 | name: "token", 101 | type: "address", 102 | }, 103 | { 104 | name: "amount", 105 | type: "uint160", 106 | }, 107 | { 108 | name: "expiration", 109 | type: "uint48", 110 | }, 111 | { 112 | name: "nonce", 113 | type: "uint48", 114 | }, 115 | ], 116 | }; 117 | 118 | export const EIP2612_EIP712_TYPES = { 119 | Permit: [ 120 | { 121 | name: "owner", 122 | type: "address", 123 | }, 124 | { 125 | name: "spender", 126 | type: "address", 127 | }, 128 | { 129 | name: "value", 130 | type: "uint256", 131 | }, 132 | { 133 | name: "nonce", 134 | type: "uint256", 135 | }, 136 | { 137 | name: "deadline", 138 | type: "uint256", 139 | }, 140 | ], 141 | }; 142 | 143 | export const getIncentivizationTip = async ( 144 | memswap: Contract, 145 | isBuy: boolean, 146 | expectedAmount: BigNumberish, 147 | expectedAmountBps: number, 148 | executeAmount: BigNumberish 149 | ): Promise => { 150 | const slippage = 151 | expectedAmountBps === 0 152 | ? await memswap.defaultSlippage() 153 | : expectedAmountBps; 154 | 155 | const multiplier = await memswap.multiplier(); 156 | const minTip = await memswap.minTip(); 157 | const maxTip = await memswap.maxTip(); 158 | 159 | const slippageUnit = bn(expectedAmount).mul(slippage).div(10000); 160 | 161 | if (isBuy) { 162 | const minValue = bn(expectedAmount).sub(slippageUnit.mul(multiplier)); 163 | const maxValue = bn(expectedAmount).add(slippageUnit); 164 | 165 | if (bn(executeAmount).gte(maxValue)) { 166 | return minTip; 167 | } else if (bn(executeAmount).lte(minValue)) { 168 | return maxTip; 169 | } else { 170 | return maxTip.sub( 171 | bn(executeAmount) 172 | .sub(minValue) 173 | .mul(maxTip.sub(minTip)) 174 | .div(maxValue.sub(minValue)) 175 | ); 176 | } 177 | } else { 178 | const minValue = bn(expectedAmount).sub(slippageUnit); 179 | const maxValue = bn(expectedAmount).add(slippageUnit.mul(multiplier)); 180 | 181 | if (bn(executeAmount).gte(maxValue)) { 182 | return minTip; 183 | } else if (bn(executeAmount).lte(minValue)) { 184 | return maxTip; 185 | } else { 186 | return minTip.add( 187 | bn(executeAmount) 188 | .sub(minValue) 189 | .mul(maxTip.sub(minTip)) 190 | .div(maxValue.sub(minValue)) 191 | ); 192 | } 193 | } 194 | }; 195 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "resolveJsonModule": true, 10 | "outDir": "dist" 11 | }, 12 | "include": ["**/*.ts"] 13 | } 14 | --------------------------------------------------------------------------------