├── .eslintrc ├── .github └── workflows │ ├── contract.js.yml │ └── node.js.yml ├── .gitignore ├── .prettierrc ├── EVMConfig.json ├── LICENSE ├── Procfile ├── README.md ├── abi ├── ERC20.abi ├── EVM_Exchange.json ├── ZigZagExchange.json └── starknet_v1.abi ├── evm_contracts ├── contracts │ ├── BTCBridge.sol │ ├── ERC20 │ │ └── Token.sol │ ├── LibOrder.sol │ ├── OZMinimalForwarder │ │ └── MinimalForwarder.sol │ ├── ZigZagExchange.sol │ ├── ZigZagFutures.sol │ ├── ZigZagVault.sol │ └── aeWETH │ │ ├── IWETH9.sol │ │ └── aeWETH.sol ├── hardhat.config.ts ├── package.json ├── scripts │ ├── deployExchange.js │ └── deployVault.js ├── test │ ├── FillOrderBook.ts │ ├── FillOrderExactInput.ts │ ├── FillOrderExactInputETH.ts │ ├── FillOrderExactOutput.ts │ ├── FillOrderExactOutputETH.ts │ ├── FillOrderRoute.ts │ ├── FillOrderRouteETH.ts │ ├── Forwarder.ts │ ├── Signature.ts │ ├── Vault.ts │ └── utils │ │ ├── PrivateKeyList.ts │ │ ├── SignUtil.ts │ │ └── types.ts └── tsconfig.json ├── examples └── simple_swap.js ├── index.d.ts ├── package.json ├── schema.sql ├── src ├── api.ts ├── background.ts ├── cryptography.ts ├── db.ts ├── env.ts ├── httpServer.ts ├── index.ts ├── redisClient.ts ├── routes │ ├── cg.ts │ ├── cmc.ts │ └── zz.ts ├── schemas.ts ├── services │ ├── cancelall.ts │ ├── cancelorder.ts │ ├── cancelorder2.ts │ ├── cancelorder3.ts │ ├── dailyvolumereq.ts │ ├── fillreceiptreq.ts │ ├── fillrequest.ts │ ├── index.ts │ ├── indicateliq2.ts │ ├── login.ts │ ├── marketsreq.ts │ ├── orderreceiptreq.ts │ ├── orderstatusupdate.ts │ ├── refreshliquidity.ts │ ├── requestquote.ts │ ├── submitorder2.ts │ ├── submitorder3.ts │ ├── subscribemarket.ts │ ├── subscribeswapevents.ts │ ├── unsubscribemarket.ts │ └── unsubscribeswapevents.ts ├── socketServer.ts ├── types.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier"], 3 | "extends": ["airbnb", "plugin:@typescript-eslint/recommended", "prettier"], 4 | "settings": { 5 | "import/resolver": { 6 | "node": { 7 | "extensions": [".js", ".jsx", ".ts", ".tsx"], 8 | "moduleDirectory": ["node_modules", "."] 9 | } 10 | } 11 | }, 12 | "rules": { 13 | "prettier/prettier": [0, { 14 | "singleQuote": true, 15 | "semi": false, 16 | "tabWidth": 2 17 | }], 18 | "no-bitwise": [0], 19 | "no-plusplus": [0], 20 | "no-loop-func": [0], 21 | "no-param-reassign": [0], 22 | "import/extensions": [0], 23 | "no-await-in-loop": [0], 24 | "lines-between-class-members": [0], 25 | "camelcase": [1], 26 | "@typescript-eslint/no-explicit-any": [0], 27 | "no-console": [0], 28 | "semi": [2, "never"], 29 | "quotes": [0, "single"], 30 | "import/prefer-default-export": [0], 31 | "react/jsx-filename-extension": ["warn", { 32 | "extensions": [".ts", ".tsx"] 33 | }] 34 | } 35 | } -------------------------------------------------------------------------------- /.github/workflows/contract.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Contract Test 5 | 6 | on: 7 | push: 8 | branches: [master, prod] 9 | pull_request: 10 | branches: [master, prod] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Build Backend 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: '16.17.1' 22 | cache: 'yarn' 23 | - run: cd evm_contracts && yarn install 24 | - run: cd evm_contracts && yarn run test 25 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master, prod] 9 | pull_request: 10 | branches: [master, prod] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Build Backend 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: '16.17.1' 22 | cache: 'yarn' 23 | - run: yarn install 24 | - run: yarn run build 25 | - run: yarn run test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | zigzag.db 3 | *.swp 4 | .env 5 | dist/ 6 | tsconfig.tsbuildinfo 7 | .vscode/ 8 | 9 | # Using yarn, ignore package-lock.json 10 | package-lock.json 11 | 12 | # hardhat 13 | evm_contracts/artifacts 14 | evm_contracts/cache 15 | evm_contracts/build-info 16 | evm_contracts/node_modules 17 | yarn-error.log 18 | 19 | #remix 20 | evm_contracts/contracts/artifacts 21 | evm_contracts/remix-compiler.config.js 22 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": "*.sol", 5 | "options": { 6 | "printWidth": 140, 7 | "tabWidth": 2, 8 | "useTabs": false, 9 | "singleQuote": true, 10 | "bracketSpacing": true, 11 | "explicitTypes": "preserve" 12 | } 13 | }, 14 | { 15 | "files": "*.ts", 16 | "options": { 17 | "printWidth": 140, 18 | "tabWidth": 2, 19 | "useTabs": false, 20 | "semi": false, 21 | "singleQuote": true 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /EVMConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "42161": { 3 | "wethAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", 4 | "exchangeAddress": "0x094cab67fbb074b7797ab0975c69a341b7a40641", 5 | "minMakerVolumeFee": 0.00, 6 | "minTakerVolumeFee": 0.0005, 7 | "domain": { 8 | "name": "ZigZag", 9 | "version": "2.1", 10 | "chainId": "42161", 11 | "verifyingContract": "0x094cab67fbb074b7797ab0975c69a341b7a40641" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | background: npm run background 3 | -------------------------------------------------------------------------------- /abi/ERC20.abi: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "constant": true, 4 | "inputs": [], 5 | "name": "name", 6 | "outputs": [ 7 | { 8 | "name": "", 9 | "type": "string" 10 | } 11 | ], 12 | "payable": false, 13 | "stateMutability": "view", 14 | "type": "function" 15 | }, 16 | { 17 | "constant": false, 18 | "inputs": [ 19 | { 20 | "name": "_spender", 21 | "type": "address" 22 | }, 23 | { 24 | "name": "_value", 25 | "type": "uint256" 26 | } 27 | ], 28 | "name": "approve", 29 | "outputs": [ 30 | { 31 | "name": "", 32 | "type": "bool" 33 | } 34 | ], 35 | "payable": false, 36 | "stateMutability": "nonpayable", 37 | "type": "function" 38 | }, 39 | { 40 | "constant": true, 41 | "inputs": [], 42 | "name": "totalSupply", 43 | "outputs": [ 44 | { 45 | "name": "", 46 | "type": "uint256" 47 | } 48 | ], 49 | "payable": false, 50 | "stateMutability": "view", 51 | "type": "function" 52 | }, 53 | { 54 | "constant": false, 55 | "inputs": [ 56 | { 57 | "name": "_from", 58 | "type": "address" 59 | }, 60 | { 61 | "name": "_to", 62 | "type": "address" 63 | }, 64 | { 65 | "name": "_value", 66 | "type": "uint256" 67 | } 68 | ], 69 | "name": "transferFrom", 70 | "outputs": [ 71 | { 72 | "name": "", 73 | "type": "bool" 74 | } 75 | ], 76 | "payable": false, 77 | "stateMutability": "nonpayable", 78 | "type": "function" 79 | }, 80 | { 81 | "constant": true, 82 | "inputs": [], 83 | "name": "decimals", 84 | "outputs": [ 85 | { 86 | "name": "", 87 | "type": "uint8" 88 | } 89 | ], 90 | "payable": false, 91 | "stateMutability": "view", 92 | "type": "function" 93 | }, 94 | { 95 | "constant": true, 96 | "inputs": [ 97 | { 98 | "name": "_owner", 99 | "type": "address" 100 | } 101 | ], 102 | "name": "balanceOf", 103 | "outputs": [ 104 | { 105 | "name": "balance", 106 | "type": "uint256" 107 | } 108 | ], 109 | "payable": false, 110 | "stateMutability": "view", 111 | "type": "function" 112 | }, 113 | { 114 | "constant": true, 115 | "inputs": [], 116 | "name": "symbol", 117 | "outputs": [ 118 | { 119 | "name": "", 120 | "type": "string" 121 | } 122 | ], 123 | "payable": false, 124 | "stateMutability": "view", 125 | "type": "function" 126 | }, 127 | { 128 | "constant": false, 129 | "inputs": [ 130 | { 131 | "name": "_to", 132 | "type": "address" 133 | }, 134 | { 135 | "name": "_value", 136 | "type": "uint256" 137 | } 138 | ], 139 | "name": "transfer", 140 | "outputs": [ 141 | { 142 | "name": "", 143 | "type": "bool" 144 | } 145 | ], 146 | "payable": false, 147 | "stateMutability": "nonpayable", 148 | "type": "function" 149 | }, 150 | { 151 | "constant": true, 152 | "inputs": [ 153 | { 154 | "name": "_owner", 155 | "type": "address" 156 | }, 157 | { 158 | "name": "_spender", 159 | "type": "address" 160 | } 161 | ], 162 | "name": "allowance", 163 | "outputs": [ 164 | { 165 | "name": "", 166 | "type": "uint256" 167 | } 168 | ], 169 | "payable": false, 170 | "stateMutability": "view", 171 | "type": "function" 172 | }, 173 | { 174 | "payable": true, 175 | "stateMutability": "payable", 176 | "type": "fallback" 177 | }, 178 | { 179 | "anonymous": false, 180 | "inputs": [ 181 | { 182 | "indexed": true, 183 | "name": "owner", 184 | "type": "address" 185 | }, 186 | { 187 | "indexed": true, 188 | "name": "spender", 189 | "type": "address" 190 | }, 191 | { 192 | "indexed": false, 193 | "name": "value", 194 | "type": "uint256" 195 | } 196 | ], 197 | "name": "Approval", 198 | "type": "event" 199 | }, 200 | { 201 | "anonymous": false, 202 | "inputs": [ 203 | { 204 | "indexed": true, 205 | "name": "from", 206 | "type": "address" 207 | }, 208 | { 209 | "indexed": true, 210 | "name": "to", 211 | "type": "address" 212 | }, 213 | { 214 | "indexed": false, 215 | "name": "value", 216 | "type": "uint256" 217 | } 218 | ], 219 | "name": "Transfer", 220 | "type": "event" 221 | } 222 | ] -------------------------------------------------------------------------------- /abi/starknet_v1.abi: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "members": [ 4 | { 5 | "name": "message_prefix", 6 | "offset": 0, 7 | "type": "felt" 8 | }, 9 | { 10 | "name": "domain_prefix", 11 | "offset": 1, 12 | "type": "StarkNet_Domain" 13 | }, 14 | { 15 | "name": "sender", 16 | "offset": 4, 17 | "type": "felt" 18 | }, 19 | { 20 | "name": "order", 21 | "offset": 5, 22 | "type": "Order" 23 | }, 24 | { 25 | "name": "sig_r", 26 | "offset": 12, 27 | "type": "felt" 28 | }, 29 | { 30 | "name": "sig_s", 31 | "offset": 13, 32 | "type": "felt" 33 | } 34 | ], 35 | "name": "ZZ_Message", 36 | "size": 14, 37 | "type": "struct" 38 | }, 39 | { 40 | "members": [ 41 | { 42 | "name": "name", 43 | "offset": 0, 44 | "type": "felt" 45 | }, 46 | { 47 | "name": "version", 48 | "offset": 1, 49 | "type": "felt" 50 | }, 51 | { 52 | "name": "chain_id", 53 | "offset": 2, 54 | "type": "felt" 55 | } 56 | ], 57 | "name": "StarkNet_Domain", 58 | "size": 3, 59 | "type": "struct" 60 | }, 61 | { 62 | "members": [ 63 | { 64 | "name": "base_asset", 65 | "offset": 0, 66 | "type": "felt" 67 | }, 68 | { 69 | "name": "quote_asset", 70 | "offset": 1, 71 | "type": "felt" 72 | }, 73 | { 74 | "name": "side", 75 | "offset": 2, 76 | "type": "felt" 77 | }, 78 | { 79 | "name": "base_quantity", 80 | "offset": 3, 81 | "type": "felt" 82 | }, 83 | { 84 | "name": "price", 85 | "offset": 4, 86 | "type": "PriceRatio" 87 | }, 88 | { 89 | "name": "expiration", 90 | "offset": 6, 91 | "type": "felt" 92 | } 93 | ], 94 | "name": "Order", 95 | "size": 7, 96 | "type": "struct" 97 | }, 98 | { 99 | "members": [ 100 | { 101 | "name": "numerator", 102 | "offset": 0, 103 | "type": "felt" 104 | }, 105 | { 106 | "name": "denominator", 107 | "offset": 1, 108 | "type": "felt" 109 | } 110 | ], 111 | "name": "PriceRatio", 112 | "size": 2, 113 | "type": "struct" 114 | }, 115 | { 116 | "inputs": [], 117 | "name": "test", 118 | "outputs": [ 119 | { 120 | "name": "hash", 121 | "type": "felt" 122 | } 123 | ], 124 | "stateMutability": "view", 125 | "type": "function" 126 | }, 127 | { 128 | "inputs": [], 129 | "name": "constructor", 130 | "outputs": [], 131 | "type": "constructor" 132 | }, 133 | { 134 | "inputs": [ 135 | { 136 | "name": "buy_order", 137 | "type": "ZZ_Message" 138 | }, 139 | { 140 | "name": "sell_order", 141 | "type": "ZZ_Message" 142 | }, 143 | { 144 | "name": "fill_price", 145 | "type": "PriceRatio" 146 | }, 147 | { 148 | "name": "base_fill_quantity", 149 | "type": "felt" 150 | } 151 | ], 152 | "name": "fill_order", 153 | "outputs": [], 154 | "type": "function" 155 | }, 156 | { 157 | "inputs": [ 158 | { 159 | "name": "order", 160 | "type": "ZZ_Message" 161 | } 162 | ], 163 | "name": "cancel_order", 164 | "outputs": [], 165 | "type": "function" 166 | }, 167 | { 168 | "inputs": [ 169 | { 170 | "name": "orderhash", 171 | "type": "felt" 172 | } 173 | ], 174 | "name": "get_order_status", 175 | "outputs": [ 176 | { 177 | "name": "filled", 178 | "type": "felt" 179 | } 180 | ], 181 | "stateMutability": "view", 182 | "type": "function" 183 | } 184 | ] -------------------------------------------------------------------------------- /evm_contracts/contracts/BTCBridge.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 6 | import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; 7 | 8 | contract ZigZagBTCBridge is ERC20 { 9 | // The manager of a vault is allowed to sign orders that a vault can execute 10 | address public manager; 11 | 12 | // Set this address on construction to restrict token transfers 13 | address immutable public WBTC_ADDRESS; 14 | uint public constant WBTC_DECIMALS = 8; 15 | 16 | // Deposit Rates are on a per second basis 17 | uint public DEPOSIT_RATE_NUMERATOR = 0; 18 | uint public constant DEPOSIT_RATE_DENOMINATOR = 1e12; 19 | 20 | // LP_PRICE is calculated against WBTC 21 | // The initial price is set to 1, then updated based on the deposit rate 22 | uint public LP_PRICE_NUMERATOR = DEPOSIT_RATE_DENOMINATOR; 23 | uint public constant LP_PRICE_DENOMINATOR = DEPOSIT_RATE_DENOMINATOR; 24 | uint public LAST_PRICE_UPDATE; 25 | 26 | // Hash Tracking for swaps 27 | // The key for the mappings is the hash 28 | struct HTLC { 29 | address counterparty; 30 | uint wbtc_amount; 31 | uint expiry; 32 | } 33 | mapping(bytes32 => HTLC) DEPOSIT_HASHES; 34 | mapping(bytes32 => HTLC) WITHDRAW_HASHES; 35 | 36 | constructor(address _manager, address _wbtc_address) ERC20("ZigZag WBTC LP", "ZWBTCLP") { 37 | manager = _manager; 38 | WBTC_ADDRESS = _wbtc_address; 39 | LAST_PRICE_UPDATE = block.timestamp; 40 | } 41 | 42 | function updateManager(address newManager) public { 43 | require(msg.sender == manager, "only manager can update manager"); 44 | manager = newManager; 45 | } 46 | 47 | function setDepositRate(uint deposit_rate_numerator) public { 48 | require(msg.sender == manager, "only manager can set deposit rate"); 49 | updateLPPrice(); 50 | DEPOSIT_RATE_NUMERATOR = deposit_rate_numerator; 51 | } 52 | 53 | function updateLPPrice() public { 54 | LP_PRICE_NUMERATOR += DEPOSIT_RATE_NUMERATOR * (block.timestamp - LAST_PRICE_UPDATE); 55 | LAST_PRICE_UPDATE = block.timestamp; 56 | } 57 | 58 | function depositWBTCToLP(uint wbtc_amount) public { 59 | IERC20(WBTC_ADDRESS).transferFrom(msg.sender, address(this), wbtc_amount); 60 | 61 | updateLPPrice(); 62 | uint lp_amount = wbtc_amount * LP_PRICE_DENOMINATOR * 10**decimals() / 10**WBTC_DECIMALS / LP_PRICE_NUMERATOR; 63 | 64 | _mint(msg.sender, lp_amount); 65 | } 66 | 67 | function withdrawWBTCFromLP(uint lp_amount) public { 68 | updateLPPrice(); 69 | uint wbtc_amount = lp_amount * LP_PRICE_NUMERATOR * 10**WBTC_DECIMALS / LP_PRICE_DENOMINATOR / 10**decimals(); 70 | 71 | _burn(msg.sender, lp_amount); 72 | IERC20(WBTC_ADDRESS).transfer(msg.sender, wbtc_amount); 73 | } 74 | 75 | function createDepositHash(uint wbtc_amount, bytes32 hash, uint expiry) public { 76 | IERC20(WBTC_ADDRESS).transferFrom(msg.sender, address(this), wbtc_amount); 77 | DEPOSIT_HASHES[hash] = HTLC(msg.sender, wbtc_amount, expiry); 78 | } 79 | 80 | function unlockDepositHash(bytes32 hash, bytes memory preimage) public { 81 | require(sha256(preimage) == hash, "preimage does not match hash"); 82 | require(DEPOSIT_HASHES[hash].expiry > block.timestamp, "HTLC is expired"); 83 | delete DEPOSIT_HASHES[hash]; 84 | } 85 | 86 | function reclaimDepositHash(bytes32 hash) public { 87 | require(DEPOSIT_HASHES[hash].expiry < block.timestamp, "HTLC is active"); 88 | IERC20(WBTC_ADDRESS).transfer(DEPOSIT_HASHES[hash].counterparty, DEPOSIT_HASHES[hash].wbtc_amount); 89 | delete DEPOSIT_HASHES[hash]; 90 | } 91 | 92 | function createWithdrawHash(address counterparty, uint wbtc_amount, bytes32 hash, uint expiry) public { 93 | require(msg.sender == manager, "only manager can create withdraw hashes"); 94 | WITHDRAW_HASHES[hash] = HTLC(counterparty, wbtc_amount, expiry); 95 | } 96 | 97 | function unlockWithdrawHash(bytes32 hash, bytes memory preimage) public { 98 | require(sha256(preimage) == hash, "preimage does not match hash"); 99 | require(WITHDRAW_HASHES[hash].expiry > block.timestamp, "HTLC is expired"); 100 | IERC20(WBTC_ADDRESS).transfer(WITHDRAW_HASHES[hash].counterparty, WITHDRAW_HASHES[hash].wbtc_amount); 101 | delete WITHDRAW_HASHES[hash]; 102 | } 103 | 104 | function reclaimWithdrawHash(bytes32 hash) public { 105 | require(WITHDRAW_HASHES[hash].expiry < block.timestamp, "HTLC is active"); 106 | delete WITHDRAW_HASHES[hash]; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /evm_contracts/contracts/ERC20/Token.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract Token is ERC20{ 7 | 8 | constructor() ERC20("Token", "TK"){ 9 | 10 | } 11 | 12 | function mint(uint256 amount, address recipient) external{ 13 | _mint( recipient, amount); 14 | } 15 | } -------------------------------------------------------------------------------- /evm_contracts/contracts/LibOrder.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | library LibOrder { 5 | //keccak256("Order(address user,address sellToken,address buyToken,uint256 sellAmount,uint256 buyAmount,uint256 expirationTimeSeconds)") 6 | bytes32 internal constant _EIP712_ORDER_SCHEMA_HASH = 0x68d868c8698fc31da3a36bb7a184a4af099797794701bae97bea3de7ebe6e399; 7 | 8 | struct Order { 9 | address user; //address of the Order Creator making the sale 10 | address sellToken; // address of the Token the Order Creator wants to sell 11 | address buyToken; // address of the Token the Order Creator wants to receive in return 12 | uint256 sellAmount; // amount of Token that the Order Creator wants to sell 13 | uint256 buyAmount; // amount of Token that the Order Creator wants to receive in return 14 | uint256 expirationTimeSeconds; //time after which the order is no longer valid 15 | } 16 | 17 | struct OrderInfo { 18 | bytes32 orderHash; // EIP712 typed data hash of the order (see LibOrder.getTypedDataHash). 19 | uint256 orderSellFilledAmount; // Amount of order that has already been filled. 20 | } 21 | 22 | // https://eips.ethereum.org/EIPS/eip-712#definition-of-hashstruct 23 | function getContentHash(Order memory order) internal pure returns (bytes32 orderHash) { 24 | orderHash = keccak256( 25 | abi.encode(_EIP712_ORDER_SCHEMA_HASH, order.user, order.sellToken, order.buyToken, order.sellAmount, order.buyAmount, order.expirationTimeSeconds) 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /evm_contracts/contracts/OZMinimalForwarder/MinimalForwarder.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // OpenZeppelin Contracts (last updated v4.8.0) (metatx/MinimalForwarder.sol) 3 | 4 | pragma solidity ^0.8.0; 5 | 6 | import "hardhat/console.sol"; 7 | import { EIP712 } from '@openzeppelin/contracts/utils/cryptography/EIP712.sol'; 8 | import { ECDSA } from '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; 9 | 10 | /** 11 | * @dev Simple minimal forwarder to be used together with an ERC2771 compatible contract. See {ERC2771Context}. 12 | * 13 | * MinimalForwarder is mainly meant for testing, as it is missing features to be a good production-ready forwarder. This 14 | * contract does not intend to have all the properties that are needed for a sound forwarding system. A fully 15 | * functioning forwarding system with good properties requires more complexity. We suggest you look at other projects 16 | * such as the GSN which do have the goal of building a system like that. 17 | */ 18 | contract MinimalForwarder is EIP712 { 19 | using ECDSA for bytes32; 20 | 21 | struct ForwardRequest { 22 | address from; 23 | address to; 24 | uint256 value; 25 | uint256 gas; 26 | uint256 nonce; 27 | bytes data; 28 | } 29 | 30 | bytes32 private constant _TYPEHASH = 31 | keccak256('ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)'); 32 | 33 | mapping(address => uint256) private _nonces; 34 | 35 | constructor() EIP712('MinimalForwarder', '0.0.1') {} 36 | 37 | function getNonce(address from) public view returns (uint256) { 38 | return _nonces[from]; 39 | } 40 | 41 | function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) { 42 | address signer = _hashTypedDataV4( 43 | keccak256(abi.encode(_TYPEHASH, req.from, req.to, req.value, req.gas, req.nonce, keccak256(req.data))) 44 | ).recover(signature); 45 | return _nonces[req.from] == req.nonce && signer == req.from; 46 | } 47 | 48 | function execute(ForwardRequest calldata req, bytes calldata signature) public payable returns (bool, bytes memory) { 49 | require(verify(req, signature), 'MinimalForwarder: signature does not match request'); 50 | _nonces[req.from] = req.nonce + 1; 51 | 52 | (bool success, bytes memory returndata) = req.to.call{ gas: req.gas, value: req.value }(abi.encodePacked(req.data, req.from)); 53 | if (!success) { 54 | string memory _revertMsg = _getRevertMsg(returndata); 55 | console.log(_revertMsg); 56 | } 57 | require(success, "MinimalForwarder: external call failed"); 58 | 59 | // Validate that the relayer has sent enough gas for the call. 60 | // See https://ronan.eth.limo/blog/ethereum-gas-dangers/ 61 | if (gasleft() <= req.gas / 63) { 62 | // We explicitly trigger invalid opcode to consume all gas and bubble-up the effects, since 63 | // neither revert or assert consume all gas since Solidity 0.8.0 64 | // https://docs.soliditylang.org/en/v0.8.0/control-structures.html#panic-via-assert-and-error-via-require 65 | /// @solidity memory-safe-assembly 66 | assembly { 67 | invalid() 68 | } 69 | } 70 | 71 | // in testing return the refund part to the sender 72 | if (address(this).balance > 0) { 73 | (bool successRefund, ) = msg.sender.call{ value: address(this).balance }(new bytes(0)); 74 | require(successRefund, 'ETH transfer failed'); 75 | } 76 | 77 | return (success, returndata); 78 | } 79 | 80 | function _getRevertMsg(bytes memory _returnData) internal pure returns (string memory) { 81 | // If the _res length is less than 68, then the transaction failed silently (without a revert message) 82 | if (_returnData.length < 68) return 'Transaction reverted silently'; 83 | 84 | assembly { 85 | // Slice the sighash. 86 | _returnData := add(_returnData, 0x04) 87 | } 88 | return abi.decode(_returnData, (string)); // All that remains is the revert string 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /evm_contracts/contracts/ZigZagExchange.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import './LibOrder.sol'; 5 | import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; 6 | import { EIP712 } from '@openzeppelin/contracts/utils/cryptography/EIP712.sol'; 7 | import { SignatureChecker } from '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol'; 8 | 9 | // import "hardhat/console.sol"; 10 | 11 | interface IWETH9 { 12 | function depositTo(address) external payable; 13 | 14 | function withdrawTo(address, uint256) external; 15 | 16 | function balanceOf(address) external view returns (uint256); 17 | } 18 | 19 | contract ZigZagExchange is EIP712 { 20 | event Swap( 21 | address maker, 22 | address indexed taker, 23 | address indexed makerSellToken, 24 | address indexed takerSellToken, 25 | uint256 makerSellAmount, 26 | uint256 takerSellAmount 27 | ); 28 | 29 | event CancelOrder(bytes32 indexed orderHash); 30 | event OrderStatus(bytes32 indexed orderHash, uint filled, uint remaining); 31 | 32 | mapping(bytes32 => uint256) public filled; 33 | 34 | mapping(bytes32 => bool) public cancelled; 35 | 36 | address immutable WETH_ADDRESS; 37 | address immutable EXCHANGE_ADDRESS; 38 | address constant ETH_ADDRESS = address(0); 39 | 40 | // initialize fee address 41 | constructor(string memory name, string memory version, address weth_address) EIP712(name, version) { 42 | WETH_ADDRESS = weth_address; 43 | EXCHANGE_ADDRESS = address(this); 44 | } 45 | 46 | receive() external payable {} 47 | 48 | /// @notice Cancel an order so it can no longer be filled 49 | /// @param order order that should get cancelled 50 | function cancelOrder(LibOrder.Order calldata order) public { 51 | require(msg.sender == order.user, 'only user may cancel order'); 52 | bytes32 orderHash = getOrderHash(order); 53 | require(filled[orderHash] < order.sellAmount, 'order already filled'); 54 | cancelled[orderHash] = true; 55 | emit CancelOrder(orderHash); 56 | } 57 | 58 | function fillOrderBookETH( 59 | LibOrder.Order[] calldata makerOrder, 60 | bytes[] calldata makerSignature, 61 | uint takerAmount 62 | ) public payable returns (bool) { 63 | require(makerOrder.length == makerSignature.length, 'Length of makerOrders and makerSignatures does not match'); 64 | require(makerOrder.length > 0, 'Length of makerOrders can not be 0'); 65 | 66 | uint256 n = makerOrder.length - 1; 67 | for (uint i = 0; i <= n && takerAmount > 0; i++) { 68 | takerAmount -= _fillOrderETH(makerOrder[i], makerSignature[i], msg.sender, msg.sender, takerAmount, true); 69 | } 70 | require(takerAmount == 0, 'Taker amount not filled'); 71 | 72 | _refundETH(); 73 | return true; 74 | } 75 | 76 | function fillOrderBook(LibOrder.Order[] calldata makerOrder, bytes[] calldata makerSignature, uint takerAmount) public returns (bool) { 77 | require(makerOrder.length == makerSignature.length, 'Length of makerOrders and makerSignatures does not match'); 78 | require(makerOrder.length > 0, 'Length of makerOrders can not be 0'); 79 | 80 | uint256 n = makerOrder.length - 1; 81 | for (uint i = 0; i <= n && takerAmount > 0; i++) { 82 | takerAmount -= _fillOrder( 83 | makerOrder[i], 84 | makerSignature[i], 85 | msg.sender, 86 | msg.sender, 87 | makerOrder[i].sellToken, 88 | makerOrder[i].buyToken, 89 | takerAmount, 90 | true 91 | ); 92 | } 93 | require(takerAmount == 0, 'Taker amount not filled'); 94 | 95 | return true; 96 | } 97 | 98 | function fillOrderRouteETH( 99 | LibOrder.Order[] calldata makerOrder, 100 | bytes[] calldata makerSignature, 101 | uint takerAmount, 102 | bool fillAvailable 103 | ) public payable returns (bool) { 104 | require(makerOrder.length == makerSignature.length, 'Length of makerOrders and makerSignatures does not match'); 105 | require(makerOrder.length > 0, 'Length of makerOrders can not be 0'); 106 | 107 | if (makerOrder.length == 1) { 108 | return fillOrderExactInputETH(makerOrder[0], makerSignature[0], takerAmount, fillAvailable); 109 | } 110 | 111 | for (uint i = 0; i < makerOrder.length; i++) { 112 | require(i == 0 || makerOrder[i - 1].sellToken == makerOrder[i].buyToken, 'Tokens on route do not match'); 113 | 114 | // takerAmountOut = takerAmountIn * price 115 | takerAmount = (takerAmount * makerOrder[i].sellAmount) / makerOrder[i].buyAmount; 116 | 117 | // first or last tx might need to (un-)wrap ETH 118 | if (i == 0 && makerOrder[0].buyToken == WETH_ADDRESS) { 119 | takerAmount = _fillOrderETH(makerOrder[0], makerSignature[0], msg.sender, EXCHANGE_ADDRESS, takerAmount, fillAvailable); 120 | } else if (i == makerOrder.length - 1 && makerOrder[makerOrder.length - 1].sellToken == WETH_ADDRESS) { 121 | takerAmount = _fillOrderETH( 122 | makerOrder[makerOrder.length - 1], 123 | makerSignature[makerOrder.length - 1], 124 | EXCHANGE_ADDRESS, 125 | msg.sender, 126 | takerAmount, 127 | fillAvailable 128 | ); 129 | } else { 130 | takerAmount = _fillOrder( 131 | makerOrder[i], 132 | makerSignature[i], 133 | i == 0 ? msg.sender : EXCHANGE_ADDRESS, 134 | i == makerOrder.length - 1 ? msg.sender : EXCHANGE_ADDRESS, 135 | makerOrder[i].sellToken, 136 | makerOrder[i].buyToken, 137 | takerAmount, 138 | fillAvailable 139 | ); 140 | } 141 | } 142 | 143 | _refundETH(); 144 | return true; 145 | } 146 | 147 | function fillOrderRoute( 148 | LibOrder.Order[] calldata makerOrder, 149 | bytes[] calldata makerSignature, 150 | uint takerAmount, 151 | bool fillAvailable 152 | ) public payable returns (bool) { 153 | require(makerOrder.length == makerSignature.length, 'Length of makerOrders and makerSignatures does not match'); 154 | require(makerOrder.length > 0, 'Length of makerOrders can not be 0'); 155 | 156 | if (makerOrder.length == 1) { 157 | return fillOrderExactInput(makerOrder[0], makerSignature[0], takerAmount, fillAvailable); 158 | } 159 | 160 | for (uint i = 0; i < makerOrder.length; i++) { 161 | require(i == 0 || makerOrder[i - 1].sellToken == makerOrder[i].buyToken, 'Tokens on route do not match'); 162 | 163 | // takerAmountOut = takerAmountIn * price 164 | takerAmount = (takerAmount * makerOrder[i].sellAmount) / makerOrder[i].buyAmount; 165 | 166 | takerAmount = _fillOrder( 167 | makerOrder[i], 168 | makerSignature[i], 169 | i == 0 ? msg.sender : EXCHANGE_ADDRESS, 170 | i == makerOrder.length - 1 ? msg.sender : EXCHANGE_ADDRESS, 171 | makerOrder[i].sellToken, 172 | makerOrder[i].buyToken, 173 | takerAmount, 174 | fillAvailable 175 | ); 176 | } 177 | 178 | return true; 179 | } 180 | 181 | /// @notice Fills an order with an exact amount to sell, taking or returning ETH 182 | /// @param makerOrder Order that will be used to make this swap, buyToken or sellToken must be WETH 183 | /// @param makerSignature Signature for the order used 184 | /// @param takerSellAmount amount send from the sender to the maker 185 | /// @return returns true if successfull 186 | function fillOrderExactInputETH( 187 | LibOrder.Order calldata makerOrder, 188 | bytes calldata makerSignature, 189 | uint takerSellAmount, 190 | bool fillAvailable 191 | ) public payable returns (bool) { 192 | uint takerBuyAmount = (takerSellAmount * makerOrder.sellAmount) / makerOrder.buyAmount; 193 | _fillOrderETH(makerOrder, makerSignature, msg.sender, msg.sender, takerBuyAmount, fillAvailable); 194 | _refundETH(); 195 | return true; 196 | } 197 | 198 | /// @notice Fills an order with an exact amount to buy, taking or returning ETH 199 | /// @param makerOrder Order that will be used to make this swap, buyToken or sellToken must be WETH 200 | /// @param makerSignature Signature for the order used 201 | /// @param takerBuyAmount amount send to the sender from the maker 202 | /// @param fillAvailable Should the maximum buyAmount possible be used 203 | /// @return returns true if successfull 204 | function fillOrderExactOutputETH( 205 | LibOrder.Order calldata makerOrder, 206 | bytes calldata makerSignature, 207 | uint takerBuyAmount, 208 | bool fillAvailable 209 | ) public payable returns (bool) { 210 | _fillOrderETH(makerOrder, makerSignature, msg.sender, msg.sender, takerBuyAmount, fillAvailable); 211 | _refundETH(); 212 | return true; 213 | } 214 | 215 | function _fillOrderETH( 216 | LibOrder.Order calldata makerOrder, 217 | bytes calldata makerSignature, 218 | address taker, 219 | address takerReciver, 220 | uint takerBuyAmountAdjusted, 221 | bool fillAvailable 222 | ) internal returns (uint256) { 223 | require(makerOrder.buyToken == WETH_ADDRESS || makerOrder.sellToken == WETH_ADDRESS, 'Either buy or sell token should be WETH'); 224 | 225 | if (makerOrder.buyToken == WETH_ADDRESS) { 226 | return 227 | _fillOrder( 228 | makerOrder, 229 | makerSignature, 230 | taker, 231 | takerReciver, 232 | makerOrder.sellToken, 233 | ETH_ADDRESS, 234 | takerBuyAmountAdjusted, 235 | fillAvailable 236 | ); 237 | } else { 238 | return 239 | _fillOrder( 240 | makerOrder, 241 | makerSignature, 242 | taker, 243 | takerReciver, 244 | ETH_ADDRESS, 245 | makerOrder.buyToken, 246 | takerBuyAmountAdjusted, 247 | fillAvailable 248 | ); 249 | } 250 | } 251 | 252 | /// @notice Fills an order with an exact amount to sell 253 | /// @param makerOrder Order that will be used to make this swap 254 | /// @param makerSignature Signature for the order used 255 | /// @param takerSellAmount amount send from the sender to the maker 256 | /// @return returns true if successfull 257 | function fillOrderExactInput( 258 | LibOrder.Order calldata makerOrder, 259 | bytes calldata makerSignature, 260 | uint takerSellAmount, 261 | bool fillAvailable 262 | ) public returns (bool) { 263 | uint takerBuyAmount = (takerSellAmount * makerOrder.sellAmount) / makerOrder.buyAmount; 264 | _fillOrder(makerOrder, makerSignature, msg.sender, msg.sender, makerOrder.sellToken, makerOrder.buyToken, takerBuyAmount, fillAvailable); 265 | return true; 266 | } 267 | 268 | /// @notice Fills an order with an exact amount to buy 269 | /// @param makerOrder Order that will be used to make this swap 270 | /// @param makerSignature Signature for the order used 271 | /// @param takerBuyAmount amount send to the sender from the maker 272 | /// @param fillAvailable Should the maximum buyAmount possible be used 273 | /// @return returns true if successfull 274 | function fillOrderExactOutput( 275 | LibOrder.Order calldata makerOrder, 276 | bytes calldata makerSignature, 277 | uint takerBuyAmount, 278 | bool fillAvailable 279 | ) public returns (bool) { 280 | _fillOrder(makerOrder, makerSignature, msg.sender, msg.sender, makerOrder.sellToken, makerOrder.buyToken, takerBuyAmount, fillAvailable); 281 | return true; 282 | } 283 | 284 | function _fillOrder( 285 | LibOrder.Order calldata makerOrder, 286 | bytes calldata makerSignature, 287 | address taker, 288 | address takerReciver, 289 | address sellToken, 290 | address buyToken, 291 | uint takerBuyAmountAdjusted, 292 | bool fillAvailable 293 | ) internal returns (uint256) { 294 | require(takerReciver != ETH_ADDRESS, "Can't recive to zero address"); 295 | 296 | LibOrder.OrderInfo memory makerOrderInfo = getOpenOrder(makerOrder); 297 | 298 | // Check if the order is valid. We dont want to revert if the user wants to fill whats available, worst case that is 0. 299 | { 300 | (bool isValidOrder, string memory errorMsgOrder) = _isValidOrder(makerOrderInfo, makerOrder, makerSignature); 301 | if (!isValidOrder && fillAvailable) return 0; 302 | require(isValidOrder, errorMsgOrder); 303 | } 304 | 305 | // adjust taker amount 306 | uint256 takerSellAmount; 307 | { 308 | uint256 availableTakerSellSize = makerOrder.sellAmount - makerOrderInfo.orderSellFilledAmount; 309 | if (fillAvailable && availableTakerSellSize < takerBuyAmountAdjusted) takerBuyAmountAdjusted = availableTakerSellSize; 310 | takerSellAmount = (takerBuyAmountAdjusted * makerOrder.buyAmount) / makerOrder.sellAmount; 311 | require(takerBuyAmountAdjusted <= availableTakerSellSize, 'amount exceeds available size'); 312 | } 313 | 314 | // check the maker balance/allowance with the adjusted taker amount 315 | { 316 | (bool isValidMaker, string memory errorMsgMaker) = _isValidMaker(makerOrder.user, sellToken, takerBuyAmountAdjusted); 317 | if (!isValidMaker && fillAvailable) return 0; 318 | require(isValidMaker, errorMsgMaker); 319 | } 320 | 321 | // mark fills in storage 322 | _updateOrderStatus(makerOrderInfo, makerOrder.sellAmount, takerBuyAmountAdjusted); 323 | 324 | _settleMatchedOrders(makerOrder.user, taker, takerReciver, sellToken, buyToken, takerBuyAmountAdjusted, takerSellAmount); 325 | 326 | return takerBuyAmountAdjusted; 327 | } 328 | 329 | function _settleMatchedOrders( 330 | address maker, 331 | address taker, 332 | address takerReciver, 333 | address makerSellToken, 334 | address takerSellToken, 335 | uint makerSellAmount, 336 | uint takerSellAmount 337 | ) internal { 338 | if (takerSellToken == ETH_ADDRESS) { 339 | require(msg.value >= takerSellAmount, 'msg value not high enough'); 340 | } else if (taker != EXCHANGE_ADDRESS) { 341 | require(IERC20(takerSellToken).balanceOf(taker) >= takerSellAmount, 'taker order not enough balance'); 342 | require(IERC20(takerSellToken).allowance(taker, EXCHANGE_ADDRESS) >= takerSellAmount, 'taker order not enough allowance'); 343 | } 344 | 345 | // taker -> maker 346 | if (takerSellToken == ETH_ADDRESS) { 347 | IWETH9(WETH_ADDRESS).depositTo{ value: takerSellAmount }(maker); 348 | } else if (taker == EXCHANGE_ADDRESS) { 349 | IERC20(takerSellToken).transfer(maker, takerSellAmount); 350 | } else { 351 | IERC20(takerSellToken).transferFrom(taker, maker, takerSellAmount); 352 | } 353 | 354 | // maker -> taker 355 | if (makerSellToken == ETH_ADDRESS) { 356 | IERC20(WETH_ADDRESS).transferFrom(maker, EXCHANGE_ADDRESS, makerSellAmount); 357 | IWETH9(WETH_ADDRESS).withdrawTo(takerReciver, makerSellAmount); 358 | } else { 359 | IERC20(makerSellToken).transferFrom(maker, takerReciver, makerSellAmount); 360 | } 361 | 362 | emit Swap(maker, taker, makerSellToken, takerSellToken, makerSellAmount, takerSellAmount); 363 | } 364 | 365 | function getOpenOrder(LibOrder.Order calldata order) public view returns (LibOrder.OrderInfo memory orderInfo) { 366 | orderInfo.orderHash = getOrderHash(order); 367 | orderInfo.orderSellFilledAmount = filled[orderInfo.orderHash]; 368 | } 369 | 370 | function getOrderHash(LibOrder.Order calldata order) public view returns (bytes32 orderHash) { 371 | bytes32 contentHash = LibOrder.getContentHash(order); 372 | orderHash = _hashTypedDataV4(contentHash); 373 | } 374 | 375 | function isValidOrderSignature(LibOrder.Order calldata order, bytes calldata signature) public view returns (bool) { 376 | bytes32 orderHash = getOrderHash(order); 377 | return _isValidSignatureHash(order.user, orderHash, signature); 378 | } 379 | 380 | function _isValidSignatureHash(address user, bytes32 hash, bytes calldata signature) private view returns (bool) { 381 | return SignatureChecker.isValidSignatureNow(user, hash, signature); 382 | } 383 | 384 | // always refund the one sending the msg, metaTx or nativeTx 385 | function _refundETH() internal { 386 | if (address(this).balance > 0) { 387 | (bool success, ) = msg.sender.call{ value: address(this).balance }(new bytes(0)); 388 | require(success, 'ETH transfer failed'); 389 | } 390 | } 391 | 392 | function _isValidOrder( 393 | LibOrder.OrderInfo memory orderInfo, 394 | LibOrder.Order calldata order, 395 | bytes calldata signature 396 | ) internal view returns (bool, string memory) { 397 | if (!_isValidSignatureHash(order.user, orderInfo.orderHash, signature)) return (false, 'invalid maker signature'); 398 | if (cancelled[orderInfo.orderHash]) return (false, 'order canceled'); 399 | if (block.timestamp > order.expirationTimeSeconds) return (false, 'order expired'); 400 | if (order.sellAmount - orderInfo.orderSellFilledAmount == 0) return (false, 'order is filled'); 401 | 402 | return (true, ''); 403 | } 404 | 405 | function _isValidMaker(address maker, address makerSellToken, uint256 takerAmount) internal view returns (bool, string memory) { 406 | if (makerSellToken == ETH_ADDRESS) makerSellToken = WETH_ADDRESS; 407 | uint256 balance = IERC20(makerSellToken).balanceOf(maker); 408 | uint256 allowance = IERC20(makerSellToken).allowance(maker, EXCHANGE_ADDRESS); 409 | if (balance < takerAmount) return (false, 'maker order not enough balance'); 410 | if (allowance < takerAmount) return (false, 'maker order not enough allowance'); 411 | 412 | return (true, ''); 413 | } 414 | 415 | function _updateOrderStatus(LibOrder.OrderInfo memory makerOrderInfo, uint256 makerSellAmount, uint256 takerBuyAmount) internal { 416 | uint makerOrderFilled = makerOrderInfo.orderSellFilledAmount + takerBuyAmount; 417 | filled[makerOrderInfo.orderHash] = makerOrderFilled; 418 | 419 | emit OrderStatus(makerOrderInfo.orderHash, makerOrderFilled, makerSellAmount - makerOrderFilled); 420 | } 421 | } 422 | -------------------------------------------------------------------------------- /evm_contracts/contracts/ZigZagFutures.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; 6 | 7 | contract ZigZagFutures is ERC20 { 8 | 9 | ///////////////////////////////////////////////////////////////////// 10 | // Definitions & Variables 11 | 12 | struct Order { 13 | address user; //address of the Order Creator making the sale 14 | string asset ; // unique identifier for the asset being traded 15 | address buyToken; // address of the Token the Order Creator wants to receive in return 16 | uint256 sellAmount; // amount of Token that the Order Creator wants to sell 17 | uint256 buyAmount; // amount of Token that the Order Creator wants to receive in return 18 | uint256 expirationTimeSeconds; //time after which the order is no longer valid 19 | } 20 | 21 | // The manager of a vault is allowed to sign orders that a vault can execute 22 | address public manager; 23 | 24 | // Only USDC is permitted as collateral 25 | address public USDC_ADDRESS; 26 | 27 | // Track USDC collateral 28 | mapping(address => uint) collateral; 29 | 30 | // Track positions, first by address then by asset 31 | mapping(address => mapping (address => uint)) positions; 32 | 33 | 34 | ///////////////////////////////////////////////////////////////////// 35 | // Basic Functions 36 | 37 | constructor(address _manager, address _usdc_address, string memory _name, string memory _symbol) ERC20(_name, _symbol) { 38 | manager = _manager; 39 | USDC_ADDRESS = _usdc_address; 40 | } 41 | 42 | function updateManager(address newManager) public { 43 | require(msg.sender == manager, "only manager can update manager"); 44 | manager = newManager; 45 | } 46 | 47 | ///////////////////////////////////////////////////////////////////// 48 | // The manager can use mintLPToken and burnLPToken to set LP limits 49 | // The LP tokens are then swapped for user funds 50 | 51 | function mintLPToken(uint amount) public { 52 | require(msg.sender == manager, "only manager can mint LP tokens"); 53 | _mint(address(this), amount); 54 | } 55 | 56 | function burnLPToken(uint amount) public { 57 | require(msg.sender == manager, "only manager can burn LP tokens"); 58 | _burn(address(this), amount); 59 | } 60 | 61 | // LP token circulating supply does not include balance of vault 62 | function circulatingSupply() public view returns (uint) { 63 | return totalSupply() - balanceOf(address(this)); 64 | } 65 | 66 | 67 | ////////////////////////////////////////////////////////////////////// 68 | // Exchange Functionality 69 | 70 | } 71 | -------------------------------------------------------------------------------- /evm_contracts/contracts/ZigZagVault.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 6 | import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; 7 | import './LibOrder.sol'; 8 | 9 | interface IExchange { 10 | function cancelOrder(LibOrder.Order calldata order) external; 11 | } 12 | 13 | 14 | contract ZigZagVault is ERC20 { 15 | // The manager of a vault is allowed to sign orders that a vault can execute 16 | address public manager; 17 | 18 | constructor(address _manager, string memory _name, string memory _symbol) ERC20(_name, _symbol) { 19 | manager = _manager; 20 | } 21 | 22 | function updateManager(address newManager) public { 23 | require(msg.sender == manager, "only manager can update manager"); 24 | manager = newManager; 25 | } 26 | 27 | function approveToken(address token, address spender, uint amount) public { 28 | require(msg.sender == manager, "only manager can approve tokens"); 29 | IERC20(token).approve(spender, amount); 30 | } 31 | 32 | ///////////////////////////////////////////////////////////////////// 33 | // The manager can use mintLPToken and burnLPToken to set LP limits 34 | // The LP tokens are then swapped for user funds 35 | 36 | function mintLPToken(uint amount) public { 37 | require(msg.sender == manager, "only manager can mint LP tokens"); 38 | _mint(address(this), amount); 39 | } 40 | 41 | function burnLPToken(uint amount) public { 42 | require(msg.sender == manager, "only manager can burn LP tokens"); 43 | _burn(address(this), amount); 44 | } 45 | 46 | // LP token circulating supply does not include balance of vault 47 | function circulatingSupply() public view returns (uint) { 48 | return totalSupply() - balanceOf(address(this)); 49 | } 50 | 51 | 52 | //////////////////////////////////////////////////// 53 | // EIP-1271 Smart Contract Signatures 54 | 55 | // This is a convenience function so off-chain signature verifications don't have to worry about 56 | // magic numbers 57 | function isValidSignatureNow(bytes32 digest, bytes memory signature) public view returns (bool) { 58 | return SignatureChecker.isValidSignatureNow(manager, digest, signature); 59 | } 60 | 61 | // EIP-1271 requires isValidSignature to return a magic number if true and 0x00 if false 62 | function isValidSignature(bytes32 digest, bytes memory signature) public view returns (bytes4) { 63 | return SignatureChecker.isValidSignatureNow(manager, digest, signature) ? bytes4(0x1626ba7e) : bytes4(0x00000000); 64 | } 65 | 66 | //////////////////////////////////////////////////// 67 | // Canceling Orders 68 | 69 | function cancelOrder(address exchange, LibOrder.Order calldata order) public { 70 | require(msg.sender == manager, "only manager can cancel orders"); 71 | IExchange(exchange).cancelOrder(order); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /evm_contracts/contracts/aeWETH/IWETH9.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | 3 | // solhint-disable-next-line compiler-version 4 | pragma solidity >=0.6.9 <0.9.0; 5 | 6 | /// @notice DEPRECATED - see new repo(https://github.com/OffchainLabs/token-bridge-contracts) for new updates 7 | interface IWETH9 { 8 | function deposit() external payable; 9 | 10 | function withdraw(uint256 _amount) external; 11 | } -------------------------------------------------------------------------------- /evm_contracts/contracts/aeWETH/aeWETH.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | 3 | /* 4 | * Copyright 2020, Offchain Labs, Inc. 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | pragma solidity ^0.8.0; 20 | 21 | import "./IWETH9.sol"; 22 | import "../ERC20/Token.sol"; 23 | 24 | // import "hardhat/console.sol"; 25 | 26 | /// @title Arbitrum extended WETH 27 | /// @notice DEPRECATED - see new repo(https://github.com/OffchainLabs/token-bridge-contracts) for new updates 28 | contract aeWETH is IWETH9, Token { 29 | 30 | function initialize() external {} 31 | 32 | function deposit() external payable override { 33 | depositTo(msg.sender); 34 | } 35 | 36 | function withdraw(uint256 amount) external override { 37 | withdrawTo(msg.sender, amount); 38 | } 39 | 40 | function depositTo(address account) public payable { 41 | _mint(account, msg.value); 42 | } 43 | 44 | function withdrawTo(address account, uint256 amount) public { 45 | _burn(msg.sender, amount); 46 | 47 | (bool success, ) = account.call{ value: amount }(""); 48 | require(success, "FAIL_TRANSFER"); 49 | } 50 | 51 | receive() external payable { 52 | depositTo(msg.sender); 53 | } 54 | } -------------------------------------------------------------------------------- /evm_contracts/hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import { task } from "hardhat/config"; 2 | import "@nomiclabs/hardhat-waffle"; 3 | import "@nomiclabs/hardhat-ethers"; 4 | import "@nomiclabs/hardhat-etherscan"; 5 | 6 | task("accounts", "Prints the list of accounts", async (args, hre) => { 7 | const accounts = await hre.ethers.getSigners(); 8 | 9 | for (const account of accounts) { 10 | console.log(await account.address); 11 | } 12 | }); 13 | /** 14 | * @type import('hardhat/config').HardhatUserConfig 15 | */ 16 | export default { 17 | solidity: { 18 | compilers: [ 19 | { 20 | version: "0.8.10", 21 | settings: { 22 | optimizer: { 23 | enabled: true, 24 | runs: 1_000_000, 25 | }, 26 | }, 27 | }, 28 | ], 29 | 30 | }, 31 | etherscan: { 32 | apiKey: { 33 | arbitrumOne: "", 34 | arbitrumGoerli: "", 35 | } 36 | }, 37 | networks: { 38 | arbitrumOne: { 39 | url: "https://arb1.arbitrum.io/rpc", 40 | accounts: [] 41 | }, 42 | arbitrumGoerli: { 43 | url: "https://goerli-rollup.arbitrum.io/rpc", 44 | accounts: [] 45 | } 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /evm_contracts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zigzag", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npx hardhat compile && mocha -r ts-node/register test/*.ts", 8 | "compile": "npx hardhat compile" 9 | }, 10 | "engines": { 11 | "node": "16.17.1" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@nomiclabs/hardhat-ethers": "^2.0.6", 18 | "@nomiclabs/hardhat-etherscan": "^3.1.2", 19 | "@nomiclabs/hardhat-waffle": "^2.0.3", 20 | "@types/chai": "^4.3.1", 21 | "@types/mocha": "^9.1.1", 22 | "@types/node": "^17.0.42", 23 | "chai": "^4.3.6", 24 | "ethereum-waffle": "^3.4.4", 25 | "ethers": "^5.6.8", 26 | "hardhat": "^2.9.7", 27 | "ts-node": "^10.8.1", 28 | "typescript": "^4.7.3" 29 | }, 30 | "dependencies": { 31 | "@0x/utils": "^6.5.3", 32 | "@noble/hashes": "^1.1.1", 33 | "@nomicfoundation/hardhat-network-helpers": "^1.0.7", 34 | "@openzeppelin/contracts": "^4.8.0-rc.1", 35 | "crypto": "^1.0.1", 36 | "eip-712": "^1.0.0", 37 | "web3": "^1.7.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /evm_contracts/scripts/deployExchange.js: -------------------------------------------------------------------------------- 1 | const hre = require('hardhat') 2 | 3 | async function main() { 4 | const Exchange = await hre.ethers.getContractFactory('ZigZagExchange') 5 | const weth_address = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"; 6 | const exchange = await Exchange.deploy("ZigZag", "2.1", weth_address); 7 | 8 | await exchange.deployed() 9 | 10 | console.log('Exchange deployed to:', exchange.address) 11 | } 12 | 13 | main() 14 | -------------------------------------------------------------------------------- /evm_contracts/scripts/deployVault.js: -------------------------------------------------------------------------------- 1 | const hre = require('hardhat') 2 | 3 | async function main() { 4 | const Vault = await hre.ethers.getContractFactory('ZigZagVault') 5 | const manager = "0x6f457Ce670D18FF8bda00E1B5D9654833e7D91BB"; 6 | const vault = await Vault.deploy(manager, "ZigZag LP", "ZZLP"); 7 | 8 | await vault.deployed() 9 | 10 | console.log('Vault deployed to:', vault.address) 11 | } 12 | 13 | main() 14 | -------------------------------------------------------------------------------- /evm_contracts/test/FillOrderExactInput.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'hardhat' 2 | import { expect } from 'chai' 3 | import { Contract, Wallet } from 'ethers' 4 | import { TESTRPC_PRIVATE_KEYS_STRINGS } from './utils/PrivateKeyList' 5 | import { signOrder, signCancelOrder, getOrderHash } from './utils/SignUtil' 6 | 7 | describe('fillOrderExactInput', () => { 8 | let exchangeContract: Contract 9 | let tokenA: Contract 10 | let tokenB: Contract 11 | const wallets: Wallet[] = [] 12 | 13 | beforeEach(async function () { 14 | this.timeout(30000) 15 | const Exchange = await ethers.getContractFactory('ZigZagExchange') 16 | const Token = await ethers.getContractFactory('Token') 17 | const { provider } = ethers 18 | 19 | tokenA = await Token.deploy() 20 | tokenB = await Token.deploy() 21 | const [owner] = await ethers.getSigners() 22 | 23 | for (let i = 0; i < 4; i++) { 24 | wallets[i] = new ethers.Wallet(TESTRPC_PRIVATE_KEYS_STRINGS[i], provider) 25 | 26 | await owner.sendTransaction({ 27 | to: wallets[i].address, 28 | value: ethers.utils.parseEther('0.1') // 0.1 ether 29 | }) 30 | } 31 | 32 | exchangeContract = await Exchange.deploy( 33 | 'ZigZag', 34 | '2.1', 35 | ethers.constants.AddressZero 36 | ) 37 | 38 | await tokenA.mint(ethers.utils.parseEther('1000'), wallets[0].address) 39 | await tokenB.mint(ethers.utils.parseEther('1000'), wallets[1].address) 40 | await tokenA 41 | .connect(wallets[0]) 42 | .approve(exchangeContract.address, ethers.utils.parseEther('1000')) 43 | await tokenB 44 | .connect(wallets[1]) 45 | .approve(exchangeContract.address, ethers.utils.parseEther('1000')) 46 | 47 | }) 48 | 49 | it("Should revert with 'maker order not enough balance' ", async () => { 50 | const makerOrder = { 51 | user: wallets[0].address, 52 | sellToken: tokenA.address, 53 | buyToken: tokenB.address, 54 | sellAmount: ethers.utils.parseEther('20000'), 55 | buyAmount: ethers.utils.parseEther('1'), 56 | expirationTimeSeconds: ethers.BigNumber.from( 57 | String(Math.floor(Date.now() / 1000) + 3600) 58 | ) 59 | } 60 | const signedLeftMessage = await signOrder( 61 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 62 | makerOrder, 63 | exchangeContract.address 64 | ) 65 | 66 | const fillAmount = ethers.utils.parseEther('1') 67 | await expect( 68 | exchangeContract 69 | .connect(wallets[1]) 70 | .fillOrderExactInput( 71 | Object.values(makerOrder), 72 | signedLeftMessage, 73 | fillAmount, 74 | false 75 | ) 76 | ).to.be.revertedWith('maker order not enough balance') 77 | }) 78 | 79 | it("Should revert with 'taker order not enough balance' ", async () => { 80 | const makerOrder = { 81 | user: wallets[0].address, 82 | sellToken: tokenA.address, 83 | buyToken: tokenB.address, 84 | sellAmount: ethers.utils.parseEther('1'), 85 | buyAmount: ethers.utils.parseEther('15000'), 86 | expirationTimeSeconds: ethers.BigNumber.from( 87 | String(Math.floor(Date.now() / 1000) + 3600) 88 | ) 89 | } 90 | const signedLeftMessage = await signOrder( 91 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 92 | makerOrder, 93 | exchangeContract.address 94 | ) 95 | 96 | const fillAmount = ethers.utils.parseEther('15000') 97 | await expect( 98 | exchangeContract 99 | .connect(wallets[1]) 100 | .fillOrderExactInput( 101 | Object.values(makerOrder), 102 | signedLeftMessage, 103 | fillAmount, 104 | false 105 | ) 106 | ).to.be.revertedWith('taker order not enough balance') 107 | }) 108 | 109 | it("Should revert with 'maker order not enough allowance' ", async () => { 110 | const makerOrder = { 111 | user: wallets[0].address, 112 | sellToken: tokenA.address, 113 | buyToken: tokenB.address, 114 | sellAmount: ethers.utils.parseEther('200'), 115 | buyAmount: ethers.utils.parseEther('100'), 116 | expirationTimeSeconds: ethers.BigNumber.from( 117 | String(Math.floor(Date.now() / 1000) + 3600) 118 | ) 119 | } 120 | 121 | const signedLeftMessage = await signOrder( 122 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 123 | makerOrder, 124 | exchangeContract.address 125 | ) 126 | await tokenA.connect(wallets[0]).approve(exchangeContract.address, '0') 127 | 128 | const fillAmount = ethers.utils.parseEther('100') 129 | await expect( 130 | exchangeContract 131 | .connect(wallets[1]) 132 | .fillOrderExactInput( 133 | Object.values(makerOrder), 134 | signedLeftMessage, 135 | fillAmount, 136 | false 137 | ) 138 | ).to.be.revertedWith('maker order not enough allowance') 139 | }) 140 | 141 | it("Should revert with 'taker order not enough allowance' ", async () => { 142 | const makerOrder = { 143 | user: wallets[0].address, 144 | sellToken: tokenA.address, 145 | buyToken: tokenB.address, 146 | sellAmount: ethers.utils.parseEther('200'), 147 | buyAmount: ethers.utils.parseEther('100'), 148 | expirationTimeSeconds: ethers.BigNumber.from( 149 | String(Math.floor(Date.now() / 1000) + 3600) 150 | ) 151 | } 152 | 153 | const signedLeftMessage = await signOrder( 154 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 155 | makerOrder, 156 | exchangeContract.address 157 | ) 158 | await tokenB.connect(wallets[1]).approve(exchangeContract.address, '0') 159 | 160 | const fillAmount = ethers.utils.parseEther('100') 161 | await expect( 162 | exchangeContract 163 | .connect(wallets[1]) 164 | .fillOrderExactInput( 165 | Object.values(makerOrder), 166 | signedLeftMessage, 167 | fillAmount, 168 | false 169 | ) 170 | ).to.be.revertedWith('taker order not enough allowance') 171 | }) 172 | 173 | it('Should revert when maker order is already filled', async () => { 174 | const makerOrder = { 175 | user: wallets[0].address, 176 | sellToken: tokenA.address, 177 | buyToken: tokenB.address, 178 | sellAmount: ethers.BigNumber.from('971'), 179 | buyAmount: ethers.BigNumber.from('120'), 180 | expirationTimeSeconds: ethers.BigNumber.from( 181 | String(Math.floor(Date.now() / 1000) + 3600) 182 | ) 183 | } 184 | 185 | const signedLeftMessage = await signOrder( 186 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 187 | makerOrder, 188 | exchangeContract.address 189 | ) 190 | 191 | const fillAmount = ethers.BigNumber.from('120') 192 | await exchangeContract 193 | .connect(wallets[1]) 194 | .fillOrderExactInput( 195 | Object.values(makerOrder), 196 | signedLeftMessage, 197 | fillAmount, 198 | false 199 | ) 200 | await expect( 201 | exchangeContract 202 | .connect(wallets[1]) 203 | .fillOrderExactInput( 204 | Object.values(makerOrder), 205 | signedLeftMessage, 206 | fillAmount, 207 | false 208 | ) 209 | ).to.be.revertedWith('order is filled') 210 | }) 211 | 212 | it('Should revert when maker order is canceled', async () => { 213 | const makerOrder = { 214 | user: wallets[0].address, 215 | sellToken: tokenA.address, 216 | buyToken: tokenB.address, 217 | sellAmount: ethers.BigNumber.from('970'), 218 | buyAmount: ethers.BigNumber.from('120'), 219 | expirationTimeSeconds: ethers.BigNumber.from( 220 | String(Math.floor(Date.now() / 1000) + 3600) 221 | ) 222 | } 223 | 224 | const signedLeftMessage = await signOrder( 225 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 226 | makerOrder, 227 | exchangeContract.address 228 | ) 229 | 230 | const fillAmount = ethers.utils.parseEther('1') 231 | await exchangeContract 232 | .connect(wallets[0]) 233 | .cancelOrder(Object.values(makerOrder)) 234 | await expect( 235 | exchangeContract 236 | .connect(wallets[1]) 237 | .fillOrderExactInput( 238 | Object.values(makerOrder), 239 | signedLeftMessage, 240 | fillAmount, 241 | false 242 | ) 243 | ).to.be.revertedWith('order canceled') 244 | }) 245 | 246 | 247 | it('Should revert when maker order is expired', async () => { 248 | const makerOrder = { 249 | user: wallets[0].address, 250 | sellToken: tokenA.address, 251 | buyToken: tokenB.address, 252 | sellAmount: ethers.BigNumber.from('970'), 253 | buyAmount: ethers.BigNumber.from('120'), 254 | expirationTimeSeconds: ethers.BigNumber.from('100') 255 | } 256 | 257 | const fillAmount = ethers.utils.parseEther('1') 258 | const signedLeftMessage = await signOrder( 259 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 260 | makerOrder, 261 | exchangeContract.address 262 | ) 263 | await expect( 264 | exchangeContract 265 | .connect(wallets[1]) 266 | .fillOrderExactInput( 267 | Object.values(makerOrder), 268 | signedLeftMessage, 269 | fillAmount, 270 | false 271 | ) 272 | ).to.be.revertedWith('order expired') 273 | }) 274 | 275 | it('should fail when filled twice', async () => { 276 | const makerOrder = { 277 | user: wallets[0].address, 278 | sellToken: tokenA.address, 279 | buyToken: tokenB.address, 280 | sellAmount: ethers.utils.parseEther('200'), 281 | buyAmount: ethers.utils.parseEther('100'), 282 | expirationTimeSeconds: ethers.BigNumber.from( 283 | String(Math.floor(Date.now() / 1000) + 3600) 284 | ) 285 | } 286 | 287 | const signedLeftMessage = await signOrder( 288 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 289 | makerOrder, 290 | exchangeContract.address 291 | ) 292 | 293 | const fillAmount = ethers.utils.parseEther('100') 294 | const tx = await exchangeContract 295 | .connect(wallets[1]) 296 | .fillOrderExactInput( 297 | Object.values(makerOrder), 298 | signedLeftMessage, 299 | fillAmount, 300 | false 301 | ) 302 | await expect( 303 | exchangeContract 304 | .connect(wallets[1]) 305 | .fillOrderExactInput( 306 | Object.values(makerOrder), 307 | signedLeftMessage, 308 | fillAmount, 309 | false 310 | ) 311 | ).to.be.revertedWith('order is filled') 312 | }) 313 | 314 | it('fill a full order', async () => { 315 | const makerOrder = { 316 | user: wallets[0].address, 317 | sellToken: tokenA.address, 318 | buyToken: tokenB.address, 319 | sellAmount: ethers.utils.parseEther('200'), 320 | buyAmount: ethers.utils.parseEther('100'), 321 | expirationTimeSeconds: ethers.BigNumber.from( 322 | String(Math.floor(Date.now() / 1000) + 3600) 323 | ) 324 | } 325 | 326 | const signedLeftMessage = await signOrder( 327 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 328 | makerOrder, 329 | exchangeContract.address 330 | ) 331 | 332 | const fillAmount = ethers.utils.parseEther('100') 333 | await exchangeContract 334 | .connect(wallets[1]) 335 | .fillOrderExactInput( 336 | Object.values(makerOrder), 337 | signedLeftMessage, 338 | fillAmount, 339 | false 340 | ) 341 | 342 | const balance1 = await tokenA.balanceOf(wallets[0].address) 343 | const balance2 = await tokenA.balanceOf(wallets[1].address) 344 | const balance3 = await tokenA.balanceOf(wallets[2].address) 345 | const balance4 = await tokenB.balanceOf(wallets[0].address) 346 | const balance5 = await tokenB.balanceOf(wallets[1].address) 347 | const balance6 = await tokenB.balanceOf(wallets[2].address) 348 | console.log( 349 | ethers.utils.formatEther(balance1), 350 | ethers.utils.formatEther(balance4) 351 | ) 352 | console.log( 353 | ethers.utils.formatEther(balance2), 354 | ethers.utils.formatEther(balance5) 355 | ) 356 | console.log( 357 | ethers.utils.formatEther(balance3), 358 | ethers.utils.formatEther(balance6) 359 | ) 360 | 361 | expect(balance2).to.equal(ethers.utils.parseEther('200')) 362 | expect(balance4).to.equal(ethers.utils.parseEther('100')) 363 | }) 364 | 365 | it('should fail without fillAvailable when over-ordering', async () => { 366 | const makerOrder = { 367 | user: wallets[0].address, 368 | sellToken: tokenA.address, 369 | buyToken: tokenB.address, 370 | sellAmount: ethers.utils.parseEther('200'), 371 | buyAmount: ethers.utils.parseEther('100'), 372 | expirationTimeSeconds: ethers.BigNumber.from( 373 | String(Math.floor(Date.now() / 1000) + 3600) 374 | ) 375 | } 376 | 377 | const signedLeftMessage = await signOrder( 378 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 379 | makerOrder, 380 | exchangeContract.address 381 | ) 382 | 383 | const fillAmount = ethers.utils.parseEther('90') 384 | await exchangeContract 385 | .connect(wallets[1]) 386 | .fillOrderExactInput( 387 | Object.values(makerOrder), 388 | signedLeftMessage, 389 | fillAmount, 390 | false 391 | ) 392 | const tx2 = exchangeContract 393 | .connect(wallets[1]) 394 | .fillOrderExactInput( 395 | Object.values(makerOrder), 396 | signedLeftMessage, 397 | fillAmount, 398 | false 399 | ) 400 | await expect(tx2).to.be.revertedWith('amount exceeds available size') 401 | }) 402 | 403 | it('should not fail with fillAvailable when over-ordering', async () => { 404 | const makerOrder = { 405 | user: wallets[0].address, 406 | sellToken: tokenA.address, 407 | buyToken: tokenB.address, 408 | sellAmount: ethers.utils.parseEther('200'), 409 | buyAmount: ethers.utils.parseEther('100'), 410 | expirationTimeSeconds: ethers.BigNumber.from( 411 | String(Math.floor(Date.now() / 1000) + 3600) 412 | ) 413 | } 414 | 415 | const signedLeftMessage = await signOrder( 416 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 417 | makerOrder, 418 | exchangeContract.address 419 | ) 420 | 421 | const fillAmount = ethers.utils.parseEther('90') 422 | await exchangeContract 423 | .connect(wallets[1]) 424 | .fillOrderExactInput( 425 | Object.values(makerOrder), 426 | signedLeftMessage, 427 | fillAmount, 428 | false 429 | ) 430 | await exchangeContract 431 | .connect(wallets[1]) 432 | .fillOrderExactInput( 433 | Object.values(makerOrder), 434 | signedLeftMessage, 435 | fillAmount, 436 | true 437 | ) 438 | 439 | const balance2 = await tokenA.balanceOf(wallets[1].address) 440 | const balance4 = await tokenB.balanceOf(wallets[0].address) 441 | 442 | expect(balance2).to.equal(ethers.utils.parseEther('200')) 443 | expect(balance4).to.equal(ethers.utils.parseEther('100')) 444 | }) 445 | 446 | it('Should emit events for a partial order', async () => { 447 | const makerOrder = { 448 | user: wallets[0].address, 449 | sellToken: tokenA.address, 450 | buyToken: tokenB.address, 451 | sellAmount: ethers.utils.parseEther('200'), 452 | buyAmount: ethers.utils.parseEther('100'), 453 | expirationTimeSeconds: ethers.BigNumber.from( 454 | String(Math.floor(Date.now() / 1000) + 3600) 455 | ) 456 | } 457 | 458 | const signedLeftMessage = await signOrder( 459 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 460 | makerOrder, 461 | exchangeContract.address 462 | ) 463 | const orderHash = await getOrderHash(makerOrder, exchangeContract.address) 464 | 465 | const fillAmount = ethers.utils.parseEther('50') 466 | 467 | expect( 468 | await exchangeContract 469 | .connect(wallets[1]) 470 | .fillOrderExactInput( 471 | Object.values(makerOrder), 472 | signedLeftMessage, 473 | fillAmount, 474 | false 475 | ) 476 | ) 477 | .to.emit(exchangeContract, 'Swap') 478 | .withArgs( 479 | wallets[0].address, 480 | wallets[1].address, 481 | tokenA.address, 482 | tokenB.address, 483 | ethers.utils.parseEther('100'), 484 | ethers.utils.parseEther('50'), 485 | ethers.utils.parseEther('0'), 486 | ethers.utils.parseEther('0.05') 487 | ) 488 | .to.emit(exchangeContract, 'OrderStatus') 489 | .withArgs( 490 | orderHash, 491 | ethers.utils.parseEther('100'), 492 | ethers.utils.parseEther('100') 493 | ) 494 | }) 495 | 496 | it('Should emit events for a full order', async () => { 497 | const makerOrder = { 498 | user: wallets[0].address, 499 | sellToken: tokenA.address, 500 | buyToken: tokenB.address, 501 | sellAmount: ethers.utils.parseEther('200'), 502 | buyAmount: ethers.utils.parseEther('100'), 503 | expirationTimeSeconds: ethers.BigNumber.from( 504 | String(Math.floor(Date.now() / 1000) + 3600) 505 | ) 506 | } 507 | 508 | const signedLeftMessage = await signOrder( 509 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 510 | makerOrder, 511 | exchangeContract.address 512 | ) 513 | const orderHash = await getOrderHash(makerOrder, exchangeContract.address) 514 | 515 | const fillAmount = ethers.utils.parseEther('100') 516 | 517 | expect( 518 | await exchangeContract 519 | .connect(wallets[1]) 520 | .fillOrderExactInput( 521 | Object.values(makerOrder), 522 | signedLeftMessage, 523 | fillAmount, 524 | false 525 | ) 526 | ) 527 | .to.emit(exchangeContract, 'Swap') 528 | .withArgs( 529 | wallets[0].address, 530 | wallets[1].address, 531 | tokenA.address, 532 | tokenB.address, 533 | ethers.utils.parseEther('200'), 534 | ethers.utils.parseEther('100'), 535 | ethers.utils.parseEther('0'), 536 | ethers.utils.parseEther('0.1') 537 | ) 538 | .to.emit(exchangeContract, 'OrderStatus') 539 | .withArgs( 540 | orderHash, 541 | ethers.utils.parseEther('200'), 542 | ethers.constants.Zero 543 | ) 544 | }) 545 | }) 546 | -------------------------------------------------------------------------------- /evm_contracts/test/FillOrderExactOutput.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'hardhat' 2 | import { expect } from 'chai' 3 | import { Contract, Wallet } from 'ethers' 4 | import { TESTRPC_PRIVATE_KEYS_STRINGS } from './utils/PrivateKeyList' 5 | import { signOrder, signCancelOrder, getOrderHash } from './utils/SignUtil' 6 | 7 | describe('fillOrderExactOutput', () => { 8 | let exchangeContract: Contract 9 | let tokenA: Contract 10 | let tokenB: Contract 11 | const wallets: Wallet[] = [] 12 | 13 | beforeEach(async function () { 14 | this.timeout(30000) 15 | const Exchange = await ethers.getContractFactory('ZigZagExchange') 16 | const Token = await ethers.getContractFactory('Token') 17 | const {provider} = ethers 18 | 19 | tokenA = await Token.deploy() 20 | tokenB = await Token.deploy() 21 | const [owner] = await ethers.getSigners() 22 | 23 | for (let i = 0; i < 4; i++) { 24 | wallets[i] = new ethers.Wallet(TESTRPC_PRIVATE_KEYS_STRINGS[i], provider) 25 | 26 | await owner.sendTransaction({ 27 | to: wallets[i].address, 28 | value: ethers.utils.parseEther('0.1') // 0.1 ether 29 | }) 30 | } 31 | 32 | exchangeContract = await Exchange.deploy( 33 | 'ZigZag', 34 | '2.1', 35 | ethers.constants.AddressZero 36 | ) 37 | 38 | await tokenA.mint(ethers.utils.parseEther('1000'), wallets[0].address) 39 | await tokenB.mint(ethers.utils.parseEther('1000'), wallets[1].address) 40 | await tokenA 41 | .connect(wallets[0]) 42 | .approve(exchangeContract.address, ethers.utils.parseEther('1000')) 43 | await tokenB 44 | .connect(wallets[1]) 45 | .approve(exchangeContract.address, ethers.utils.parseEther('1000')) 46 | 47 | }) 48 | 49 | it("Should revert with 'taker order not enough balance' ", async () => { 50 | const makerOrder = { 51 | user: wallets[0].address, 52 | sellToken: tokenA.address, 53 | buyToken: tokenB.address, 54 | sellAmount: ethers.utils.parseEther('1'), 55 | buyAmount: ethers.utils.parseEther('20000'), 56 | expirationTimeSeconds: ethers.BigNumber.from( 57 | String(Math.floor(Date.now() / 1000) + 3600) 58 | ) 59 | } 60 | const signedLeftMessage = await signOrder( 61 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 62 | makerOrder, 63 | exchangeContract.address 64 | ) 65 | 66 | const fillAmount = ethers.utils.parseEther('1') 67 | await expect( 68 | exchangeContract 69 | .connect(wallets[1]) 70 | .fillOrderExactOutput( 71 | Object.values(makerOrder), 72 | signedLeftMessage, 73 | fillAmount, 74 | false 75 | ) 76 | ).to.be.revertedWith('taker order not enough balance') 77 | }) 78 | 79 | it("Should revert with 'maker order not enough balance' ", async () => { 80 | const makerOrder = { 81 | user: wallets[0].address, 82 | sellToken: tokenA.address, 83 | buyToken: tokenB.address, 84 | sellAmount: ethers.utils.parseEther('15000'), 85 | buyAmount: ethers.utils.parseEther('1'), 86 | expirationTimeSeconds: ethers.BigNumber.from( 87 | String(Math.floor(Date.now() / 1000) + 3600) 88 | ) 89 | } 90 | const signedLeftMessage = await signOrder( 91 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 92 | makerOrder, 93 | exchangeContract.address 94 | ) 95 | 96 | const fillAmount = ethers.utils.parseEther('15000') 97 | await expect( 98 | exchangeContract 99 | .connect(wallets[1]) 100 | .fillOrderExactOutput( 101 | Object.values(makerOrder), 102 | signedLeftMessage, 103 | fillAmount, 104 | false 105 | ) 106 | ).to.be.revertedWith('maker order not enough balance') 107 | }) 108 | 109 | it("Should revert with 'maker order not enough allowance' ", async () => { 110 | const makerOrder = { 111 | user: wallets[0].address, 112 | sellToken: tokenA.address, 113 | buyToken: tokenB.address, 114 | sellAmount: ethers.utils.parseEther('100'), 115 | buyAmount: ethers.utils.parseEther('200'), 116 | expirationTimeSeconds: ethers.BigNumber.from( 117 | String(Math.floor(Date.now() / 1000) + 3600) 118 | ) 119 | } 120 | 121 | const signedLeftMessage = await signOrder( 122 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 123 | makerOrder, 124 | exchangeContract.address 125 | ) 126 | 127 | await tokenA.connect(wallets[0]).approve(exchangeContract.address, '0') 128 | 129 | const fillAmount = ethers.utils.parseEther('100') 130 | await expect( 131 | exchangeContract 132 | .connect(wallets[1]) 133 | .fillOrderExactOutput( 134 | Object.values(makerOrder), 135 | signedLeftMessage, 136 | fillAmount, 137 | false 138 | ) 139 | ).to.be.revertedWith('maker order not enough allowance') 140 | }) 141 | 142 | it("Should revert with 'taker order not enough allowance' ", async () => { 143 | const makerOrder = { 144 | user: wallets[0].address, 145 | sellToken: tokenA.address, 146 | buyToken: tokenB.address, 147 | sellAmount: ethers.utils.parseEther('100'), 148 | buyAmount: ethers.utils.parseEther('200'), 149 | expirationTimeSeconds: ethers.BigNumber.from( 150 | String(Math.floor(Date.now() / 1000) + 3600) 151 | ) 152 | } 153 | 154 | const signedLeftMessage = await signOrder( 155 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 156 | makerOrder, 157 | exchangeContract.address 158 | ) 159 | 160 | await tokenB.connect(wallets[1]).approve(exchangeContract.address, '0') 161 | 162 | const fillAmount = ethers.utils.parseEther('100') 163 | await expect( 164 | exchangeContract 165 | .connect(wallets[1]) 166 | .fillOrderExactOutput( 167 | Object.values(makerOrder), 168 | signedLeftMessage, 169 | fillAmount, 170 | false 171 | ) 172 | ).to.be.revertedWith('taker order not enough allowance') 173 | }) 174 | 175 | it('Should revert when maker order is already filled', async () => { 176 | const makerOrder = { 177 | user: wallets[0].address, 178 | sellToken: tokenA.address, 179 | buyToken: tokenB.address, 180 | sellAmount: ethers.BigNumber.from('120'), 181 | buyAmount: ethers.BigNumber.from('971'), 182 | expirationTimeSeconds: ethers.BigNumber.from( 183 | String(Math.floor(Date.now() / 1000) + 3600) 184 | ) 185 | } 186 | 187 | const signedLeftMessage = await signOrder( 188 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 189 | makerOrder, 190 | exchangeContract.address 191 | ) 192 | 193 | const fillAmount = ethers.utils.parseEther('120') 194 | await exchangeContract 195 | .connect(wallets[1]) 196 | .fillOrderExactOutput( 197 | Object.values(makerOrder), 198 | signedLeftMessage, 199 | fillAmount, 200 | true 201 | ) 202 | await expect( 203 | exchangeContract 204 | .connect(wallets[1]) 205 | .fillOrderExactOutput( 206 | Object.values(makerOrder), 207 | signedLeftMessage, 208 | fillAmount, 209 | false 210 | ) 211 | ).to.be.revertedWith('order is filled') 212 | }) 213 | 214 | it('Should revert when maker order is canceled', async () => { 215 | const makerOrder = { 216 | user: wallets[0].address, 217 | sellToken: tokenA.address, 218 | buyToken: tokenB.address, 219 | sellAmount: ethers.BigNumber.from('120'), 220 | buyAmount: ethers.BigNumber.from('970'), 221 | expirationTimeSeconds: ethers.BigNumber.from( 222 | String(Math.floor(Date.now() / 1000) + 3600) 223 | ) 224 | } 225 | 226 | const signedLeftMessage = await signOrder( 227 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 228 | makerOrder, 229 | exchangeContract.address 230 | ) 231 | 232 | const fillAmount = ethers.utils.parseEther('1') 233 | await exchangeContract 234 | .connect(wallets[0]) 235 | .cancelOrder(Object.values(makerOrder)) 236 | await expect( 237 | exchangeContract 238 | .connect(wallets[1]) 239 | .fillOrderExactOutput( 240 | Object.values(makerOrder), 241 | signedLeftMessage, 242 | fillAmount, 243 | false 244 | ) 245 | ).to.be.revertedWith('order canceled') 246 | }) 247 | 248 | 249 | it('Should revert when maker order is expired', async () => { 250 | const makerOrder = { 251 | user: wallets[0].address, 252 | sellToken: tokenA.address, 253 | buyToken: tokenB.address, 254 | sellAmount: ethers.BigNumber.from('120'), 255 | buyAmount: ethers.BigNumber.from('970'), 256 | expirationTimeSeconds: ethers.BigNumber.from('100') 257 | } 258 | 259 | const fillAmount = ethers.utils.parseEther('1') 260 | const signedLeftMessage = await signOrder( 261 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 262 | makerOrder, 263 | exchangeContract.address 264 | ) 265 | await expect( 266 | exchangeContract 267 | .connect(wallets[1]) 268 | .fillOrderExactOutput( 269 | Object.values(makerOrder), 270 | signedLeftMessage, 271 | fillAmount, 272 | false 273 | ) 274 | ).to.be.revertedWith('order expired') 275 | }) 276 | 277 | it('should fail when filled twice', async () => { 278 | const makerOrder = { 279 | user: wallets[0].address, 280 | sellToken: tokenA.address, 281 | buyToken: tokenB.address, 282 | sellAmount: ethers.utils.parseEther('100'), 283 | buyAmount: ethers.utils.parseEther('200'), 284 | expirationTimeSeconds: ethers.BigNumber.from( 285 | String(Math.floor(Date.now() / 1000) + 3600) 286 | ) 287 | } 288 | 289 | const signedLeftMessage = await signOrder( 290 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 291 | makerOrder, 292 | exchangeContract.address 293 | ) 294 | 295 | const fillAmount = ethers.utils.parseEther('100') 296 | const tx = await exchangeContract 297 | .connect(wallets[1]) 298 | .fillOrderExactOutput( 299 | Object.values(makerOrder), 300 | signedLeftMessage, 301 | fillAmount, 302 | true 303 | ) 304 | await expect( 305 | exchangeContract 306 | .connect(wallets[1]) 307 | .fillOrderExactOutput( 308 | Object.values(makerOrder), 309 | signedLeftMessage, 310 | fillAmount, 311 | false 312 | ) 313 | ).to.be.revertedWith('order is filled') 314 | }) 315 | 316 | it('fill a full order', async () => { 317 | const makerOrder = { 318 | user: wallets[0].address, 319 | sellToken: tokenA.address, 320 | buyToken: tokenB.address, 321 | sellAmount: ethers.utils.parseEther('100'), 322 | buyAmount: ethers.utils.parseEther('200'), 323 | expirationTimeSeconds: ethers.BigNumber.from( 324 | String(Math.floor(Date.now() / 1000) + 3600) 325 | ) 326 | } 327 | 328 | const signedLeftMessage = await signOrder( 329 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 330 | makerOrder, 331 | exchangeContract.address 332 | ) 333 | 334 | const fillAmount = ethers.utils.parseEther('50') 335 | await exchangeContract 336 | .connect(wallets[1]) 337 | .fillOrderExactOutput( 338 | Object.values(makerOrder), 339 | signedLeftMessage, 340 | fillAmount, 341 | true 342 | ) 343 | 344 | const balance1 = await tokenA.balanceOf(wallets[0].address) 345 | const balance2 = await tokenA.balanceOf(wallets[1].address) 346 | const balance3 = await tokenA.balanceOf(wallets[2].address) 347 | const balance4 = await tokenB.balanceOf(wallets[0].address) 348 | const balance5 = await tokenB.balanceOf(wallets[1].address) 349 | const balance6 = await tokenB.balanceOf(wallets[2].address) 350 | console.log( 351 | ethers.utils.formatEther(balance1), 352 | ethers.utils.formatEther(balance4) 353 | ) 354 | console.log( 355 | ethers.utils.formatEther(balance2), 356 | ethers.utils.formatEther(balance5) 357 | ) 358 | console.log( 359 | ethers.utils.formatEther(balance3), 360 | ethers.utils.formatEther(balance6) 361 | ) 362 | 363 | expect(balance2).to.equal(ethers.utils.parseEther('50')) 364 | expect(balance4).to.equal(ethers.utils.parseEther('100')) 365 | }) 366 | 367 | it('should fail without fillAvailable when over-ordering', async () => { 368 | const makerOrder = { 369 | user: wallets[0].address, 370 | sellToken: tokenA.address, 371 | buyToken: tokenB.address, 372 | sellAmount: ethers.utils.parseEther('100'), 373 | buyAmount: ethers.utils.parseEther('200'), 374 | expirationTimeSeconds: ethers.BigNumber.from( 375 | String(Math.floor(Date.now() / 1000) + 3600) 376 | ) 377 | } 378 | 379 | const signedLeftMessage = await signOrder( 380 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 381 | makerOrder, 382 | exchangeContract.address 383 | ) 384 | 385 | const fillAmount = ethers.utils.parseEther('90') 386 | await exchangeContract 387 | .connect(wallets[1]) 388 | .fillOrderExactOutput( 389 | Object.values(makerOrder), 390 | signedLeftMessage, 391 | fillAmount, 392 | true 393 | ) 394 | const tx2 = exchangeContract 395 | .connect(wallets[1]) 396 | .fillOrderExactOutput( 397 | Object.values(makerOrder), 398 | signedLeftMessage, 399 | fillAmount, 400 | false 401 | ) 402 | await expect(tx2).to.be.revertedWith('amount exceeds available size') 403 | }) 404 | 405 | it('should not fail with fillAvailable when over-ordering', async () => { 406 | const makerOrder = { 407 | user: wallets[0].address, 408 | sellToken: tokenA.address, 409 | buyToken: tokenB.address, 410 | sellAmount: ethers.utils.parseEther('100'), 411 | buyAmount: ethers.utils.parseEther('200'), 412 | expirationTimeSeconds: ethers.BigNumber.from( 413 | String(Math.floor(Date.now() / 1000) + 3600) 414 | ) 415 | } 416 | 417 | const signedLeftMessage = await signOrder( 418 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 419 | makerOrder, 420 | exchangeContract.address 421 | ) 422 | 423 | const fillAmount = ethers.utils.parseEther('90') 424 | await exchangeContract 425 | .connect(wallets[1]) 426 | .fillOrderExactOutput( 427 | Object.values(makerOrder), 428 | signedLeftMessage, 429 | fillAmount, 430 | true 431 | ) 432 | await exchangeContract 433 | .connect(wallets[1]) 434 | .fillOrderExactOutput( 435 | Object.values(makerOrder), 436 | signedLeftMessage, 437 | fillAmount, 438 | true 439 | ) 440 | 441 | const balance1 = await tokenA.balanceOf(wallets[0].address) 442 | const balance2 = await tokenA.balanceOf(wallets[1].address) 443 | const balance3 = await tokenA.balanceOf(wallets[2].address) 444 | const balance4 = await tokenB.balanceOf(wallets[0].address) 445 | const balance5 = await tokenB.balanceOf(wallets[1].address) 446 | const balance6 = await tokenB.balanceOf(wallets[2].address) 447 | console.log( 448 | ethers.utils.formatEther(balance1), 449 | ethers.utils.formatEther(balance4) 450 | ) 451 | console.log( 452 | ethers.utils.formatEther(balance2), 453 | ethers.utils.formatEther(balance5) 454 | ) 455 | console.log( 456 | ethers.utils.formatEther(balance3), 457 | ethers.utils.formatEther(balance6) 458 | ) 459 | 460 | expect(balance2).to.equal(ethers.utils.parseEther('100')) 461 | expect(balance4).to.equal(ethers.utils.parseEther('200')) 462 | }) 463 | 464 | it('Should emit events for a partial order', async () => { 465 | const makerOrder = { 466 | user: wallets[0].address, 467 | sellToken: tokenA.address, 468 | buyToken: tokenB.address, 469 | sellAmount: ethers.utils.parseEther('100'), 470 | buyAmount: ethers.utils.parseEther('200'), 471 | expirationTimeSeconds: ethers.BigNumber.from( 472 | String(Math.floor(Date.now() / 1000) + 3600) 473 | ) 474 | } 475 | 476 | const signedLeftMessage = await signOrder( 477 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 478 | makerOrder, 479 | exchangeContract.address 480 | ) 481 | const orderHash = await getOrderHash(makerOrder, exchangeContract.address) 482 | 483 | const fillAmount = ethers.utils.parseEther('50') 484 | 485 | expect( 486 | await exchangeContract 487 | .connect(wallets[1]) 488 | .fillOrderExactOutput( 489 | Object.values(makerOrder), 490 | signedLeftMessage, 491 | fillAmount, 492 | false 493 | ) 494 | ) 495 | .to.emit(exchangeContract, 'Swap') 496 | .withArgs( 497 | wallets[0].address, 498 | wallets[1].address, 499 | tokenA.address, 500 | tokenB.address, 501 | ethers.utils.parseEther('50'), 502 | ethers.utils.parseEther('100'), 503 | ethers.utils.parseEther('0'), 504 | ethers.utils.parseEther('0.0075') 505 | ) 506 | .to.emit(exchangeContract, 'OrderStatus') 507 | .withArgs( 508 | orderHash, 509 | ethers.utils.parseEther('100'), 510 | ethers.utils.parseEther('50') 511 | ) 512 | }) 513 | 514 | it('Should emit events for a full order', async () => { 515 | const makerOrder = { 516 | user: wallets[0].address, 517 | sellToken: tokenA.address, 518 | buyToken: tokenB.address, 519 | sellAmount: ethers.utils.parseEther('100'), 520 | buyAmount: ethers.utils.parseEther('200'), 521 | expirationTimeSeconds: ethers.BigNumber.from( 522 | String(Math.floor(Date.now() / 1000) + 3600) 523 | ) 524 | } 525 | 526 | const signedLeftMessage = await signOrder( 527 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 528 | makerOrder, 529 | exchangeContract.address 530 | ) 531 | const orderHash = await getOrderHash(makerOrder, exchangeContract.address) 532 | 533 | const fillAmount = ethers.utils.parseEther('100') 534 | 535 | expect( 536 | await exchangeContract 537 | .connect(wallets[1]) 538 | .fillOrderExactOutput( 539 | Object.values(makerOrder), 540 | signedLeftMessage, 541 | fillAmount, 542 | false 543 | ) 544 | ) 545 | .to.emit(exchangeContract, 'Swap') 546 | .withArgs( 547 | wallets[0].address, 548 | wallets[1].address, 549 | tokenA.address, 550 | tokenB.address, 551 | ethers.utils.parseEther('100'), 552 | ethers.utils.parseEther('200'), 553 | ethers.utils.parseEther('0'), 554 | ethers.utils.parseEther('0.015') 555 | ) 556 | .to.emit(exchangeContract, 'OrderStatus') 557 | .withArgs( 558 | orderHash, 559 | ethers.utils.parseEther('200'), 560 | ethers.constants.Zero 561 | ) 562 | }) 563 | }) 564 | -------------------------------------------------------------------------------- /evm_contracts/test/Signature.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { ethers } from 'hardhat' 3 | import { expect } from 'chai' 4 | import { Contract, Wallet } from 'ethers' 5 | import { TESTRPC_PRIVATE_KEYS_STRINGS } from './utils/PrivateKeyList' 6 | import { signOrder, getOrderHash } from './utils/SignUtil' 7 | import { Order } from './utils/types' 8 | 9 | describe('Signature Validation', () => { 10 | let exchangeContract: Contract 11 | let wallet: Wallet 12 | let order: Order 13 | 14 | beforeEach(async function _ () { 15 | this.timeout(30000) 16 | 17 | const Exchange = await ethers.getContractFactory('ZigZagExchange') 18 | exchangeContract = await Exchange.deploy( 19 | 'ZigZag', 20 | '2.1', 21 | ethers.constants.AddressZero 22 | ) 23 | 24 | wallet = new ethers.Wallet( 25 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 26 | ethers.provider 27 | ) 28 | 29 | order = { 30 | user: wallet.address, 31 | sellToken: '0x90d4ffBf13bF3203940E6DAcE392F7C23ff6b9Ed', 32 | buyToken: '0x90d4ffBf13bF3203940E6DAcE392F7C23ff6b9Ed', 33 | sellAmount: ethers.BigNumber.from('12'), 34 | buyAmount: ethers.BigNumber.from('13'), 35 | expirationTimeSeconds: ethers.BigNumber.from('0'), 36 | } 37 | }) 38 | 39 | it('Should validate order signature', async () => { 40 | const signedMessage = await signOrder( 41 | TESTRPC_PRIVATE_KEYS_STRINGS[0], 42 | order, 43 | exchangeContract.address 44 | ) 45 | 46 | expect( 47 | await exchangeContract.isValidOrderSignature( 48 | Object.values(order), 49 | signedMessage 50 | ) 51 | ).to.equal(true) 52 | }) 53 | 54 | it("Shouldn't validate order signature with different Private Key", async () => { 55 | const incorrenctlySignedMessage = await signOrder( 56 | TESTRPC_PRIVATE_KEYS_STRINGS[1], 57 | order, 58 | exchangeContract.address 59 | ) 60 | expect( 61 | await exchangeContract.isValidOrderSignature( 62 | Object.values(order), 63 | incorrenctlySignedMessage 64 | ) 65 | ).to.equal(false) 66 | }) 67 | 68 | it("Should emit event cancel order", async () => { 69 | const orderHash = await getOrderHash(order, exchangeContract.address) 70 | 71 | expect(await exchangeContract.connect(wallet).cancelOrder( 72 | Object.values(order) 73 | )).to.emit(exchangeContract, 'CancelOrder') 74 | .withArgs(orderHash) 75 | }) 76 | 77 | }) 78 | -------------------------------------------------------------------------------- /evm_contracts/test/Vault.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "hardhat"; 2 | import { expect } from "chai"; 3 | import { TESTRPC_PRIVATE_KEYS_STRINGS } from "./utils/PrivateKeyList" 4 | import { signOrder, signCancelOrder } from "./utils/SignUtil" 5 | import { Contract, Wallet } from "ethers"; 6 | 7 | describe("Vault", function () { 8 | 9 | let exchangeContract: Contract; 10 | let vaultContract: Contract; 11 | let tokenA: Contract; 12 | let tokenB: Contract; 13 | let wallets: Wallet[] = []; 14 | let manager: any; 15 | 16 | beforeEach(async function () { 17 | this.timeout(30000) 18 | const Exchange = await ethers.getContractFactory("ZigZagExchange"); 19 | const Vault = await ethers.getContractFactory("ZigZagVault"); 20 | const Token = await ethers.getContractFactory("Token"); 21 | const provider = ethers.provider; 22 | 23 | tokenA = await Token.deploy(); 24 | tokenB = await Token.deploy(); 25 | let [owner] = await ethers.getSigners(); 26 | 27 | for (let i = 0; i < 4; i++) { 28 | wallets[i] = new ethers.Wallet(TESTRPC_PRIVATE_KEYS_STRINGS[i], provider) 29 | 30 | await owner.sendTransaction({ 31 | to: wallets[i].address, 32 | value: ethers.utils.parseEther("0.1") // 0.1 ether 33 | }) 34 | } 35 | 36 | manager = wallets[2]; 37 | exchangeContract = await Exchange.deploy("ZigZag", "2.1", ethers.constants.AddressZero); 38 | vaultContract = await Vault.deploy(manager.address, "ZigZag LP 1", "ZZLP1"); 39 | 40 | await tokenA.mint(ethers.utils.parseEther("10000"), wallets[0].address); 41 | await tokenB.mint(ethers.utils.parseEther("10000"), vaultContract.address); 42 | await tokenA.connect(wallets[0]).approve(exchangeContract.address, ethers.utils.parseEther("10000")); 43 | await vaultContract.connect(manager).approveToken(tokenB.address, exchangeContract.address, ethers.utils.parseEther("10000")); 44 | 45 | 46 | }); 47 | 48 | it("Should allow manager to sign orders", async function () { 49 | const makerOrder = { 50 | user: vaultContract.address, 51 | sellToken: tokenB.address, 52 | buyToken: tokenA.address, 53 | sellAmount: ethers.utils.parseEther("1"), 54 | buyAmount: ethers.utils.parseEther("100"), 55 | expirationTimeSeconds: ethers.BigNumber.from(String(Math.floor(Date.now() / 1000) + 3600)) 56 | } 57 | const signedLeftMessage = await signOrder(TESTRPC_PRIVATE_KEYS_STRINGS[2], makerOrder, exchangeContract.address) 58 | 59 | const fillAmount = ethers.utils.parseEther("0.5"); 60 | await exchangeContract.connect(wallets[0]).fillOrderExactOutput(Object.values(makerOrder), signedLeftMessage, fillAmount, true) 61 | }); 62 | 63 | it("Non-manager cannot sign orders", async function () { 64 | const makerOrder = { 65 | user: vaultContract.address, 66 | sellToken: tokenB.address, 67 | buyToken: tokenA.address, 68 | sellAmount: ethers.utils.parseEther("1"), 69 | buyAmount: ethers.utils.parseEther("100"), 70 | expirationTimeSeconds: ethers.BigNumber.from(String(Math.floor(Date.now() / 1000) + 3600)) 71 | } 72 | const signedLeftMessage = await signOrder(TESTRPC_PRIVATE_KEYS_STRINGS[1], makerOrder, exchangeContract.address) 73 | 74 | const fillAmount = ethers.utils.parseEther("0.5"); 75 | await expect(exchangeContract.connect(wallets[0]).fillOrderExactOutput(Object.values(makerOrder), signedLeftMessage, fillAmount, false)).to.be.revertedWith("invalid maker signature"); 76 | }); 77 | 78 | it("Non-manager cannot sign limit orders", async function () { 79 | const makerOrder = { 80 | user: vaultContract.address, 81 | sellToken: tokenB.address, 82 | buyToken: tokenA.address, 83 | sellAmount: ethers.utils.parseEther("1"), 84 | buyAmount: ethers.utils.parseEther("100"), 85 | expirationTimeSeconds: ethers.BigNumber.from(String(Math.floor(Date.now() / 1000) + 3600)) 86 | } 87 | const signedLeftMessage = await signOrder(TESTRPC_PRIVATE_KEYS_STRINGS[1], makerOrder, exchangeContract.address) 88 | 89 | const fillAmount = ethers.utils.parseEther("0.5"); 90 | await expect(exchangeContract.connect(wallets[0]).fillOrderExactOutput(Object.values(makerOrder), signedLeftMessage, fillAmount, false)) 91 | .to.be.revertedWith("invalid maker signature"); 92 | }); 93 | 94 | it("Vault limit order", async function () { 95 | const makerOrder = { 96 | user: vaultContract.address, 97 | sellToken: tokenB.address, 98 | buyToken: tokenA.address, 99 | sellAmount: ethers.utils.parseEther("1"), 100 | buyAmount: ethers.utils.parseEther("100"), 101 | expirationTimeSeconds: ethers.BigNumber.from(String(Math.floor(Date.now() / 1000) + 3600)) 102 | } 103 | const signedLeftMessage = await signOrder(TESTRPC_PRIVATE_KEYS_STRINGS[2], makerOrder, exchangeContract.address) 104 | 105 | const fillAmount = ethers.utils.parseEther("0.5"); 106 | await exchangeContract.connect(wallets[0]).fillOrderExactOutput(Object.values(makerOrder), signedLeftMessage, fillAmount, false); 107 | }); 108 | 109 | it("Vault mint LP tokens", async function () { 110 | await vaultContract.connect(wallets[2]).mintLPToken(ethers.utils.parseEther("100")); 111 | }); 112 | 113 | it("Non-manager cannot mint LP tokens", async function () { 114 | await expect(vaultContract.connect(wallets[1]).mintLPToken(ethers.utils.parseEther("100"))).to.be.revertedWith("only manager can mint LP tokens"); 115 | }); 116 | 117 | it("Vault mint and burn LP tokens", async function () { 118 | await vaultContract.connect(wallets[2]).mintLPToken(ethers.utils.parseEther("100")); 119 | await vaultContract.connect(wallets[2]).burnLPToken(ethers.utils.parseEther("100")); 120 | }); 121 | 122 | it("Non-manager cannot burn LP tokens", async function () { 123 | await vaultContract.connect(wallets[2]).mintLPToken(ethers.utils.parseEther("100")); 124 | await expect(vaultContract.connect(wallets[1]).burnLPToken(ethers.utils.parseEther("100"))).to.be.revertedWith("only manager can burn LP tokens"); 125 | }); 126 | 127 | it("Cannot burn more than vault balance", async function () { 128 | await vaultContract.connect(wallets[2]).mintLPToken(ethers.utils.parseEther("100")); 129 | await expect(vaultContract.connect(wallets[2]).burnLPToken(ethers.utils.parseEther("200"))).to.be.reverted 130 | }); 131 | 132 | it("Mint and swap LP tokens", async function () { 133 | await vaultContract.connect(wallets[2]).mintLPToken(ethers.utils.parseEther("100")); 134 | await vaultContract.connect(wallets[2]).approveToken(vaultContract.address, exchangeContract.address, ethers.utils.parseEther("100")); 135 | 136 | const makerOrder = { 137 | user: vaultContract.address, 138 | sellToken: vaultContract.address, 139 | buyToken: tokenA.address, 140 | sellAmount: ethers.utils.parseEther("1"), 141 | buyAmount: ethers.utils.parseEther("100"), 142 | expirationTimeSeconds: ethers.BigNumber.from(String(Math.floor(Date.now() / 1000) + 3600)) 143 | } 144 | const signedLeftMessage = await signOrder(TESTRPC_PRIVATE_KEYS_STRINGS[2], makerOrder, exchangeContract.address) 145 | 146 | const fillAmount = ethers.utils.parseEther("0.5"); 147 | await exchangeContract.connect(wallets[0]).fillOrderExactOutput(Object.values(makerOrder), signedLeftMessage, fillAmount, false) 148 | }); 149 | 150 | it("Cannot burn after mint and swap LP tokens", async function () { 151 | await vaultContract.connect(wallets[2]).mintLPToken(ethers.utils.parseEther("100")); 152 | await vaultContract.connect(wallets[2]).approveToken(vaultContract.address, exchangeContract.address, ethers.utils.parseEther("100")); 153 | 154 | const makerOrder = { 155 | user: vaultContract.address, 156 | sellToken: vaultContract.address, 157 | buyToken: tokenA.address, 158 | sellAmount: ethers.utils.parseEther("1"), 159 | buyAmount: ethers.utils.parseEther("100"), 160 | expirationTimeSeconds: ethers.BigNumber.from(String(Math.floor(Date.now() / 1000) + 3600)) 161 | } 162 | const signedLeftMessage = await signOrder(TESTRPC_PRIVATE_KEYS_STRINGS[2], makerOrder, exchangeContract.address) 163 | 164 | const fillAmount = ethers.utils.parseEther("0.5"); 165 | await exchangeContract.connect(wallets[0]).fillOrderExactOutput(Object.values(makerOrder), signedLeftMessage, fillAmount, false) 166 | 167 | await expect(vaultContract.connect(wallets[2]).burnLPToken(ethers.utils.parseEther("100"))).to.be.reverted 168 | }); 169 | 170 | it("Non-manager cannot approve tokens", async function () { 171 | await expect(vaultContract.connect(wallets[1]).approveToken(tokenA.address, exchangeContract.address, ethers.utils.parseEther("100"))).to.be.revertedWith("only manager can approve tokens"); 172 | }); 173 | 174 | it("Update manager", async function () { 175 | await vaultContract.connect(wallets[2]).updateManager(wallets[1].address); 176 | await vaultContract.connect(wallets[1]).approveToken(vaultContract.address, exchangeContract.address, ethers.utils.parseEther("100")); 177 | }); 178 | 179 | it("Non-manager cannot update manager", async function () { 180 | await expect(vaultContract.connect(wallets[1]).updateManager(wallets[1].address)).to.be.revertedWith("only manager can update manager"); 181 | }); 182 | 183 | it("Minting LP tokens doesn't affect circulating supply", async function () { 184 | const mintAmount = ethers.utils.parseEther("100"); 185 | await vaultContract.connect(wallets[2]).mintLPToken(mintAmount); 186 | 187 | const circulatingSupply = await vaultContract.circulatingSupply(); 188 | const totalSupply = await vaultContract.totalSupply(); 189 | await expect(totalSupply).to.equal(mintAmount); 190 | await expect(circulatingSupply).to.equal("0"); 191 | }); 192 | 193 | it("Mint and swapping LP tokens affects circulating supply", async function () { 194 | await vaultContract.connect(wallets[2]).mintLPToken(ethers.utils.parseEther("100")); 195 | await vaultContract.connect(wallets[2]).approveToken(vaultContract.address, exchangeContract.address, ethers.utils.parseEther("100")); 196 | 197 | const makerOrder = { 198 | user: vaultContract.address, 199 | sellToken: vaultContract.address, 200 | buyToken: tokenA.address, 201 | sellAmount: ethers.utils.parseEther("1"), 202 | buyAmount: ethers.utils.parseEther("100"), 203 | expirationTimeSeconds: ethers.BigNumber.from(String(Math.floor(Date.now() / 1000) + 3600)) 204 | } 205 | const signedLeftMessage = await signOrder(TESTRPC_PRIVATE_KEYS_STRINGS[2], makerOrder, exchangeContract.address) 206 | 207 | const fillAmount = ethers.utils.parseEther("0.5"); 208 | await exchangeContract.connect(wallets[0]).fillOrderExactOutput(Object.values(makerOrder), signedLeftMessage, fillAmount, false) 209 | 210 | const circulatingSupply = await vaultContract.circulatingSupply(); 211 | const totalSupply = await vaultContract.totalSupply(); 212 | await expect(totalSupply).to.equal(ethers.utils.parseEther("100")); 213 | await expect(circulatingSupply).to.equal(ethers.utils.parseEther("0.5")); // user amount and fees 214 | }); 215 | 216 | it("Vault cancel order", async function () { 217 | 218 | const makerOrder = { 219 | user: vaultContract.address, 220 | sellToken: tokenA.address, 221 | buyToken: tokenB.address, 222 | sellAmount: ethers.BigNumber.from("120"), 223 | buyAmount: ethers.BigNumber.from("970"), 224 | expirationTimeSeconds: ethers.BigNumber.from(String(Math.floor(Date.now() / 1000) + 3600)) 225 | } 226 | 227 | await vaultContract.connect(manager).cancelOrder(exchangeContract.address, Object.values(makerOrder)); 228 | }); 229 | 230 | it("Bad cancel signature should revert", async function () { 231 | const makerOrder = { 232 | user: vaultContract.address, 233 | sellToken: tokenA.address, 234 | buyToken: tokenB.address, 235 | sellAmount: ethers.BigNumber.from("120"), 236 | buyAmount: ethers.BigNumber.from("970"), 237 | expirationTimeSeconds: ethers.BigNumber.from(String(Math.floor(Date.now() / 1000) + 3600)) 238 | } 239 | 240 | const signedCancelOrder = await signCancelOrder(TESTRPC_PRIVATE_KEYS_STRINGS[0], makerOrder, exchangeContract.address) 241 | 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /evm_contracts/test/utils/PrivateKeyList.ts: -------------------------------------------------------------------------------- 1 | export const TESTRPC_PRIVATE_KEYS_STRINGS = [ 2 | '0xf2f48ee19680706196e2e339e5da3491186e0c4c5030670656b0e0164837257d', 3 | '0x5d862464fe9303452126c8bc94274b8c5f9874cbd219789b3eb2128075a76f72', 4 | '0xdf02719c4df8b9b8ac7f551fcb5d9ef48fa27eef7a66453879f4d8fdc6e78fb1', 5 | '0xff12e391b79415e941a94de3bf3a9aee577aed0731e297d5cfa0b8a1e02fa1d0', 6 | '0x752dd9cf65e68cfaba7d60225cbdbc1f4729dd5e5507def72815ed0d8abc6249', 7 | '0xefb595a0178eb79a8df953f87c5148402a224cdf725e88c0146727c6aceadccd', 8 | '0x83c6d2cc5ddcf9711a6d59b417dc20eb48afd58d45290099e5987e3d768f328f', 9 | '0xbb2d3f7c9583780a7d3904a2f55d792707c345f21de1bacb2d389934d82796b2', 10 | '0xb2fd4d29c1390b71b8795ae81196bfd60293adf99f9d32a0aff06288fcdac55f', 11 | '0x23cb7121166b9a2f93ae0b7c05bde02eae50d64449b2cbb42bc84e9d38d6cc89', 12 | '0x5ad34d7f8704ed33ab9e8dc30a76a8c48060649204c1f7b21b973235bba8092f', 13 | '0xf18b03c1ae8e3876d76f20c7a5127a169dd6108c55fe9ce78bc7a91aca67dee3', 14 | '0x4ccc4e7d7843e0701295e8fd671332a0e2f1e92d0dab16e8792e91cb0b719c9d', 15 | '0xd7638ae813450e710e6f1b09921cc1593181073ce2099fb418fc03a933c7f41f', 16 | '0xbc7bbca8ca15eb567be60df82e4452b13072dcb60db89747e3c85df63d8270ca', 17 | '0x55131517839bf782e6e573bc3ac8f262efd2b6cb0ac86e8f147db26fcbdb15a5', 18 | '0x6c2b5a16e327e0c4e7fafca5ae35616141de81f77da66ee0857bc3101d446e68', 19 | '0xfd79b71625eec963e6ec42e9b5b10602c938dfec29cbbc7d17a492dd4f403859', 20 | '0x3003eace3d4997c52ba69c2ca97a6b5d0d1216d894035a97071590ee284c1023', 21 | '0x84a8bb71450a1b82be2b1cdd25d079cbf23dc8054e94c47ad14510aa967f45de', 22 | ]; -------------------------------------------------------------------------------- /evm_contracts/test/utils/SignUtil.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { BigNumber, ethers } from 'ethers' 3 | import { Order } from './types' 4 | 5 | export async function getOrderHash(order: Order, exchangeAddress: string) { 6 | return ethers.utils._TypedDataEncoder.hash( 7 | { 8 | name: 'ZigZag', 9 | version: '2.1', 10 | chainId: '31337', // test hardhat default 11 | verifyingContract: exchangeAddress, 12 | }, 13 | { 14 | Order: [ 15 | { name: 'user', type: 'address' }, 16 | { name: 'sellToken', type: 'address' }, 17 | { name: 'buyToken', type: 'address' }, 18 | { name: 'sellAmount', type: 'uint256' }, 19 | { name: 'buyAmount', type: 'uint256' }, 20 | { name: 'expirationTimeSeconds', type: 'uint256' }, 21 | ] 22 | }, 23 | { 24 | user: order.user, 25 | sellToken: order.sellToken, 26 | buyToken: order.buyToken, 27 | sellAmount: order.sellAmount, 28 | buyAmount: order.buyAmount, 29 | expirationTimeSeconds: order.expirationTimeSeconds, 30 | } 31 | ) 32 | } 33 | 34 | export async function signOrder( 35 | privateKey: string, 36 | order: Order, 37 | exchangeAddress: string 38 | ) { 39 | const provider = ethers.getDefaultProvider() 40 | const wallet = new ethers.Wallet(privateKey, provider) 41 | 42 | const typedData = { 43 | types: { 44 | EIP712Domain: [ 45 | { name: 'name', type: 'string' }, 46 | { name: 'version', type: 'string' }, 47 | { name: 'chainId', type: 'uint256' }, 48 | { name: 'verifyingContract', type: 'address' }, 49 | ], 50 | Order: [ 51 | { name: 'user', type: 'address' }, 52 | { name: 'sellToken', type: 'address' }, 53 | { name: 'buyToken', type: 'address' }, 54 | { name: 'sellAmount', type: 'uint256' }, 55 | { name: 'buyAmount', type: 'uint256' }, 56 | { name: 'expirationTimeSeconds', type: 'uint256' }, 57 | ], 58 | }, 59 | primaryType: 'Order', 60 | domain: { 61 | name: 'ZigZag', 62 | version: '2.1', 63 | chainId: '31337', // test hardhat default 64 | verifyingContract: exchangeAddress, 65 | }, 66 | message: { 67 | user: order.user, 68 | sellToken: order.sellToken, 69 | buyToken: order.buyToken, 70 | sellAmount: order.sellAmount, 71 | buyAmount: order.buyAmount, 72 | expirationTimeSeconds: order.expirationTimeSeconds, 73 | }, 74 | } 75 | 76 | // eslint-disable-next-line no-underscore-dangle 77 | const signature = await wallet._signTypedData( 78 | typedData.domain, 79 | { Order: typedData.types.Order }, 80 | typedData.message 81 | ) 82 | 83 | return signature 84 | } 85 | 86 | export async function signReq( 87 | wallet: any, 88 | req: { 89 | from: string 90 | to: string 91 | value: BigNumber 92 | gas: BigNumber 93 | nonce: BigNumber 94 | data: string 95 | }, 96 | forwarderAddress: string 97 | ) { 98 | const typedData = { 99 | types: { 100 | EIP712Domain: [ 101 | { name: 'name', type: 'string' }, 102 | { name: 'version', type: 'string' }, 103 | { name: 'chainId', type: 'uint256' }, 104 | { name: 'verifyingContract', type: 'address' } 105 | ], 106 | ForwardRequest: [ 107 | { name: 'from', type: 'address' }, 108 | { name: 'to', type: 'address' }, 109 | { name: 'value', type: 'uint256' }, 110 | { name: 'gas', type: 'uint256' }, 111 | { name: 'nonce', type: 'uint256' }, 112 | { name: 'data', type: 'bytes' } 113 | ] 114 | }, 115 | primaryType: 'ForwardRequest', 116 | domain: { 117 | name: 'MinimalForwarder', 118 | version: '0.0.1', 119 | chainId: '31337', // test hardhat default 120 | verifyingContract: forwarderAddress 121 | }, 122 | message: { 123 | from: req.from, 124 | to: req.to, 125 | value: req.value, 126 | gas: req.gas, 127 | nonce: req.nonce, 128 | data: req.data 129 | } 130 | } 131 | 132 | // eslint-disable-next-line no-underscore-dangle 133 | const signature = await wallet._signTypedData(typedData.domain, { ForwardRequest: typedData.types.ForwardRequest }, typedData.message) 134 | 135 | return signature 136 | } 137 | 138 | export async function signCancelOrder( 139 | privateKey: string, 140 | order: Order, 141 | exchangeAddress: string 142 | ) { 143 | const provider = ethers.getDefaultProvider() 144 | const wallet = new ethers.Wallet(privateKey, provider) 145 | 146 | const types = { 147 | Order: [ 148 | { name: 'user', type: 'address' }, 149 | { name: 'sellToken', type: 'address' }, 150 | { name: 'buyToken', type: 'address' }, 151 | { name: 'sellAmount', type: 'uint256' }, 152 | { name: 'buyAmount', type: 'uint256' }, 153 | { name: 'expirationTimeSeconds', type: 'uint256' }, 154 | ], 155 | CancelOrder: [ 156 | { name: 'orderHash', type: 'bytes32' }, 157 | ] 158 | } 159 | const domain = { 160 | name: 'ZigZag', 161 | version: '2.1', 162 | chainId: '31337', // test hardhat default 163 | verifyingContract: exchangeAddress, 164 | } 165 | const orderMessage = { 166 | user: order.user, 167 | sellToken: order.sellToken, 168 | buyToken: order.buyToken, 169 | sellAmount: order.sellAmount, 170 | buyAmount: order.buyAmount, 171 | expirationTimeSeconds: order.expirationTimeSeconds, 172 | } 173 | 174 | // eslint-disable-next-line no-underscore-dangle 175 | const orderHash = ethers.utils._TypedDataEncoder.from({ Order: types.Order }).hash(orderMessage) 176 | 177 | const cancelOrderMessage = { 178 | orderHash 179 | } 180 | 181 | // eslint-disable-next-line no-underscore-dangle 182 | const signature = await wallet._signTypedData( 183 | domain, 184 | { CancelOrder: types.CancelOrder }, 185 | cancelOrderMessage 186 | ) 187 | 188 | return signature 189 | } 190 | -------------------------------------------------------------------------------- /evm_contracts/test/utils/types.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from 'ethers' 2 | 3 | export interface Order { 4 | user: string 5 | sellToken: string 6 | buyToken: string 7 | sellAmount: BigNumber 8 | buyAmount: BigNumber 9 | expirationTimeSeconds: BigNumber 10 | } 11 | -------------------------------------------------------------------------------- /evm_contracts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "outDir": "dist" 8 | }, 9 | "include": ["./scripts", "./test"], 10 | "files": ["./hardhat.config.ts"] 11 | } -------------------------------------------------------------------------------- /examples/simple_swap.js: -------------------------------------------------------------------------------- 1 | import ethers from 'ethers'; 2 | import * as zksync from "zksync"; 3 | import WebSocket from 'ws'; 4 | 5 | /* 6 | * This is a simple example how to send a single swap with zigzag - use this as a starting point to build upon. 7 | * 8 | * In the settings below you can set market, amount, side and your private ethereum key. This is only for a single trade. 9 | * 10 | * The basic flow is: 11 | * 1. Subscribe to the market you want to trade on. 12 | * { "op":"subscribemarket", "args":[chainId,market] } 13 | * 14 | * 2. You will receive a ws message "op":"marketinfo". This has any needed details to your market. 15 | * Here is the README with the exact content: https://github.com/ZigZagExchange/markets/blob/master/README.md 16 | * 17 | * 3. Next, you want to ask for a quote: (only set baseQuantity or quoteQuantity) 18 | * { "op":"requestquote", "args": [chainid, market, side, baseQuantity, quoteQuantity] } 19 | * 20 | * 4. You will recive an ws message "op":"quote" It contains following args: [chainid, market, side, baseQuantity, price, quoteQuantity] 21 | * 22 | * 5. You can use that to build an zkSync order, see here: `async function sendOrder(quote)` 23 | * 24 | * 6. The last step is to send it like this: { "op":"submitorder2", "args": [chainId, market, zkOrder] } 25 | * 26 | * The last step returns an userorderack. You can use that to track the order. 27 | * Check out the README here to learn more: https://github.com/ZigZagExchange/backend/blob/master/README.md 28 | * 29 | */ 30 | 31 | 32 | //Settings 33 | const setting_zigzagChainId = 1; 34 | const setting_market = "ETH-USDC"; 35 | const setting_amount = 0.01; // in base amount 36 | const setting_side = 'b'; 37 | const ethereum_key = "xxxx"; 38 | 39 | // request the Quote after 5 sec to update MARKETS in time 40 | setTimeout( 41 | requestQuote, 42 | 5000 43 | ); 44 | 45 | 46 | // globals 47 | let ethWallet; 48 | let syncWallet; 49 | let account_state; 50 | let syncProvider; 51 | let MARKETS = {}; 52 | 53 | 54 | // Connect to zksync 55 | const ETH_NETWORK = (setting_zigzagChainId === 1) ? "mainnet" : "goerli"; 56 | const ethersProvider = ethers.getDefaultProvider(ETH_NETWORK); 57 | try { 58 | syncProvider = await zksync.getDefaultProvider(ETH_NETWORK); 59 | ethWallet = new ethers.Wallet(ethereum_key); 60 | syncWallet = await zksync.Wallet.fromEthSigner(ethWallet, syncProvider); 61 | if (!(await syncWallet.isSigningKeySet())) { 62 | console.log("setting sign key"); 63 | const signKeyResult = await syncWallet.setSigningKey({ 64 | feeToken: "ETH", 65 | ethAuthType: "ECDSA", 66 | }); 67 | console.log(signKeyResult); 68 | } 69 | account_state = await syncWallet.getAccountState(); 70 | } catch (e) { 71 | console.log(e); 72 | throw new Error("Could not connect to zksync API"); 73 | } 74 | 75 | const zigzagWsUrl = { 76 | 1: "wss://zigzag-exchange.herokuapp.com", 77 | 1000: "wss://secret-thicket-93345.herokuapp.com" 78 | } 79 | 80 | let zigzagws = new WebSocket(zigzagWsUrl[setting_zigzagChainId]); 81 | zigzagws.on( 82 | 'open', 83 | onWsOpen 84 | ); 85 | zigzagws.on( 86 | 'close', 87 | onWsClose 88 | ); 89 | zigzagws.on( 90 | 'error', 91 | console.error 92 | ); 93 | 94 | async function onWsOpen() { 95 | zigzagws.on( 96 | 'message', 97 | handleMessage 98 | ); 99 | zigzagws.send( 100 | JSON.stringify( 101 | { 102 | "op":"subscribemarket", 103 | "args":[setting_zigzagChainId, setting_market] 104 | } 105 | ) 106 | ); 107 | } 108 | 109 | function onWsClose () { 110 | console.log("Websocket closed.."); 111 | setTimeout(() => { 112 | console.log("..Restarting:") 113 | zigzagws = new WebSocket(zigzagWsUrl[setting_zigzagChainId]); 114 | zigzagws.on('open', onWsOpen); 115 | zigzagws.on('close', onWsClose); 116 | zigzagws.on('error', onWsClose); 117 | }, 5000); 118 | } 119 | 120 | async function handleMessage(json) { 121 | const msg = JSON.parse(json); 122 | switch(msg.op) { 123 | case 'error': 124 | console.error(msg); 125 | break; 126 | case "marketinfo": 127 | const marketInfo = msg.args[0]; 128 | const marketId = marketInfo.alias; 129 | if(!marketId) break; 130 | MARKETS[marketId] = marketInfo; 131 | break; 132 | case 'quote': 133 | const quote = msg.args; 134 | console.log(`Recived a quote: ${quote}`) 135 | sendOrder(quote); 136 | break; 137 | default: 138 | break; 139 | } 140 | } 141 | 142 | async function requestQuote() { 143 | const args = [ 144 | setting_zigzagChainId, 145 | setting_market, 146 | setting_side, 147 | setting_amount 148 | ]; 149 | zigzagws.send( 150 | JSON.stringify( 151 | { 152 | "op":"requestquote", 153 | "args": args 154 | } 155 | ) 156 | ); 157 | } 158 | 159 | async function sendOrder(quote) { 160 | const chainId = quote[0]; 161 | const marketId = quote[1]; 162 | const side = quote[2]; 163 | const baseQuantity = quote[3]; 164 | const price = quote[4]; 165 | const quoteQuantity = quote[5]; 166 | 167 | const marketInfo = MARKETS[marketId]; 168 | if(!marketInfo) { return; } 169 | let tokenBuy, tokenSell, sellQuantity, tokenRatio = {}, fullSellQuantity; 170 | if (side === 'b') { 171 | sellQuantity = parseFloat(quoteQuantity); 172 | tokenSell = marketInfo.quoteAssetId; 173 | tokenBuy = marketInfo.baseAssetId; 174 | tokenRatio[marketInfo.baseAssetId] = baseQuantity; 175 | tokenRatio[marketInfo.quoteAssetId] = quoteQuantity; 176 | fullSellQuantity = (sellQuantity * 10**(marketInfo.quoteAsset.decimals)).toLocaleString('fullwide', {useGrouping: false }) 177 | } else if (side === 's') { 178 | sellQuantity = parseFloat(baseQuantity); 179 | tokenSell = marketInfo.baseAssetId; 180 | tokenBuy = marketInfo.quoteAssetId; 181 | tokenRatio[marketInfo.baseAssetId] = baseQuantity; 182 | tokenRatio[marketInfo.quoteAssetId] = quoteQuantity; 183 | fullSellQuantity = (sellQuantity * 10**(marketInfo.baseAsset.decimals)).toLocaleString('fullwide', {useGrouping: false }) 184 | } 185 | 186 | const now_unix = Date.now() / 1000 | 0; 187 | const validUntil = now_unix + 120; 188 | const sellQuantityBN = ethers.BigNumber.from(fullSellQuantity); 189 | const packedSellQuantity = zksync.utils.closestPackableTransactionAmount(sellQuantityBN); 190 | const order = await syncWallet.getOrder({ 191 | tokenSell, 192 | tokenBuy, 193 | amount: packedSellQuantity.toString(), 194 | ratio: zksync.utils.tokenRatio(tokenRatio), 195 | validUntil 196 | }); 197 | const args = [chainId, marketId, order]; 198 | zigzagws.send(JSON.stringify({ "op":"submitorder2", "args": args })); 199 | } -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | export interface Request { 3 | chainId?: number 4 | } 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zigzag-zksync-backend", 3 | "version": "1.0", 4 | "description": "Typescript implementation of Zigzag protocol", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "test": "true", 8 | "dev": "nodemon -x ts-node -e 'ts tsx json' -r ./src/env src/", 9 | "build": "tsc", 10 | "start": "ts-node -r ./src/env src/", 11 | "background": "ts-node -r ./src/env ./src/background.ts", 12 | "prepare": "husky install" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/zigzag/backend.git" 17 | }, 18 | "author": "ZigZag Exchange", 19 | "license": "GPL-3.0", 20 | "bugs": { 21 | "url": "https://github.com/zigzag/backend/issues" 22 | }, 23 | "homepage": "https://github.com/zigzag/backend#readme", 24 | "dependencies": { 25 | "@ethersproject/logger": "^5.4.0", 26 | "@types/express": "^4.17.13", 27 | "@types/isomorphic-fetch": "^0.0.35", 28 | "@types/node": "^17.0.23", 29 | "@types/pg": "^8.6.4", 30 | "@types/throng": "^5.0.3", 31 | "@types/ws": "^8.2.2", 32 | "better-sqlite3": "^7.4.3", 33 | "dotenv": "^10.0.0", 34 | "ethers": "^5.5.4", 35 | "express": "^4.17.1", 36 | "install": "^0.13.0", 37 | "isomorphic-fetch": "^3.0.0", 38 | "joi": "^17.5.0", 39 | "nodemon": "^2.0.15", 40 | "npm": "^7.21.0", 41 | "pg": "^8.7.1", 42 | "redis": "^4.0.0", 43 | "starknet": "3.10.1", 44 | "throng": "^5.0.0", 45 | "ts-node": "^10.5.0", 46 | "tsconfig-paths": "^3.12.0", 47 | "typescript": "^4.5.5", 48 | "ws": "^8.2.2", 49 | "zksync": "0.13.1" 50 | }, 51 | "optionalDependencies": { 52 | "bufferutil": "^4.0.3", 53 | "utf-8-validate": "^5.0.5" 54 | }, 55 | "engines": { 56 | "node": "16.17.1" 57 | }, 58 | "imports": { 59 | "src/*": "./src/*" 60 | }, 61 | "devDependencies": { 62 | "@typescript-eslint/eslint-plugin": "^5.12.0", 63 | "@typescript-eslint/parser": "^5.12.0", 64 | "eslint": "^8.9.0", 65 | "eslint-config-airbnb": "^19.0.4", 66 | "eslint-config-prettier": "^8.3.0", 67 | "eslint-plugin-import": "^2.25.4", 68 | "eslint-plugin-jsx-a11y": "^6.5.1", 69 | "eslint-plugin-prettier": "^4.0.0", 70 | "eslint-plugin-react": "^7.28.0", 71 | "eslint-plugin-react-hooks": "^4.3.0", 72 | "husky": "^7.0.0", 73 | "prettier": "^2.5.1", 74 | "prettier-eslint": "^13.0.0", 75 | "pretty-quick": "^3.1.3" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS offers ( 2 | id SERIAL PRIMARY KEY, 3 | userid TEXT, 4 | nonce INTEGER, 5 | market TEXT, 6 | side CHAR(1), 7 | price NUMERIC NOT NULL CHECK (price > 0), 8 | base_quantity NUMERIC CHECK (base_quantity > 0), 9 | quote_quantity NUMERIC CHECK (quote_quantity > 0), 10 | order_type TEXT, 11 | order_status TEXT, 12 | expires BIGINT, 13 | zktx TEXT, 14 | chainid INTEGER NOT NULL, 15 | insert_timestamp TIMESTAMPTZ, 16 | update_timestamp TIMESTAMPTZ, 17 | unfilled NUMERIC NOT NULL CHECK (unfilled <= base_quantity) 18 | ); 19 | CREATE INDEX IF NOT EXISTS offers_order_status_by_market_idx ON offers(chainid, market, order_status); 20 | 21 | ALTER TABLE offers ADD COLUMN IF NOT EXISTS txhash TEXT; 22 | ALTER TABLE offers ADD COLUMN IF NOT EXISTS token TEXT; 23 | 24 | CREATE TABLE IF NOT EXISTS fills ( 25 | id SERIAL PRIMARY KEY, 26 | insert_timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), 27 | chainid INTEGER NOT NULL, 28 | market TEXT NOT NULL, 29 | maker_offer_id INTEGER, 30 | taker_offer_id INTEGER NOT NULL, 31 | maker_user_id TEXT, 32 | taker_user_id TEXT NOT NULL, 33 | fill_status TEXT NOT NULL DEFAULT 'm', 34 | txhash TEXT, 35 | price NUMERIC(32, 16) NOT NULL CHECK (price > 0), 36 | amount NUMERIC(32, 16) NOT NULL CHECK (amount > 0), 37 | maker_fee NUMERIC(32, 16) NOT NULL DEFAULT 0.0, 38 | taker_fee NUMERIC(32, 16) NOT NULL DEFAULT 0.0 39 | ) WITH (OIDS=FALSE); 40 | 41 | ALTER TABLE fills ADD COLUMN IF NOT EXISTS side TEXT; 42 | ALTER TABLE fills ADD COLUMN IF NOT EXISTS feeamount NUMERIC(32, 16); 43 | ALTER TABLE fills ADD COLUMN IF NOT EXISTS feetoken TEXT; 44 | 45 | CREATE INDEX IF NOT EXISTS fills_chainid_market ON fills(chainid, market); 46 | CREATE INDEX IF NOT EXISTS fills_fill_status ON fills(fill_status); 47 | CREATE INDEX IF NOT EXISTS fills_maker_user_id ON fills(chainid, maker_user_id); 48 | CREATE INDEX IF NOT EXISTS fills_taker_user_id ON fills(chainid, taker_user_id); 49 | CREATE INDEX IF NOT EXISTS fills_taker_offer_id ON fills(chainid, taker_offer_id); 50 | CREATE INDEX IF NOT EXISTS fills_chainid_fill_status_market ON fills(chainid, fill_status, market); 51 | CREATE INDEX IF NOT EXISTS fills_fill_status_insert_timestamp ON fills(fill_status, insert_timestamp); 52 | CREATE INDEX IF NOT EXISTS fills_chainid_fill_status_insert_timestamp_market ON fills(chainid, fill_status, insert_timestamp, market); 53 | 54 | CREATE TABLE IF NOT EXISTS marketids ( 55 | marketalias TEXT PRIMARY KEY, 56 | chainid INTEGER NOT NULL, 57 | marketid TEXT NOT NULL 58 | ); 59 | 60 | ------------------------------------------------------------------- 61 | -- match_limit_order 62 | -- 63 | -- Matches a limit order against offers in the book. Example usage: 64 | -- SELECT match_limit_order(1001, '0xeae57ce9cc1984F202e15e038B964bb8bdF7229a', 'ETH-USDT', 'b', 4010.0, 0.5, 'fills', 'offer'); 65 | -- SELECT match_limit_order((SELECT id FROM users WHERE email = 'user-a@example.com' AND obsolete = FALSE), (SELECT id FROM markets WHERE base_symbol = 'BTC' AND quote_symbol = 'USD' AND obsolete = FALSE), 'sell', 4993.0, 0.5); 66 | -- 67 | -- Notes: Currently lots of copied code in this and no tests yet. 68 | -- Returns a table of IDs. That list ID in the table is the offer ID. Every other ID in the table is a fill ID. 69 | ------------------------------------------------------------------- 70 | 71 | CREATE OR REPLACE FUNCTION match_limit_order(_chainid INTEGER, _userid TEXT, _market TEXT, _side CHAR(1), _price NUMERIC, _base_quantity NUMERIC, _quote_quantity NUMERIC, _expires BIGINT, _zktx TEXT, _token TEXT) 72 | RETURNS TABLE ( 73 | id INTEGER 74 | ) 75 | LANGUAGE plpgsql 76 | AS $$ 77 | DECLARE 78 | match RECORD; 79 | amount_taken NUMERIC; 80 | amount_remaining NUMERIC; 81 | _taker_offer_id INTEGER; 82 | BEGIN 83 | CREATE TEMPORARY TABLE tmp_ret ( 84 | id INTEGER 85 | ) ON COMMIT DROP; 86 | 87 | -- Insert initial order to get an orderid 88 | INSERT INTO offers (chainid , userid, market, side, price, base_quantity, order_status, order_type, quote_quantity, expires, unfilled, zktx, insert_timestamp, token) 89 | VALUES ( 90 | _chainid , _userid, _market, _side, _price, _base_quantity, 'o', 'l', _quote_quantity, _expires, _base_quantity, _zktx, NOW(), _token 91 | ) 92 | RETURNING offers.id INTO _taker_offer_id; 93 | 94 | amount_remaining := _base_quantity; 95 | 96 | -- take any offers that cross 97 | IF _side = 'b' THEN 98 | FOR match IN SELECT * FROM offers WHERE chainid = _chainid AND market = _market AND side = 's' AND price <= _price AND unfilled > 0 AND order_status IN ('o', 'pf', 'pm') ORDER BY price ASC, insert_timestamp ASC LOOP 99 | IF amount_remaining > 0 THEN 100 | IF amount_remaining < match.unfilled THEN 101 | amount_taken := amount_remaining; 102 | amount_remaining := amount_remaining - amount_taken; 103 | WITH fill AS (INSERT INTO fills (chainid , market, maker_offer_id, taker_offer_id, maker_user_id, taker_user_id, price, amount, side) VALUES (_chainid , _market, match.id, _taker_offer_id, match.userid, _userid, match.price, amount_taken, _side) RETURNING fills.id) INSERT INTO tmp_ret SELECT * FROM fill; 104 | UPDATE offers SET unfilled = unfilled - amount_taken, order_status=(CASE WHEN unfilled=amount_taken THEN 'm' ELSE 'pm' END) WHERE offers.id = match.id; 105 | IF amount_remaining = 0 THEN 106 | EXIT; -- exit loop 107 | END IF; 108 | ELSE 109 | amount_taken := match.unfilled; 110 | amount_remaining := amount_remaining - amount_taken; 111 | WITH fill AS (INSERT INTO fills (chainid , market, maker_offer_id, taker_offer_id, maker_user_id, taker_user_id, price, amount, side) VALUES (_chainid , _market, match.id, _taker_offer_id, match.userid, _userid, match.price, amount_taken, _side) RETURNING fills.id) INSERT INTO tmp_ret SELECT * FROM fill; 112 | UPDATE offers SET unfilled = unfilled - amount_taken, order_status=(CASE WHEN unfilled=amount_taken THEN 'm' ELSE 'pm' END) WHERE offers.id = match.id; 113 | IF amount_remaining = 0 THEN 114 | EXIT; -- exit loop 115 | END IF; 116 | END IF; 117 | END IF; -- if amount_remaining > 0 118 | END LOOP; 119 | ELSE -- side is 's' 120 | FOR match IN SELECT * FROM offers WHERE chainid = _chainid AND market = _market AND side = 'b' AND price >= _price and unfilled > 0 AND order_status IN ('o', 'pf', 'pm') ORDER BY price DESC, insert_timestamp ASC LOOP 121 | IF amount_remaining > 0 THEN 122 | IF amount_remaining < match.unfilled THEN 123 | amount_taken := amount_remaining; 124 | amount_remaining := amount_remaining - amount_taken; 125 | WITH fill AS (INSERT INTO fills (chainid , market, maker_offer_id, taker_offer_id, maker_user_id, taker_user_id, price, amount, side) VALUES (_chainid , _market, match.id, _taker_offer_id, match.userid, _userid, match.price, amount_taken, _side) RETURNING fills.id) INSERT INTO tmp_ret SELECT * FROM fill; 126 | UPDATE offers SET unfilled = unfilled - amount_taken, order_status=(CASE WHEN unfilled=amount_taken THEN 'm' ELSE 'pm' END) WHERE offers.id = match.id; 127 | IF amount_remaining = 0 THEN 128 | EXIT; -- exit loop 129 | END IF; 130 | ELSE 131 | amount_taken := match.unfilled; 132 | amount_remaining := amount_remaining - amount_taken; 133 | WITH fill AS (INSERT INTO fills (chainid , market, maker_offer_id, taker_offer_id, maker_user_id, taker_user_id, price, amount, side) VALUES (_chainid , _market, match.id, _taker_offer_id, match.userid, _userid, match.price, amount_taken, _side) RETURNING fills.id) INSERT INTO tmp_ret SELECT * FROM fill; 134 | UPDATE offers SET unfilled = unfilled - amount_taken, order_status=(CASE WHEN unfilled=amount_taken THEN 'm' ELSE 'pm' END) WHERE offers.id = match.id; 135 | IF amount_remaining = 0 THEN 136 | EXIT; -- exit loop 137 | END IF; 138 | END IF; 139 | END IF; -- if amount_remaining > 0 140 | END LOOP; 141 | END IF; 142 | 143 | -- Update offer with fill and status data 144 | UPDATE offers SET 145 | order_status=(CASE WHEN amount_remaining = 0 THEN 'm' WHEN amount_remaining != _base_quantity THEN 'pm' ELSE 'o' END), 146 | unfilled=LEAST(amount_remaining, _base_quantity) 147 | WHERE offers.id=_taker_offer_id; 148 | 149 | INSERT INTO tmp_ret (id) VALUES (_taker_offer_id); 150 | 151 | RETURN QUERY 152 | SELECT * FROM tmp_ret; 153 | 154 | END; 155 | $$; 156 | 157 | /* ################ V3 functions ################ */ 158 | CREATE TABLE IF NOT EXISTS past_orders_V3 ( 159 | id SERIAL PRIMARY KEY, 160 | txhash TEXT NOT NULL, 161 | market TEXT NOT NULL, 162 | chainid INTEGER NOT NULL, 163 | taker_address TEXT NOT NULL, 164 | maker_address TEXT NOT NULL, 165 | taker_buy_token TEXT NOT NULL, 166 | taker_sell_token TEXT NOT NULL, 167 | taker_buy_amount NUMERIC(32, 16) NOT NULL, 168 | taker_sell_amount NUMERIC(32, 16) NOT NULL, 169 | maker_fee NUMERIC(32, 16) NOT NULL DEFAULT 0.0, 170 | taker_fee NUMERIC(32, 16) NOT NULL DEFAULT 0.0, 171 | txtime TIMESTAMPTZ NOT NULL DEFAULT now() 172 | ); 173 | 174 | CREATE INDEX IF NOT EXISTS past_orders_V3_chainid_taker_buy_token_taker_sell_token ON past_orders_V3(chainid, taker_buy_token, taker_sell_token); 175 | CREATE INDEX IF NOT EXISTS past_orders_V3_chainid ON past_orders_V3(chainid); 176 | CREATE INDEX IF NOT EXISTS past_orders_V3_chainid_taker_address ON past_orders_V3(chainid, taker_address); 177 | CREATE INDEX IF NOT EXISTS past_orders_V3_taker_address ON past_orders_V3(taker_address); 178 | CREATE INDEX IF NOT EXISTS past_orders_V3_chainid_maker_address ON past_orders_V3(chainid, maker_address); 179 | CREATE INDEX IF NOT EXISTS past_orders_V3_maker_address ON past_orders_V3(maker_address); 180 | CREATE INDEX IF NOT EXISTS past_orders_V3_chainid_market ON past_orders_V3(chainid, market); 181 | CREATE INDEX IF NOT EXISTS past_orders_V3_market ON past_orders_V3(market); -------------------------------------------------------------------------------- /src/cryptography.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | import type { AnyObject } from './types' 3 | import { redis } from './redisClient' 4 | 5 | const VALIDATOR_1271_ABI = ['function isValidSignature(bytes32 hash, bytes signature) view returns (bytes4)'] 6 | 7 | export function getEvmEIP712Types(chainId: number) { 8 | if ([42161, 421613].includes(chainId)) { 9 | return { 10 | Order: [ 11 | { name: 'user', type: 'address' }, 12 | { name: 'sellToken', type: 'address' }, 13 | { name: 'buyToken', type: 'address' }, 14 | { name: 'sellAmount', type: 'uint256' }, 15 | { name: 'buyAmount', type: 'uint256' }, 16 | { name: 'expirationTimeSeconds', type: 'uint256' }, 17 | ], 18 | } 19 | } 20 | return null 21 | } 22 | 23 | export function modifyOldSignature(signature: string): string { 24 | if (signature.slice(-2) === '00') return signature.slice(0, -2).concat('1B') 25 | if (signature.slice(-2) === '01') return signature.slice(0, -2).concat('1C') 26 | return signature 27 | } 28 | 29 | // Address recovery wrapper 30 | function recoverAddress(hash: string, signature: string): string { 31 | try { 32 | return ethers.utils.recoverAddress(hash, signature) 33 | } catch { 34 | return '' 35 | } 36 | } 37 | 38 | // Comparing addresses. targetAddr is already checked upstream 39 | function addrMatching(recoveredAddr: string, targetAddr: string) { 40 | if (recoveredAddr === '') return false 41 | if (!ethers.utils.isAddress(recoveredAddr)) throw new Error(`Invalid recovered address: ${recoveredAddr}`) 42 | 43 | return recoveredAddr.toLowerCase() === targetAddr.toLowerCase() 44 | } 45 | 46 | /* 47 | // EIP 1271 check 48 | async function eip1271Check( 49 | provider: ethers.providers.Provider, 50 | signer: string, 51 | hash: string, 52 | signature: string 53 | ) { 54 | let ethersProvider 55 | if (ethers.providers.Provider.isProvider(provider)) { 56 | ethersProvider = provider 57 | } else { 58 | ethersProvider = new ethers.providers.Web3Provider(provider) 59 | } 60 | const code = await ethersProvider.getCode(signer) 61 | if (code && code !== '0x') { 62 | const contract = new ethers.Contract( 63 | signer, 64 | VALIDATOR_1271_ABI, 65 | ethersProvider 66 | ) 67 | return (await contract.isValidSignature(hash, signature)) === '0x1626ba7e' 68 | } 69 | return false 70 | } 71 | */ 72 | 73 | // you only need to pass one of: typedData or message 74 | export async function verifyMessage(param: { 75 | signer: string 76 | message?: string 77 | typedData?: AnyObject 78 | signature: string 79 | }): Promise { 80 | const { message, typedData, signer } = param 81 | const signature = modifyOldSignature(param.signature) 82 | let finalDigest: string 83 | 84 | if (message) { 85 | finalDigest = ethers.utils.hashMessage(message) 86 | } else if (typedData) { 87 | if (!typedData.domain || !typedData.types || !typedData.message) { 88 | throw Error('Missing one or more properties for typedData (domain, types, message)') 89 | } 90 | 91 | // eslint-disable-next-line no-underscore-dangle 92 | finalDigest = ethers.utils._TypedDataEncoder.hash(typedData.domain, typedData.types, typedData.message) 93 | } else { 94 | throw Error('Missing one of the properties: message or typedData') 95 | } 96 | 97 | // 1nd try: elliptic curve signature (EOA) 98 | const recoveredAddress = recoverAddress(finalDigest, signature) 99 | if (addrMatching(recoveredAddress, signer)) return true 100 | 101 | // 2nd try: Check registered vault address 102 | // Requires manual whitelist 103 | const vaultSigner = await redis.get(`vaultsigner:${signer.toLowerCase()}`) 104 | if (vaultSigner && addrMatching(recoveredAddress, vaultSigner)) return true 105 | console.log(`Expected ${signer}, recovered ${recoveredAddress}`) 106 | 107 | return false 108 | } 109 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | import pg from 'pg' 3 | 4 | const { Pool } = pg 5 | 6 | pg.types.setTypeParser(20, parseInt) 7 | pg.types.setTypeParser(23, parseInt) 8 | pg.types.setTypeParser(1700, parseFloat) 9 | 10 | const db = new Pool({ 11 | connectionString: process.env.DATABASE_URL, 12 | ssl: { 13 | rejectUnauthorized: false, 14 | }, 15 | max: 10, 16 | }) 17 | 18 | export default db 19 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // SPDX-License-Identifier: BUSL-1.1 3 | import 'tsconfig-paths/register' 4 | import dotenv from 'dotenv' 5 | 6 | dotenv.config() 7 | -------------------------------------------------------------------------------- /src/httpServer.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | import express from 'express' 3 | import { createServer } from 'http' 4 | import type { WebSocket, WebSocketServer } from 'ws' 5 | import type { ZZHttpServer } from 'src/types' 6 | import cmcRoutes from 'src/routes/cmc' 7 | import cgRoutes from 'src/routes/cg' 8 | import zzRoutes from 'src/routes/zz' 9 | 10 | export const createHttpServer = (socketServer: WebSocketServer): ZZHttpServer => { 11 | const expressApp = express() as any as ZZHttpServer 12 | const server = createServer(expressApp) 13 | 14 | expressApp.use('/', (req, res, next) => { 15 | res.header('Access-Control-Allow-Origin', '*') 16 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') 17 | res.header('Access-Control-Allow-Methods', 'GET, POST') 18 | next() 19 | }) 20 | 21 | const httpMessages = [ 22 | 'requestquote', 23 | 'submitorder', 24 | 'submitorder2', 25 | 'submitorder3', 26 | 'orderreceiptreq', 27 | 'dailyvolumereq', 28 | 'refreshliquidity', 29 | 'marketsreq', 30 | 'cancelorder2' 31 | ] 32 | 33 | expressApp.use(express.json()) 34 | 35 | expressApp.post('/', async (req, res) => { 36 | if (req.headers['content-type'] !== 'application/json') { 37 | res.json({ 38 | op: 'error', 39 | args: ['Content-Type header must be set to application/json'], 40 | }) 41 | return 42 | } 43 | 44 | const outputString = JSON.stringify(req.body) 45 | if (!outputString.includes('/api/v1/marketinfos')) { 46 | console.log(`REST: ${outputString}`) 47 | } 48 | 49 | if (!httpMessages.includes(req.body.op)) { 50 | res.json({ op: 'error', args: [req.body.op, 'Not supported in HTTP'] }) 51 | return 52 | } 53 | 54 | const timeOutLog = setTimeout(() => { 55 | console.log(`10 sec Timeout processing:`) 56 | console.log(req.body) 57 | }, 10000) 58 | let responseMessage 59 | try { 60 | responseMessage = await expressApp.api.serviceHandler(req.body) 61 | } catch (e: any) { 62 | console.error(`Unexpected error while processing HTTP request: ${e}`) 63 | res.status(400).json(`Unexpected error while processing your request: ${e.message}`) 64 | } 65 | clearTimeout(timeOutLog) 66 | 67 | res.header('Content-Type', 'application/json') 68 | res.status(200).json(responseMessage) 69 | }) 70 | 71 | server.on('upgrade', (request, socket, head) => { 72 | socketServer.handleUpgrade(request, socket, head, (ws: WebSocket) => { 73 | socketServer.emit('connection', ws, request) 74 | }) 75 | }) 76 | 77 | cmcRoutes(expressApp) 78 | cgRoutes(expressApp) 79 | zzRoutes(expressApp) 80 | 81 | expressApp.listen = (...args: any) => server.listen(...args) 82 | 83 | return expressApp 84 | } 85 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // SPDX-License-Identifier: BUSL-1.1 3 | import { createHttpServer } from 'src/httpServer' 4 | import { createSocketServer } from 'src/socketServer' 5 | import { redis, subscriber, publisher } from 'src/redisClient' 6 | import db from 'src/db' 7 | import API from 'src/api' 8 | import type { RedisClientType } from 'redis' 9 | import throng from 'throng' 10 | 11 | const socketServer = createSocketServer() 12 | const httpServer = createHttpServer(socketServer) 13 | 14 | function start() { 15 | const port = Number(process.env.PORT) || 3004 16 | const api = new API( 17 | socketServer as any, 18 | db, 19 | httpServer, 20 | redis as RedisClientType, 21 | subscriber as RedisClientType, 22 | publisher as RedisClientType 23 | ) 24 | 25 | api.start(port).then(() => { 26 | console.log('Successfully started server.') 27 | }) 28 | } 29 | 30 | let WORKERS = Number(process.env.WEB_CONCURRENCY) 31 | if (!WORKERS || Number.isNaN(WORKERS)) WORKERS = 2 32 | 33 | throng({ 34 | worker: start, 35 | count: WORKERS, 36 | lifetime: Infinity, 37 | }) 38 | -------------------------------------------------------------------------------- /src/redisClient.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | import * as Redis from 'redis' 3 | 4 | const redisUrl = process.env.REDIS_URL || 'redis://0.0.0.0:6379' 5 | const redisUseTLS = redisUrl.includes('rediss') 6 | 7 | export const redis = Redis.createClient({ 8 | url: redisUrl, 9 | socket: { 10 | tls: redisUseTLS, 11 | rejectUnauthorized: false, 12 | }, 13 | }).on('error', (err: Error) => console.log('Redis Client Error', err)) 14 | 15 | export const subscriber = Redis.createClient({ 16 | url: redisUrl, 17 | socket: { 18 | tls: redisUseTLS, 19 | rejectUnauthorized: false, 20 | }, 21 | }).on('error', (err: Error) => console.log('Redis Subscriber Error', err)) 22 | 23 | export const publisher = Redis.createClient({ 24 | url: redisUrl, 25 | socket: { 26 | tls: redisUseTLS, 27 | rejectUnauthorized: false, 28 | }, 29 | }).on('error', (err: Error) => console.log('Redis Publisher Error', err)) 30 | -------------------------------------------------------------------------------- /src/routes/cg.ts: -------------------------------------------------------------------------------- 1 | import type { ZZHttpServer } from 'src/types' 2 | 3 | export default function cgRoutes(app: ZZHttpServer) { 4 | const defaultChainId = process.env.DEFAULT_CHAIN_ID ? Number(process.env.DEFAULT_CHAIN_ID) : 1 5 | 6 | function getChainId(req: any, res: any, next: any) { 7 | const chainId = req.params.chainId ? Number(req.params.chainId) : defaultChainId 8 | 9 | req.chainId = chainId 10 | next() 11 | } 12 | 13 | app.get('/api/coingecko/v1/pairs/:chainId?', getChainId, async (req, res) => { 14 | try { 15 | const { chainId } = req 16 | 17 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 18 | res.status(400).send({ 19 | op: 'error', 20 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 21 | }) 22 | return 23 | } 24 | 25 | const results: any[] = [] 26 | const markets = await app.api.redis.SMEMBERS(`activemarkets:${chainId}`) 27 | markets.forEach((market) => { 28 | const [base, target] = market.split('-') 29 | const entry: any = { 30 | ticker_id: `${base}_${target}`, 31 | base, 32 | target, 33 | } 34 | results.push(entry) 35 | }) 36 | res.status(200).send(results) 37 | } catch (error: any) { 38 | console.log(error.message) 39 | res.status(400).send({ op: 'error', message: 'Failed to fetch markets' }) 40 | } 41 | }) 42 | 43 | app.get('/api/coingecko/v1/tickers/:chainId?', getChainId, async (req, res) => { 44 | try { 45 | const { chainId } = req 46 | 47 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 48 | res.status(400).send({ 49 | op: 'error', 50 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 51 | }) 52 | return 53 | } 54 | 55 | const markets: any = {} 56 | const marketSummarys: any = await app.api.getMarketSummarys(chainId) 57 | 58 | Object.keys(marketSummarys).forEach((market: string) => { 59 | const marketSummary = marketSummarys[market] 60 | const entry: any = { 61 | ticker_id: marketSummary.market, 62 | base_currency: marketSummary.baseSymbol, 63 | target_currency: marketSummary.quoteSymbol, 64 | last_price: marketSummary.lastPrice, 65 | base_volume: marketSummary.baseVolume, 66 | target_volume: marketSummary.quoteVolume, 67 | bid: marketSummary.highestBid, 68 | ask: marketSummary.lowestAsk, 69 | high: marketSummary.highestPrice_24h, 70 | low: marketSummary.lowestPrice_24h, 71 | } 72 | markets[market] = entry 73 | }) 74 | res.status(200).json(markets) 75 | } catch (error: any) { 76 | console.log(error.message) 77 | res.status(400).send({ op: 'error', message: 'Failed to fetch markets' }) 78 | } 79 | }) 80 | 81 | app.get('/api/coingecko/v1/orderbook/:chainId?', getChainId, async (req, res) => { 82 | const { chainId } = req 83 | 84 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 85 | res.status(400).send({ 86 | op: 'error', 87 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 88 | }) 89 | return 90 | } 91 | 92 | const tickerId: string = req.query.ticker_id as string 93 | const depth: number = req.query.depth ? Number(req.query.depth) : 0 94 | let market: string 95 | let altMarket: string 96 | if (tickerId) { 97 | market = tickerId.replace('_', '-').toUpperCase() 98 | altMarket = tickerId.replace('_', '-') 99 | } else { 100 | res.status(400).send({ 101 | op: 'error', 102 | message: "Please set a 'ticker_id' like '/orderbook?ticker_id=___'", 103 | }) 104 | return 105 | } 106 | 107 | try { 108 | let orderBook: any = await app.api.getOrderBook(chainId, market, depth, 3) 109 | if (orderBook.asks.length === 0 && orderBook.bids.length === 0) { 110 | orderBook = await app.api.getOrderBook(chainId, altMarket, depth, 3) 111 | } 112 | orderBook.ticker_id = market.replace('-', '_') 113 | res.status(200).json(orderBook) 114 | } catch (error: any) { 115 | console.log(error.message) 116 | res.status(400).send({ 117 | op: 'error', 118 | message: `Failed to fetch orderbook for ${market}, ${error.message}`, 119 | }) 120 | } 121 | }) 122 | 123 | app.get('/api/coingecko/v1/historical_trades/:chainId?', getChainId, async (req, res) => { 124 | const { chainId } = req 125 | 126 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 127 | res.status(400).send({ 128 | op: 'error', 129 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 130 | }) 131 | return 132 | } 133 | 134 | const tickerId: string = req.query.ticker_id as string 135 | const type: string = req.query.type as string 136 | const limit = req.query.limit ? Number(req.query.limit) : 0 137 | const startTime = req.query.start_time ? Number(req.query.start_time) : 0 138 | const endTime = req.query.end_time ? Number(req.query.end_time) : 0 139 | 140 | let market: string 141 | let altMarket: string 142 | if (tickerId) { 143 | market = tickerId.replace('_', '-').toUpperCase() 144 | altMarket = tickerId.replace('_', '-') 145 | } else { 146 | res.status(400).send({ 147 | op: 'error', 148 | message: "Please set a 'ticker_id' like '/orderbook?ticker_id=___'", 149 | }) 150 | return 151 | } 152 | 153 | if (type && !['s', 'b', 'sell', 'buy'].includes(type)) { 154 | res.status(400).send({ 155 | op: 'error', 156 | message: `Type: ${type} is not a valid type. Use 's', 'b', 'sell', 'buy'`, 157 | }) 158 | return 159 | } 160 | 161 | try { 162 | let fills = await app.api.getfills(chainId, market, limit, 25, type, startTime, endTime) 163 | if (fills.length === 0) { 164 | fills = await app.api.getfills(chainId, altMarket, 25) 165 | } 166 | 167 | if (fills.length === 0) { 168 | res.status(400).send({ op: 'error', message: `Can not find trades for ${market}` }) 169 | return 170 | } 171 | 172 | const response: any[] = [] 173 | for (let i = 0; i < fills.length; i++) { 174 | const fill = fills[i] 175 | const date = new Date(fill[12]) 176 | const entry: any = { 177 | trade_id: fill[1], 178 | price: fill[4], 179 | base_volume: fill[5], 180 | target_volume: fill[5] * fill[4], 181 | trade_timestamp: date.getTime(), 182 | type: fill[3] === 's' ? 'sell' : 'buy', 183 | } 184 | response.push(entry) 185 | } 186 | res.status(200).send(response) 187 | } catch (error: any) { 188 | console.log(error.message) 189 | res.status(400).send({ 190 | op: 'error', 191 | message: `Failed to fetch trades for ${market}`, 192 | }) 193 | } 194 | }) 195 | } 196 | -------------------------------------------------------------------------------- /src/routes/cmc.ts: -------------------------------------------------------------------------------- 1 | import type { ZZHttpServer } from 'src/types' 2 | 3 | export default function cmcRoutes(app: ZZHttpServer) { 4 | const defaultChainId = process.env.DEFAULT_CHAIN_ID ? Number(process.env.DEFAULT_CHAIN_ID) : 1 5 | 6 | function getChainId(req: any, res: any, next: any) { 7 | const chainId = req.params.chainId ? Number(req.params.chainId) : defaultChainId 8 | 9 | req.chainId = chainId 10 | next() 11 | } 12 | 13 | app.get('/api/coinmarketcap/v1/markets/:chainId?', getChainId, async (req, res) => { 14 | try { 15 | const { chainId } = req 16 | 17 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 18 | res.status(400).send({ 19 | op: 'error', 20 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 21 | }) 22 | return 23 | } 24 | 25 | const markets: any = {} 26 | const marketSummarys: any = await app.api.getMarketSummarys(chainId) 27 | 28 | Object.keys(marketSummarys).forEach((market: string) => { 29 | const marketSummary = marketSummarys[market] 30 | const entry: any = { 31 | trading_pairs: marketSummary.market, 32 | base_currency: marketSummary.baseSymbol, 33 | quote_currency: marketSummary.quoteSymbol, 34 | last_price: marketSummary.lastPrice, 35 | lowest_ask: marketSummary.lowestAsk, 36 | highest_bid: marketSummary.highestBid, 37 | base_volume: marketSummary.baseVolume, 38 | quote_volume: marketSummary.quoteVolume, 39 | price_change_percent_24h: marketSummary.priceChangePercent_24h, 40 | highest_price_24h: marketSummary.highestPrice_24h, 41 | lowest_price_24h: marketSummary.lowestPrice_24h, 42 | } 43 | markets[market] = entry 44 | }) 45 | res.status(200).json(markets) 46 | } catch (error: any) { 47 | console.log(error.message) 48 | res.status(400).send({ op: 'error', message: 'Failed to fetch markets' }) 49 | } 50 | }) 51 | 52 | app.get('/api/coinmarketcap/v1/ticker/:chainId?', getChainId, async (req, res) => { 53 | try { 54 | const { chainId } = req 55 | 56 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 57 | res.status(400).send({ 58 | op: 'error', 59 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 60 | }) 61 | return 62 | } 63 | 64 | const ticker: any = {} 65 | const lastPrices: any = await app.api.getLastPrices(chainId) 66 | lastPrices.forEach((price: string[]) => { 67 | const entry: any = { 68 | last_price: price[1], 69 | base_volume: price[4], 70 | quote_volume: price[3], 71 | isFrozen: 0, 72 | } 73 | ticker[price[0]] = entry 74 | }) 75 | res.status(200).json(ticker) 76 | } catch (error: any) { 77 | console.log(error.message) 78 | res.status(400).send({ op: 'error', message: 'Failed to fetch ticker prices' }) 79 | } 80 | }) 81 | 82 | app.get('/api/coinmarketcap/v1/orderbook/:market_pair/:chainId?', getChainId, async (req, res) => { 83 | const { chainId } = req 84 | 85 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 86 | res.status(400).send({ 87 | op: 'error', 88 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 89 | }) 90 | return 91 | } 92 | 93 | const market = req.params.market_pair.replace('_', '-').toUpperCase() 94 | const altMarket = req.params.market_pair.replace('_', '-') 95 | const depth: number = req.query.depth ? Number(req.query.depth) : 0 96 | const level: number = req.query.level ? Number(req.query.level) : 2 97 | if (![1, 2, 3].includes(level)) { 98 | res.status(400).send({ 99 | op: 'error', 100 | message: `Level: ${level} is not a valid level. Use 1, 2 or 3.`, 101 | }) 102 | return 103 | } 104 | 105 | try { 106 | // get data 107 | let orderBook = await app.api.getOrderBook(chainId, market, depth, level) 108 | if (orderBook.asks.length === 0 && orderBook.bids.length === 0) { 109 | orderBook = await app.api.getOrderBook(chainId, altMarket, depth, level) 110 | } 111 | res.status(200).json(orderBook) 112 | } catch (error: any) { 113 | console.log(error.message) 114 | res.status(400).send({ 115 | op: 'error', 116 | message: `Failed to fetch orderbook for ${market}, ${error.message}`, 117 | }) 118 | } 119 | }) 120 | 121 | app.get('/api/coinmarketcap/v1/trades/:market_pair/:chainId?', getChainId, async (req, res) => { 122 | const { chainId } = req 123 | 124 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 125 | res.status(400).send({ 126 | op: 'error', 127 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 128 | }) 129 | return 130 | } 131 | 132 | const market = req.params.market_pair.replace('_', '-').toUpperCase() 133 | const altMarket = req.params.market_pair.replace('_', '-') 134 | try { 135 | let fills = await app.api.getfills(chainId, market, 25) 136 | if (fills.length === 0) { 137 | fills = await app.api.getfills(chainId, altMarket, 25) 138 | } 139 | 140 | if (fills.length === 0) { 141 | res.status(400).send({ op: 'error', message: `Can not find trades for ${market}` }) 142 | return 143 | } 144 | 145 | const response: any[] = [] 146 | fills.forEach((fill) => { 147 | const date = new Date(fill[12]) 148 | const entry: any = { 149 | trade_id: fill[1], 150 | price: fill[4], 151 | base_volume: fill[5], 152 | quote_volume: fill[5] * fill[4], 153 | timestamp: date.getTime(), 154 | type: fill[3] === 's' ? 'sell' : 'buy', 155 | } 156 | response.push(entry) 157 | }) 158 | 159 | res.status(200).send(response) 160 | } catch (error: any) { 161 | console.log(error.message) 162 | res.status(400).send({ 163 | op: 'error', 164 | message: `Failed to fetch trades for ${market}`, 165 | }) 166 | } 167 | }) 168 | } 169 | -------------------------------------------------------------------------------- /src/routes/zz.ts: -------------------------------------------------------------------------------- 1 | import type { ZZHttpServer, ZZMarket, ZZMarketInfo, ZZMarketSummary } from 'src/types' 2 | 3 | export default function zzRoutes(app: ZZHttpServer) { 4 | const defaultChainId = process.env.DEFAULT_CHAIN_ID ? Number(process.env.DEFAULT_CHAIN_ID) : 1 5 | 6 | function getChainId(req: any, res: any, next: any) { 7 | const chainId = req.params.chainId ? Number(req.params.chainId) : defaultChainId 8 | 9 | req.chainId = chainId 10 | next() 11 | } 12 | 13 | app.use('/', (req, res, next) => { 14 | res.header('Access-Control-Allow-Origin', '*') 15 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') 16 | res.header('Access-Control-Allow-Methods', 'GET') 17 | next() 18 | }) 19 | 20 | app.get('/api/v1/time', async (req, res) => { 21 | res.send({ serverTimestamp: +new Date() }) 22 | }) 23 | 24 | app.get('/api/v1/markets/:chainId?', getChainId, async (req, res) => { 25 | const { chainId } = req 26 | 27 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 28 | res.status(400).send({ 29 | op: 'error', 30 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 31 | }) 32 | return 33 | } 34 | 35 | const UTCFlag = req.query.utc === 'true' 36 | const markets: string[] = [] 37 | if (req.query.market) { 38 | ;(req.query.market as string).split(',').forEach((market: string) => { 39 | if (market.length < 20) market = market.replace('_', '-').replace('/', '-') 40 | markets.push(market) 41 | }) 42 | } 43 | 44 | try { 45 | const marketSummarys: ZZMarketSummary[] = await app.api.getMarketSummarys(chainId, markets, UTCFlag) 46 | // eslint-disable-next-line no-restricted-syntax 47 | for (const market in marketSummarys) { 48 | if (!marketSummarys[market]) { 49 | const upperCaseSummary = await app.api.getMarketSummarys(chainId, [market.toUpperCase()], UTCFlag) 50 | marketSummarys[market] = upperCaseSummary[market.toUpperCase()] 51 | } 52 | } 53 | 54 | if (!marketSummarys) { 55 | if (markets.length === 0) { 56 | res.status(400).send({ op: 'error', message: `Can't find any markets.` }) 57 | } else { 58 | res.status(400).send({ 59 | op: 'error', 60 | message: `Can't find a summary for ${markets}.`, 61 | }) 62 | } 63 | return 64 | } 65 | res.json(marketSummarys) 66 | } catch (error: any) { 67 | console.log(error.message) 68 | res.status(400).send({ op: 'error', message: 'Failed to fetch markets' }) 69 | } 70 | }) 71 | 72 | app.get('/api/v1/ticker/:chainId?', getChainId, async (req, res) => { 73 | const { chainId } = req 74 | 75 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 76 | res.status(400).send({ 77 | op: 'error', 78 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 79 | }) 80 | return 81 | } 82 | 83 | const markets: ZZMarket[] = [] 84 | if (req.query.market) { 85 | ;(req.query.market as string).split(',').forEach((market: string) => { 86 | if (market.length < 20) market = market.replace('_', '-').replace('/', '-') 87 | markets.push(market) 88 | markets.push(market.toUpperCase()) 89 | }) 90 | } 91 | 92 | try { 93 | const ticker: any = {} 94 | const lastPrices: any = await app.api.getLastPrices(chainId, markets) 95 | if (lastPrices.length === 0) { 96 | if (markets.length === 0) { 97 | res.status(400).send({ 98 | op: 'error', 99 | message: `Can't find any lastPrices for any markets.`, 100 | }) 101 | } else { 102 | res.status(400).send({ 103 | op: 'error', 104 | message: `Can't find a lastPrice for ${req.query.market}.`, 105 | }) 106 | } 107 | return 108 | } 109 | lastPrices.forEach((price: string[]) => { 110 | const entry: any = { 111 | lastPrice: price[1], 112 | priceChange: price[2], 113 | baseVolume: price[4], 114 | quoteVolume: price[3], 115 | } 116 | ticker[price[0]] = entry 117 | }) 118 | res.json(ticker) 119 | } catch (error: any) { 120 | console.log(error.message) 121 | res.status(400).send({ op: 'error', message: 'Failed to fetch ticker prices' }) 122 | } 123 | }) 124 | 125 | app.get('/api/v1/orderbook/:market_pair/:chainId?', getChainId, async (req, res) => { 126 | const { chainId } = req 127 | 128 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 129 | res.status(400).send({ 130 | op: 'error', 131 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 132 | }) 133 | return 134 | } 135 | 136 | const market = req.params.market_pair.replace('_', '-').replace('/', '-').replace(':', '-') 137 | const altMarket = req.params.market_pair.replace('_', '-').replace('/', '-').replace(':', '-').toUpperCase() 138 | const depth = req.query.depth ? Number(req.query.depth) : 0 139 | const level: number = req.query.level ? Number(req.query.level) : 2 140 | if (![1, 2, 3].includes(level)) { 141 | res.send({ 142 | op: 'error', 143 | message: `Level: ${level} is not a valid level. Use 1, 2 or 3.`, 144 | }) 145 | return 146 | } 147 | 148 | try { 149 | // get data 150 | let orderBook = await app.api.getOrderBook(chainId, market, depth, level) 151 | if (orderBook.asks.length === 0 && orderBook.bids.length === 0) { 152 | orderBook = await app.api.getOrderBook(chainId, altMarket, depth, level) 153 | } 154 | res.json(orderBook) 155 | } catch (error: any) { 156 | console.log(error.message) 157 | res.send({ 158 | op: 'error', 159 | message: `Failed to fetch orderbook for ${market}, ${error.message}`, 160 | }) 161 | } 162 | }) 163 | 164 | app.get('/api/v1/trades/:chainId?', getChainId, async (req, res) => { 165 | const { chainId } = req 166 | 167 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 168 | res.status(400).send({ 169 | op: 'error', 170 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 171 | }) 172 | return 173 | } 174 | 175 | let market = req.query.market as string 176 | let altMarket = req.query.market as string 177 | if (market) { 178 | market = market.replace('_', '-') 179 | altMarket = market.replace('_', '-').toUpperCase() 180 | } 181 | const type: string = req.query.type as string 182 | const direction = req.query.direction as string 183 | const limit = req.query.limit ? Number(req.query.limit) : 25 184 | const orderId = req.query.order_id ? Number(req.query.order_id) : 0 185 | const startTime = req.query.start_time ? Number(req.query.start_time) : 0 186 | const endTime = req.query.end_time ? Number(req.query.end_time) : 0 187 | const accountId = req.query.account_id ? Number(req.query.account_id) : 0 188 | 189 | if (type && !['s', 'b', 'sell', 'buy'].includes(type)) { 190 | res.send({ 191 | op: 'error', 192 | message: `Type: ${type} is not a valid type. Use 's', 'b', 'sell', 'buy'`, 193 | }) 194 | return 195 | } 196 | 197 | try { 198 | let fills = await app.api.getfills(chainId, market, limit, orderId, type, startTime, endTime, accountId, direction) 199 | if (fills.length === 0) { 200 | fills = await app.api.getfills(chainId, altMarket, limit, orderId, type, startTime, endTime, accountId, direction) 201 | } 202 | 203 | if (fills.length === 0) { 204 | res.status(400).send({ op: 'error', message: `Can not find fills for ${market}` }) 205 | return 206 | } 207 | 208 | const response: any[] = [] 209 | fills.forEach((fill) => { 210 | const date = new Date(fill[12]) 211 | const entry: any = { 212 | chainId: fill[0], 213 | orderId: fill[1], 214 | market: fill[2], 215 | price: fill[4], 216 | baseVolume: fill[5], 217 | quoteVolume: fill[5] * fill[4], 218 | timestamp: date.getTime(), 219 | side: fill[3] === 's' ? 'sell' : 'buy', 220 | txHash: fill[7], 221 | takerId: chainId === 1 ? Number(fill[8]) : fill[8], // chainId === 1 backward compatible 222 | makerId: chainId === 1 ? Number(fill[9]) : fill[9], // chainId === 1 backward compatible 223 | feeAmount: fill[10], 224 | feeToken: fill[11], 225 | } 226 | response.push(entry) 227 | }) 228 | 229 | res.send(response) 230 | } catch (error: any) { 231 | console.log(error.message) 232 | res.status(400).send({ op: 'error', message: `Failed to fetch trades for ${market}` }) 233 | } 234 | }) 235 | 236 | app.get('/api/v1/tradedata/:chainId?', getChainId, async (req, res) => { 237 | const { chainId } = req 238 | 239 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 240 | res.status(400).send({ 241 | op: 'error', 242 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 243 | }) 244 | return 245 | } 246 | 247 | const daysInput = Number(req.query.days ? req.query.days : 7) 248 | let days: 1 | 7 | 31 = 7 249 | if (daysInput === 1 || daysInput === 31) days = daysInput 250 | 251 | let market = req.query.market as string 252 | let altMarket = req.query.market as string 253 | if (market) { 254 | market = market.replace('_', '-') 255 | altMarket = market.replace('_', '-').toUpperCase() 256 | } 257 | 258 | try { 259 | let tradeData = await app.api.getTradeData(chainId, market, days) 260 | if (tradeData.length === 0) { 261 | tradeData = await app.api.getTradeData(chainId, altMarket, days) 262 | } 263 | 264 | if (tradeData.length === 0) { 265 | res.status(400).send({ op: 'error', message: `Can not find fills for ${market}` }) 266 | return 267 | } 268 | 269 | res.send(tradeData) 270 | } catch (error: any) { 271 | console.log(error.message) 272 | res.status(400).send({ op: 'error', message: `Failed to fetch trades for ${market}` }) 273 | } 274 | }) 275 | 276 | app.get('/api/v1/marketinfos/:chainId?', async (req, res) => { 277 | let chainId = req.params.chainId ? Number(req.params.chainId) : null 278 | 279 | if (!chainId) { 280 | chainId = req.query.chain_id ? Number(req.query.chain_id) : defaultChainId 281 | } 282 | 283 | if (!chainId || !app.api.VALID_CHAINS.includes(chainId)) { 284 | res.status(400).send({ 285 | op: 'error', 286 | message: `ChainId not found, use ${app.api.VALID_CHAINS}`, 287 | }) 288 | return 289 | } 290 | 291 | const markets: ZZMarket[] = [] 292 | if (req.query.market) { 293 | ;(req.query.market as string).split(',').forEach((market: string) => { 294 | if (market.length < 20) market = market.replace('_', '-').replace('/', '-') 295 | markets.push(market) 296 | }) 297 | } else { 298 | res.send({ 299 | op: 'error', 300 | message: `Set a requested pair with '?market=___'`, 301 | }) 302 | return 303 | } 304 | 305 | const marketInfos: ZZMarketInfo = {} 306 | const results: Promise[] = markets.map(async (market: ZZMarket) => { 307 | try { 308 | let marketInfo = await app.api.getMarketInfo(market, Number(chainId)).catch(() => null) 309 | // 2nd try, eg if user send eth-usdc 310 | if (!marketInfo) { 311 | marketInfo = await app.api.getMarketInfo(market.toUpperCase(), Number(chainId)) 312 | } 313 | if (!marketInfo) throw new Error('Market not found') 314 | marketInfos[market] = marketInfo 315 | } catch (err: any) { 316 | marketInfos[market] = { 317 | error: err.message, 318 | market, 319 | } 320 | } 321 | }) 322 | await Promise.all(results) 323 | res.json(marketInfos) 324 | }) 325 | } 326 | -------------------------------------------------------------------------------- /src/schemas.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | import Joi from 'joi' 3 | 4 | export const zksyncOrderSchema = Joi.object({ 5 | accountId: Joi.number().integer().required(), 6 | recipient: Joi.string().required(), 7 | nonce: Joi.number().integer().required(), 8 | amount: Joi.string().required(), 9 | tokenSell: Joi.number().integer().required(), 10 | tokenBuy: Joi.number().integer().required(), 11 | validFrom: Joi.number().required(), 12 | validUntil: Joi.number() 13 | .min((Date.now() / 1000) | 0) 14 | .max(2000000000) 15 | .required(), 16 | ratio: Joi.array().items(Joi.string()).length(2).required(), 17 | signature: Joi.object().required().keys({ 18 | pubKey: Joi.string().required(), 19 | signature: Joi.string().required(), 20 | }), 21 | ethSignature: Joi.any(), 22 | }) 23 | 24 | export const StarkNetSchema = Joi.object({ 25 | message_prefix: Joi.string().required(), 26 | domain_prefix: Joi.object({ 27 | name: Joi.string().required(), 28 | version: Joi.string().required(), 29 | chain_id: Joi.string().required(), 30 | }), 31 | sender: Joi.string(), 32 | order: Joi.object({ 33 | base_asset: Joi.string().required(), 34 | quote_asset: Joi.string().required(), 35 | side: Joi.string().required(), 36 | base_quantity: Joi.string().required(), 37 | price: Joi.object({ 38 | numerator: Joi.string().required(), 39 | denominator: Joi.string().required(), 40 | }), 41 | expiration: Joi.string().required(), 42 | }), 43 | sig_r: Joi.string(), 44 | sig_s: Joi.string(), 45 | }) 46 | 47 | export const EVMOrderSchema = Joi.object({ 48 | user: Joi.string().required().messages({ 49 | 'string.base': `"user" should be a type of 'string'`, 50 | 'string.hex': `"user" should be a hex string`, 51 | 'any.required': `"user" is a required field`, 52 | }), 53 | sellToken: Joi.string().required().messages({ 54 | 'string.base': `"sellToken" should be a type of 'string'`, 55 | 'string.hex': `"sellToken" should be a hex string`, 56 | 'any.required': `"sellToken" is a required field`, 57 | }), 58 | buyToken: Joi.string().required().messages({ 59 | 'string.base': `"buyToken" should be a type of 'string'`, 60 | 'string.hex': `"buyToken" should be a hex string`, 61 | 'any.required': `"buyToken" is a required field`, 62 | }), 63 | sellAmount: Joi.string().required().messages({ 64 | 'string.base': `"sellAmount" should be a type of 'string'`, 65 | 'any.required': `"sellAmount" is a required field`, 66 | }), 67 | buyAmount: Joi.string().required().messages({ 68 | 'string.base': `"buyAmount" should be a type of 'string'`, 69 | 'any.required': `"buyAmount" is a required field`, 70 | }), 71 | expirationTimeSeconds: Joi.string().required().messages({ 72 | 'string.base': `"expirationTimeSeconds" should be a type of 'string'`, 73 | 'any.required': `"expirationTimeSeconds" is a required field`, 74 | }), 75 | signature: Joi.string().required().messages({ 76 | 'string.base': `"signature" should be a type of 'string'`, 77 | 'any.required': `"signature" is a required field`, 78 | }), 79 | }) 80 | -------------------------------------------------------------------------------- /src/services/cancelall.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const cancelall: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, userId] 7 | ) => { 8 | 9 | const errorMsg: WSMessage = { 10 | op: 'error', 11 | args: [ 12 | 'cancelorder', 13 | `cancelall is no longer supported. Use cancelall2 or cancelall3. Docs: https://github.com/ZigZagExchange/backend#operation-cancelall2`, 14 | ], 15 | } 16 | ws.send(JSON.stringify(errorMsg)) 17 | console.log(`Error, cancelall for ${chainId}:${userId} is no longer supported.`) 18 | } 19 | -------------------------------------------------------------------------------- /src/services/cancelorder.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const cancelorder: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, orderId] 7 | ) => { 8 | const errorMsg: WSMessage = { 9 | op: 'error', 10 | args: [ 11 | 'cancelorder', 12 | `cancelorder is no longer supported. Use cancelorder2 or cancelorder3. Docs: https://github.com/ZigZagExchange/backend#operation-cancelorder2`, 13 | ], 14 | } 15 | ws.send(JSON.stringify(errorMsg)) 16 | console.log(`Error, cancelorder for ${chainId}:${orderId} is no longer supported.`) 17 | } 18 | -------------------------------------------------------------------------------- /src/services/cancelorder2.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const cancelorder2: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, orderId, signedMessage] 7 | ) => { 8 | if (!api.VALID_CHAINS.includes(chainId)) { 9 | const errorMsg: WSMessage = { 10 | op: 'error', 11 | args: [ 12 | 'cancelorder2', 13 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS}`, 14 | ], 15 | } 16 | console.log(`Error, ${chainId} is not a valid chain id.`) 17 | if (ws) ws.send(JSON.stringify(errorMsg)) 18 | return errorMsg 19 | } 20 | 21 | try { 22 | const cancelResult: boolean = await api.cancelorder2(chainId, orderId, signedMessage) 23 | if (!cancelResult) throw new Error('Unexpected error') 24 | } catch (e: any) { 25 | const errorMsg: WSMessage = { 26 | op: 'error', 27 | args: ['cancelorder2', e.message, orderId], 28 | } 29 | if (ws) ws.send(JSON.stringify(errorMsg)) 30 | return errorMsg 31 | } 32 | 33 | // return the new status to the sender 34 | const successMsg: WSMessage = { op: 'orderstatus', args: [[[chainId, orderId, 'c']]] } 35 | if (ws) ws.send(JSON.stringify(successMsg)) 36 | return successMsg 37 | } 38 | -------------------------------------------------------------------------------- /src/services/cancelorder3.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const cancelorder3: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, orderId, token] 7 | ) => { 8 | if (!api.VALID_CHAINS.includes(chainId)) { 9 | const errorMsg: WSMessage = { 10 | op: 'error', 11 | args: [ 12 | 'cancelorder3', 13 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS}`, 14 | ], 15 | } 16 | ws.send(JSON.stringify(errorMsg)) 17 | console.log(`Error, ${chainId} is not a valid chain id.`) 18 | return 19 | } 20 | 21 | try { 22 | const cancelResult: boolean = await api.cancelorder3( 23 | chainId, 24 | orderId, 25 | token 26 | ) 27 | if (!cancelResult) throw new Error('Unexpected error') 28 | } catch (e: any) { 29 | const errorMsg: WSMessage = { 30 | op: 'error', 31 | args: ['cancelorder3', e.message, orderId], 32 | } 33 | ws.send(JSON.stringify(errorMsg)) 34 | } 35 | 36 | // return the new status to the sender 37 | ws.send( 38 | JSON.stringify({ op: 'orderstatus', args: [[[chainId, orderId, 'c']]] }) 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/services/dailyvolumereq.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const dailyvolumereq: ZZServiceHandler = async (api, ws, [chainId]) => { 4 | if (!api.VALID_CHAINS.includes(chainId)) { 5 | const errorMsg: WSMessage = { 6 | op: 'error', 7 | args: [ 8 | 'dailyvolumereq', 9 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS}`, 10 | ], 11 | } 12 | if (ws) ws.send(JSON.stringify(errorMsg)) 13 | console.log(`Error, ${chainId} is not a valid chain id.`) 14 | return errorMsg 15 | } 16 | 17 | const historicalVolume = await api.dailyVolumes(chainId) 18 | const dailyVolumeMsg = { op: 'dailyvolume', args: [historicalVolume] } 19 | if (ws) ws.send(JSON.stringify(dailyVolumeMsg)) 20 | return dailyVolumeMsg 21 | } 22 | -------------------------------------------------------------------------------- /src/services/fillreceiptreq.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const fillreceiptreq: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, orderId] 7 | ) => { 8 | if (!api.VALID_CHAINS.includes(chainId)) { 9 | const errorMsg: WSMessage = { 10 | op: 'error', 11 | args: [ 12 | 'fillreceiptreq', 13 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS}`, 14 | ], 15 | } 16 | if (ws) ws.send(JSON.stringify(errorMsg)) 17 | console.log(`Error, ${chainId} is not a valid chain id.`) 18 | return errorMsg 19 | } 20 | 21 | if (!orderId) { 22 | const errorMsg: WSMessage = { 23 | op: 'error', 24 | args: ['fillreceiptreq', `orderId is not set`], 25 | } 26 | if (ws) ws.send(JSON.stringify(errorMsg)) 27 | return errorMsg 28 | } 29 | 30 | if (typeof orderId === 'object' && orderId.length > 25) { 31 | const errorMsg: WSMessage = { 32 | op: 'error', 33 | args: [ 34 | 'fillreceiptreq', 35 | `${orderId.length} is not a valid length. Use up to 25`, 36 | ], 37 | } 38 | if (ws) ws.send(JSON.stringify(errorMsg)) 39 | return errorMsg 40 | } 41 | 42 | try { 43 | const fillreceipt = await api.getFill(chainId, orderId) 44 | const msg = { op: 'fillreceipt', args: fillreceipt } 45 | if (ws) ws.send(JSON.stringify(msg)) 46 | return fillreceipt 47 | } catch (err: any) { 48 | const errorMsg: WSMessage = { 49 | op: 'error', 50 | args: ['fillreceiptreq', err.message], 51 | } 52 | if (ws) ws.send(JSON.stringify(errorMsg)) 53 | return errorMsg 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/services/fillrequest.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | const BLACKLIST = process.env.BLACKLIST || '' 4 | 5 | export const fillrequest: ZZServiceHandler = async ( 6 | api, 7 | ws, 8 | [chainId, orderId, fillOrder] 9 | ) => { 10 | if (!api.VALID_CHAINS.includes(chainId)) { 11 | const errorMsg: WSMessage = { 12 | op: 'error', 13 | args: [ 14 | 'fillrequest', 15 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS}`, 16 | ], 17 | } 18 | ws.send(JSON.stringify(errorMsg)) 19 | console.log(`Error, ${chainId} is not a valid chain id.`) 20 | return 21 | } 22 | 23 | const makerUserId = fillOrder.accountId.toString() 24 | const blacklistedAccounts = BLACKLIST.split(',') 25 | if (blacklistedAccounts.includes(makerUserId)) { 26 | const errorMsg: WSMessage = { 27 | op: 'error', 28 | args: [ 29 | 'fillrequest', 30 | makerUserId, 31 | "You're running a bad version of the market maker. Please run git pull to update your code.", 32 | ], 33 | } 34 | ws.send(JSON.stringify(errorMsg)) 35 | console.log('fillrequest - return blacklisted market maker.') 36 | return 37 | } 38 | 39 | try { 40 | await api.matchorder(chainId, orderId, fillOrder, ws.uuid) 41 | } catch (err: any) { 42 | console.log(err.message) 43 | const errorMsg: WSMessage = { 44 | op: 'error', 45 | args: ['fillrequest', makerUserId, err.message], 46 | } 47 | ws.send(JSON.stringify(errorMsg)) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './marketsreq' 2 | export * from './login' 3 | export * from './orderreceiptreq' 4 | export * from './refreshliquidity' 5 | export * from './indicateliq2' 6 | export * from './submitorder2' 7 | export * from './submitorder3' 8 | export * from './cancelorder' 9 | export * from './cancelorder2' 10 | export * from './cancelorder3' 11 | export * from './cancelall' 12 | export * from './requestquote' 13 | export * from './fillrequest' 14 | export * from './subscribemarket' 15 | export * from './unsubscribemarket' 16 | export * from './orderstatusupdate' 17 | export * from './dailyvolumereq' 18 | export * from './fillreceiptreq' 19 | 20 | /* ################ V3 functions ################ */ 21 | export * from './subscribeswapevents' 22 | export * from './unsubscribeswapevents' 23 | -------------------------------------------------------------------------------- /src/services/indicateliq2.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const indicateliq2: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, market, liquidity] 7 | ) => { 8 | const makerConnId = `${chainId}:${ws.uuid}` 9 | api.MAKER_CONNECTIONS[makerConnId] = ws 10 | try { 11 | const errorArgs: string[] = await api.updateLiquidity( 12 | chainId, 13 | market, 14 | liquidity, 15 | ws.uuid 16 | ) 17 | 18 | // return any bad liquidity msg 19 | if (errorArgs.length > 0) { 20 | const errorMsg: WSMessage = { 21 | op: 'error', 22 | args: [ 23 | 'indicateliq2', 24 | `Send one or more invalid liquidity positions: ${errorArgs.join( 25 | '. ' 26 | )}.`, 27 | ], 28 | } 29 | ws.send(JSON.stringify(errorMsg)) 30 | } 31 | } catch (e: any) { 32 | const errorMsg: WSMessage = { 33 | op: 'error', 34 | args: ['indicateliq2', e.message], 35 | } 36 | ws.send(JSON.stringify(errorMsg)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/services/login.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const login: ZZServiceHandler = async (api, ws, [chainId, userId]) => { 4 | if (!api.VALID_CHAINS.includes(chainId)) { 5 | const errorMsg: WSMessage = { 6 | op: 'error', 7 | args: [ 8 | 'login', 9 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS}`, 10 | ], 11 | } 12 | ws.send(JSON.stringify(errorMsg)) 13 | console.log(`Error, ${chainId} is not a valid chain id.`) 14 | return 15 | } 16 | 17 | ws.chainId = chainId 18 | ws.userId = userId 19 | const userconnkey = `${chainId}:${userId}` 20 | api.USER_CONNECTIONS[userconnkey] = ws 21 | const userorders = await api.getuserorders(chainId, userId) 22 | const userfills = await api.getuserfills(chainId, userId) 23 | ws.send(JSON.stringify({ op: 'orders', args: [userorders] })) 24 | ws.send(JSON.stringify({ op: 'fills', args: [userfills] })) 25 | } 26 | -------------------------------------------------------------------------------- /src/services/marketsreq.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZMarketInfo, ZZServiceHandler } from 'src/types' 2 | 3 | export const marketsreq: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, detailedFlag] 7 | ) => { 8 | if (!api.VALID_CHAINS.includes(chainId)) { 9 | const errorMsg: WSMessage = { 10 | op: 'error', 11 | args: [ 12 | 'marketsreq', 13 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS}`, 14 | ], 15 | } 16 | if (ws) ws.send(JSON.stringify(errorMsg)) 17 | console.log(`Error, ${chainId} is not a valid chain id.`) 18 | return errorMsg 19 | } 20 | 21 | let marketsMsg 22 | if (detailedFlag) { 23 | const marketInfo: ZZMarketInfo[] = [] 24 | const activeMarkets = await api.redis.SMEMBERS(`activemarkets:${chainId}`) 25 | const result = activeMarkets.map(async (market: string) => { 26 | let details: ZZMarketInfo 27 | try { 28 | details = await api.getMarketInfo(market, chainId) 29 | } catch (e: any) { 30 | console.log(`Error marketsreq: getMarketInfo: ${e.message}`) 31 | return 32 | } 33 | if (details) marketInfo.push(details) 34 | }) 35 | await Promise.all(result) 36 | marketsMsg = { op: 'marketinfo2', args: [marketInfo] } 37 | 38 | if (ws) { 39 | ws.send(JSON.stringify(marketsMsg)) 40 | // fetch lastPrices after sending marketsMsg for some delay 41 | const lastPrices = await api.getLastPrices(chainId) 42 | ws.send(JSON.stringify({ op: 'lastprice', args: [lastPrices, chainId] })) 43 | } 44 | } else { 45 | const lastPrices = await api.getLastPrices(chainId) 46 | marketsMsg = { op: 'lastprice', args: [lastPrices, chainId] } 47 | if (ws) { 48 | ws.send(JSON.stringify({ op: 'lastprice', args: [lastPrices, chainId] })) 49 | } 50 | } 51 | return marketsMsg 52 | } 53 | -------------------------------------------------------------------------------- /src/services/orderreceiptreq.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const orderreceiptreq: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, orderId] 7 | ) => { 8 | if (!api.VALID_CHAINS.includes(chainId)) { 9 | const errorMsg: WSMessage = { 10 | op: 'error', 11 | args: [ 12 | 'orderreceiptreq', 13 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS}`, 14 | ], 15 | } 16 | if (ws) ws.send(JSON.stringify(errorMsg)) 17 | console.log(`Error, ${chainId} is not a valid chain id.`) 18 | return errorMsg 19 | } 20 | 21 | try { 22 | const orderreceipt = await api.getOrder(chainId, orderId) 23 | const msg = { op: 'orderreceipt', args: orderreceipt[0] } 24 | if (ws) ws.send(JSON.stringify(msg)) 25 | return orderreceipt[0] 26 | } catch (err: any) { 27 | const errorMsg: WSMessage = { op: 'error', args: ['orderreceiptreq', err.message] } 28 | if (ws) ws.send(JSON.stringify(errorMsg)) 29 | return errorMsg 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/services/orderstatusupdate.ts: -------------------------------------------------------------------------------- 1 | import type { AnyObject, ZZServiceHandler } from 'src/types' 2 | 3 | export const orderstatusupdate: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [updates] 7 | ) => { 8 | const promises = Object.keys(updates).map(async (i) => { 9 | const update = updates[i] 10 | const chainId = Number(update[0]) 11 | const orderId = update[1] 12 | const newstatus = update[2] 13 | let success 14 | let fillId 15 | let market 16 | let fillPrice 17 | let feeAmount 18 | let feeToken 19 | let timestamp 20 | let userId 21 | 22 | if (newstatus === 'b') { 23 | const txhash = update[3] 24 | const result = (await api.updateMatchedOrder( 25 | chainId, 26 | orderId, 27 | newstatus, 28 | txhash 29 | )) as AnyObject 30 | success = result.success 31 | fillId = result.fillId 32 | market = result.market 33 | } 34 | if (newstatus === 'r' || newstatus === 'f') { 35 | const txhash = update[3] 36 | const result = (await api.updateOrderFillStatus( 37 | chainId, 38 | orderId, 39 | newstatus, 40 | txhash 41 | )) as AnyObject 42 | success = result.success 43 | fillId = result.fillId 44 | market = result.market 45 | fillPrice = result.fillPrice 46 | feeAmount = result.feeAmount 47 | feeToken = result.feeToken 48 | timestamp = result.timestamp 49 | userId = result.userId 50 | } 51 | if (success) { 52 | const fillUpdate = [...update] 53 | fillUpdate[1] = fillId 54 | fillUpdate[5] = feeAmount 55 | fillUpdate[6] = feeToken 56 | fillUpdate[7] = timestamp 57 | fillUpdate[8] = fillPrice 58 | 59 | // update user 60 | api.redisPublisher.publish( 61 | `broadcastmsg:user:${chainId}:${userId}`, 62 | JSON.stringify({ op: 'orderstatus', args: [[update]] }) 63 | ) 64 | api.redisPublisher.publish( 65 | `broadcastmsg:all:${chainId}:${market}`, 66 | JSON.stringify({ op: 'orderstatus', args: [[update]] }) 67 | ) 68 | api.redisPublisher.publish( 69 | `broadcastmsg:all:${chainId}:${market}`, 70 | JSON.stringify({ op: 'fillstatus', args: [[fillUpdate]] }) 71 | ) 72 | } 73 | if (success && newstatus === 'f') { 74 | const yesterday = new Date(Date.now() - 86400 * 1000) 75 | .toISOString() 76 | .slice(0, 10) 77 | const yesterdayPrice = Number( 78 | await api.redis.get(`dailyprice:${chainId}:${market}:${yesterday}`) 79 | ) 80 | const priceChange = (fillPrice - yesterdayPrice).toString() 81 | api.redisPublisher.publish( 82 | `broadcastmsg:all:${chainId}:all`, 83 | JSON.stringify({ 84 | op: 'lastprice', 85 | args: [[[market, fillPrice, priceChange]], chainId], 86 | }) 87 | ) 88 | // TODO: Account for nonce checks here 89 | // const userId = update[5]; 90 | // const userNonce = update[6]; 91 | // if(userId && userNonce) { 92 | // if(!NONCES[userId]) { NONCES[userId] = {}; }; 93 | // // nonce+1 to save the next expected nonce 94 | // NONCES[userId][chainId] = userNonce+1; 95 | // } 96 | } 97 | }) 98 | 99 | return Promise.all(promises) 100 | } 101 | -------------------------------------------------------------------------------- /src/services/refreshliquidity.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const refreshliquidity: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, market] 7 | ) => { 8 | if (!api.VALID_CHAINS_ZKSYNC.includes(chainId)) { 9 | const errorMsg: WSMessage = { 10 | op: 'error', 11 | args: [ 12 | 'refreshliquidity', 13 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS_ZKSYNC}`, 14 | ], 15 | } 16 | if (ws) ws.send(JSON.stringify(errorMsg)) 17 | console.log(`Error, ${chainId} is not a valid chain id.`) 18 | return errorMsg 19 | } 20 | 21 | const liquidity = await api.getLiquidity(chainId, market) 22 | const liquidityMsg: WSMessage = { op: 'liquidity2', args: [chainId, market, liquidity] } 23 | if (ws) ws.send(JSON.stringify(liquidityMsg)) 24 | return liquidityMsg 25 | } 26 | -------------------------------------------------------------------------------- /src/services/requestquote.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const requestquote: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, market, side, baseQuantity = null, quoteQuantity = null] 7 | ): Promise => { 8 | if (!api.VALID_CHAINS.includes(chainId)) { 9 | const errorMsg: WSMessage = { 10 | op: 'error', 11 | args: [ 12 | 'requestquote', 13 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS}`, 14 | ], 15 | } 16 | if (ws) ws.send(JSON.stringify(errorMsg)) 17 | console.log(`Error, ${chainId} is not a valid chain id.`) 18 | return errorMsg 19 | } 20 | 21 | let quoteMessage 22 | try { 23 | const quote = await api.genquote( 24 | chainId, 25 | market, 26 | side, 27 | baseQuantity, 28 | quoteQuantity 29 | ) 30 | 31 | quoteMessage = { 32 | op: 'quote', 33 | args: [ 34 | chainId, 35 | market, 36 | side, 37 | quote.softBaseQuantity, 38 | quote.softPrice, 39 | quote.softQuoteQuantity, 40 | ], 41 | } 42 | } catch (e: any) { 43 | console.error(e) 44 | quoteMessage = { op: 'error', args: ['requestquote', e.message] } 45 | } 46 | 47 | if (ws) { 48 | ws.send(JSON.stringify(quoteMessage)) 49 | } 50 | 51 | return quoteMessage 52 | } 53 | -------------------------------------------------------------------------------- /src/services/submitorder2.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | export const submitorder2: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, market, zktx] 7 | ) => { 8 | let msg: WSMessage 9 | try { 10 | if (api.VALID_CHAINS_ZKSYNC.includes(chainId)) { 11 | msg = await api.processorderzksync(chainId, market, zktx) 12 | } else { 13 | msg = { op: 'error', args: ['submitorder2', `'${chainId}' is an invalid chainId`] } 14 | } 15 | } catch (err: any) { 16 | console.error(err) 17 | msg = { op: 'error', args: ['submitorder2', err.message] } 18 | } 19 | 20 | if (ws) ws.send(JSON.stringify(msg)) 21 | // submitorder2 only returns the args and should no longer be used 22 | return msg.args 23 | } 24 | -------------------------------------------------------------------------------- /src/services/submitorder3.ts: -------------------------------------------------------------------------------- 1 | import type { WSMessage, ZZServiceHandler } from 'src/types' 2 | 3 | // Exact same thing as submitorder2 but it follows our standardized response format 4 | // Returns: 5 | // {"op":"userorderack","args":[[1002,4734,"USDC-USDT","b",1.0015431034482758,127.6,127.7969,1646051432,"1285612","o",null,127.6]]} 6 | export const submitorder3: ZZServiceHandler = async ( 7 | api, 8 | ws, 9 | [chainId, market, zktx] 10 | ) => { 11 | let msg: WSMessage 12 | try { 13 | if (api.VALID_CHAINS_ZKSYNC.includes(chainId)) { 14 | msg = await api.processorderzksync(chainId, market, zktx) 15 | } else { 16 | msg = { op: 'error', args: ['submitorder3', `'${chainId}' is an invalid chainId`] } 17 | } 18 | } catch (err: any) { 19 | console.error(err) 20 | msg = { op: 'error', args: ['submitorder3', err.message] } 21 | } 22 | 23 | if (ws) ws.send(JSON.stringify(msg)) 24 | return msg 25 | } 26 | -------------------------------------------------------------------------------- /src/services/subscribemarket.ts: -------------------------------------------------------------------------------- 1 | import type { ZZServiceHandler, ZZMarketSummary, ZZMarketInfo, WSMessage } from 'src/types' 2 | 3 | // subscribemarket operations should be very conservative 4 | // this function gets called like 10k times in 2 seconds on a restart 5 | // so if any expensive functionality is in here it will result in a 6 | // infinite crash loop 7 | // we disabled lastprice and getLiquidity calls in here because they 8 | // were too expensive 9 | // those are run once and broadcast to each user in the background.ts file now 10 | export const subscribemarket: ZZServiceHandler = async ( 11 | api, 12 | ws, 13 | [chainId, market, UTCFlag] 14 | ) => { 15 | const ratelimit = await api.redis.sendCommand(['SET', `subscribemarket:${ws.uuid}:${chainId}:${market}`, '1', 'GET', 'EX', '5']); 16 | if (ratelimit) { 17 | const errorMsg: WSMessage = { 18 | op: 'error', 19 | args: [ 20 | 'subscribemarket', 21 | `Cannot subscribe to ${market} more than once per 5 seconds`, 22 | ], 23 | } 24 | ws.send(JSON.stringify(errorMsg)) 25 | return 26 | } 27 | 28 | if (!api.VALID_CHAINS.includes(chainId)) { 29 | const errorMsg: WSMessage = { 30 | op: 'error', 31 | args: [ 32 | 'subscribemarket', 33 | `${chainId} is not a valid chain id. Use ${api.VALID_CHAINS}`, 34 | ], 35 | } 36 | ws.send(JSON.stringify(errorMsg)) 37 | return 38 | } 39 | 40 | try { 41 | const marketSummary: ZZMarketSummary = ( 42 | await api.getMarketSummarys(chainId, [market], UTCFlag) 43 | )[market] 44 | if (marketSummary) { 45 | const marketSummaryMsg = { 46 | op: 'marketsummary', 47 | args: [ 48 | marketSummary.market, 49 | marketSummary.lastPrice, 50 | marketSummary.highestPrice_24h, 51 | marketSummary.lowestPrice_24h, 52 | marketSummary.priceChange, 53 | marketSummary.baseVolume, 54 | marketSummary.quoteVolume, 55 | ], 56 | } 57 | ws.send(JSON.stringify(marketSummaryMsg)) 58 | } else { 59 | const errorMsg: WSMessage = { 60 | op: 'error', 61 | args: ['subscribemarket', `Can not find marketSummary for ${market}`], 62 | } 63 | ws.send(JSON.stringify(errorMsg)) 64 | } 65 | 66 | let marketInfo: ZZMarketInfo 67 | try { 68 | marketInfo = await api.getMarketInfo(market, chainId) 69 | } catch (e: any) { 70 | const errorMsg: WSMessage = { 71 | op: 'error', 72 | args: [ 73 | 'subscribemarket', 74 | `Can not get marketinfo for ${market}, ${e.message}`, 75 | ], 76 | } 77 | ws.send(JSON.stringify(errorMsg)) 78 | return 79 | } 80 | 81 | if (marketInfo) { 82 | const marketInfoMsg = { op: 'marketinfo', args: [marketInfo] } 83 | ws.send(JSON.stringify(marketInfoMsg)) 84 | } else { 85 | const errorMsg: WSMessage = { 86 | op: 'error', 87 | args: ['subscribemarket', `Can not find market ${market}`], 88 | } 89 | ws.send(JSON.stringify(errorMsg)) 90 | return 91 | } 92 | 93 | const openorders = await api.getopenorders(chainId, market) 94 | ws.send(JSON.stringify({ op: 'orders', args: [openorders] })) 95 | 96 | try { 97 | const fillsString = await api.redis.GET( 98 | `recenttrades:${chainId}:${market}` 99 | ) 100 | if (fillsString) 101 | ws.send( 102 | JSON.stringify({ op: 'fills', args: [JSON.parse(fillsString)] }) 103 | ) 104 | } catch (e: any) { 105 | const errorMsg: WSMessage = { 106 | op: 'error', 107 | args: [ 108 | 'subscribemarket', 109 | `Can not get recenttrades for ${market}, ${e.message}`, 110 | ], 111 | } 112 | ws.send(JSON.stringify(errorMsg)) 113 | return 114 | } 115 | 116 | if (api.VALID_CHAINS_ZKSYNC.includes(chainId)) { 117 | // Send a fast snapshot of liquidity 118 | const liquidity = await api.getSnapshotLiquidity(chainId, market) 119 | ws.send( 120 | JSON.stringify({ op: 'liquidity2', args: [chainId, market, liquidity] }) 121 | ) 122 | } 123 | } catch (e: any) { 124 | console.error(e.message) 125 | const errorMsg: WSMessage = { op: 'error', args: ['subscribemarket', e.message] } 126 | ws.send(JSON.stringify(errorMsg)) 127 | } 128 | 129 | ws.marketSubscriptions.push(`${chainId}:${market}`) 130 | } 131 | -------------------------------------------------------------------------------- /src/services/subscribeswapevents.ts: -------------------------------------------------------------------------------- 1 | import type { ZZServiceHandler, WSMessage } from 'src/types' 2 | import { sortMarketPair } from 'src/utils' 3 | 4 | /* ################ V3 functions ################ */ 5 | export const subscribeswapevents: ZZServiceHandler = async ( 6 | api, 7 | ws, 8 | [chainId, market] 9 | ) => { 10 | if (!api.VALID_EVM_CHAINS.includes(chainId) || chainId === -1) { 11 | const errorMsg: WSMessage = { 12 | op: 'error', 13 | args: [ 14 | 'subscribeswapevents', 15 | `${chainId} is not a valid chain id. Use ${api.VALID_EVM_CHAINS} or -1`, 16 | ], 17 | } 18 | ws.send(JSON.stringify(errorMsg)) 19 | return 20 | } 21 | 22 | if (!market.includes('-') && market !== 'all') { 23 | const errorMsg: WSMessage = { 24 | op: 'error', 25 | args: [ 26 | 'subscribeswapevents', 27 | `${market} is not a valid market Use "tokenA-tokenB" or all`, 28 | ], 29 | } 30 | ws.send(JSON.stringify(errorMsg)) 31 | return 32 | } 33 | 34 | try { 35 | // sort market key 36 | if (market.toLowerCase() !== 'all') { 37 | const [tokenA, tokenB] = market.split('-') 38 | market = sortMarketPair(tokenA, tokenB) 39 | } 40 | await api.sendInitialPastOrders(chainId, market, ws) 41 | } catch (e: any) { 42 | console.error(e) 43 | const errorMsg: WSMessage = { 44 | op: 'error', 45 | args: ['subscribeswapevents', e.message], 46 | } 47 | ws.send(JSON.stringify(errorMsg)) 48 | } 49 | 50 | ws.chainId = chainId 51 | ws.swapEventSubscription = market 52 | } 53 | -------------------------------------------------------------------------------- /src/services/unsubscribemarket.ts: -------------------------------------------------------------------------------- 1 | import type { ZZServiceHandler } from 'src/types' 2 | 3 | export const unsubscribemarket: ZZServiceHandler = async ( 4 | api, 5 | ws, 6 | [chainId, market] 7 | ) => { 8 | if (!chainId || !market) { 9 | ws.marketSubscriptions = [] 10 | } else { 11 | const subscription = `${chainId}:${market}` 12 | ws.marketSubscriptions = ws.marketSubscriptions.filter( 13 | (m) => m !== subscription 14 | ) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/unsubscribeswapevents.ts: -------------------------------------------------------------------------------- 1 | import type { ZZServiceHandler, WSMessage } from 'src/types' 2 | 3 | /* ################ V3 functions ################ */ 4 | export const unsubscribeswapevents: ZZServiceHandler = async ( 5 | api, 6 | ws, 7 | // eslint-disable-next-line no-empty-pattern 8 | [] 9 | ) => { 10 | ws.swapEventSubscription = null 11 | 12 | const successMsg: WSMessage = { 13 | op: 'unsubscribeswapevents', 14 | args: ['success'], 15 | } 16 | ws.send(JSON.stringify(successMsg)) 17 | } 18 | -------------------------------------------------------------------------------- /src/socketServer.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | import mws from 'ws' 3 | import type { IncomingMessage } from 'http' 4 | import { randomUUID } from 'crypto' 5 | import type { WSocket, WSMessage, ZZSocketServer } from 'src/types' 6 | 7 | export const createSocketServer = (): ZZSocketServer => { 8 | const wss = new mws.Server({ noServer: true }) as ZZSocketServer 9 | 10 | async function onWsConnection(ws: WSocket, req: IncomingMessage) { 11 | Object.assign(ws, { 12 | uuid: randomUUID(), 13 | isAlive: true, 14 | marketSubscriptions: [], 15 | chainId: null, 16 | userId: null, 17 | origin: req?.headers?.origin, 18 | }) 19 | 20 | console.log('New connection', req.socket.remoteAddress) 21 | 22 | ws.on('pong', () => { 23 | ws.isAlive = true 24 | }) 25 | 26 | ws.on('message', (json: string): any => { 27 | let msg: WSMessage 28 | try { 29 | msg = JSON.parse(json) as WSMessage 30 | if (typeof msg.op === 'string' && Array.isArray(msg.args)) { 31 | if ( 32 | ![ 33 | 'indicateliq2', 34 | 'submitorder2', 35 | 'submitorder3', 36 | 'submitorder4', 37 | 'subscribemarket', 38 | 'ping', 39 | ].includes(msg.op) 40 | ) { 41 | console.log(`WS[${ws.origin}]: %s`, json) 42 | } else if (['submitorder2', 'submitorder3', 'submitorder4'].includes(msg.op)) { 43 | console.log(`WS[${ws.origin}]: {"op":${msg.op},"args":[${msg.args[0]},${msg.args[1]}, "ZZMessage"]}`) 44 | } 45 | 46 | const debugLog = setTimeout(() => console.log(`Failed to process ${msg.op}, arg: ${msg.args} in under 5 seconds.`), 5000) 47 | if (wss.api) { 48 | const res = wss.api.serviceHandler(msg, ws) 49 | clearTimeout(debugLog) 50 | return res 51 | } 52 | } 53 | } catch (err) { 54 | console.log(err) 55 | } 56 | 57 | return null 58 | }) 59 | 60 | ws.on('error', console.error) 61 | } 62 | 63 | wss.on('connection', onWsConnection) 64 | wss.on('error', console.error) 65 | 66 | return wss 67 | } 68 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | import type { Application } from 'express' 3 | import type { WebSocket, WebSocketServer } from 'ws' 4 | import type API from 'src/api' 5 | 6 | export type AnyObject = { [key: string | number]: any } 7 | 8 | export type ZZMarket = string 9 | 10 | export type ZZMarketInfo = { 11 | [key: string]: any 12 | } 13 | 14 | export type ZZFillOrder = { 15 | amount: number 16 | accountId: string 17 | } 18 | 19 | export type ZZMarketSide = 'b' | 's' 20 | 21 | export type ZkTx = { 22 | accountId: string 23 | tokenSell: string 24 | tokenBuy: string 25 | nonce: string 26 | ratio: [number, number] 27 | amount: number 28 | validUntil: number 29 | } 30 | 31 | export type ZZMarketSummary = { 32 | market: string 33 | baseSymbol: string 34 | quoteSymbol: string 35 | lastPrice: number 36 | lowestAsk: number 37 | highestBid: number 38 | baseVolume: number 39 | quoteVolume: number 40 | priceChange: number 41 | priceChangePercent_24h: number 42 | highestPrice_24h: number 43 | lowestPrice_24h: number 44 | numberOfTrades_24h: number 45 | } 46 | 47 | export type ZZOrder = { 48 | user: string 49 | sellToken: string 50 | buyToken: string 51 | sellAmount: string 52 | buyAmount: string 53 | expirationTimeSeconds: string 54 | signature?: string 55 | } 56 | 57 | /* ################ V3 functions ################ */ 58 | export type WSMessage = { 59 | op: string 60 | args: any[] 61 | } 62 | 63 | export type WSocket = WebSocket & { 64 | uuid: string 65 | isAlive: boolean 66 | marketSubscriptions: string[] 67 | chainId: number 68 | userId: string 69 | origin: string 70 | swapEventSubscription: string | null 71 | } 72 | 73 | export type ZZAPITransport = { api: API } 74 | export type ZZServiceHandler = (api: API, ws: WSocket, args: any[]) => any 75 | export type ZZSocketServer = WebSocketServer & ZZAPITransport 76 | export type ZZHttpServer = Application & ZZAPITransport 77 | 78 | export type ZZPastOrder = { 79 | chainId: number 80 | taker: string 81 | maker: string 82 | makerSellToken: string 83 | takerSellToken: string 84 | takerBuyAmount: number 85 | takerSellAmount: number 86 | makerFee: number 87 | takerFee: number 88 | transactionHash: string 89 | transactionTime: number 90 | } 91 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as starknet from 'starknet' 2 | import { ethers } from 'ethers' 3 | import { randomBytes } from 'crypto' 4 | 5 | export function formatPrice(input: any) { 6 | const inputNumber = Number(input) 7 | if (inputNumber > 99999) { 8 | return inputNumber.toFixed(0) 9 | } 10 | if (inputNumber > 9999) { 11 | return inputNumber.toFixed(1) 12 | } 13 | if (inputNumber > 999) { 14 | return inputNumber.toFixed(2) 15 | } 16 | if (inputNumber > 99) { 17 | return inputNumber.toFixed(3) 18 | } 19 | if (inputNumber > 9) { 20 | return inputNumber.toFixed(4) 21 | } 22 | if (inputNumber > 1) { 23 | return inputNumber.toFixed(5) 24 | } 25 | return inputNumber.toPrecision(6) 26 | } 27 | 28 | export function stringToFelt(text: string) { 29 | const bufferText = Buffer.from(text, 'utf8') 30 | const hexString = `0x${bufferText.toString('hex')}` 31 | return starknet.number.toFelt(hexString) 32 | } 33 | 34 | export function getNetwork(chainId: number) { 35 | switch (chainId) { 36 | case 1: 37 | return 'mainnet' 38 | case 1002: 39 | case 1001: 40 | return 'goerli' 41 | case 42161: 42 | return 'arbitrum' 43 | default: 44 | throw new Error('No valid chainId') 45 | } 46 | } 47 | 48 | export function getRPCURL(chainId: number) { 49 | switch (chainId) { 50 | case 42161: 51 | return 'https://arb1.arbitrum.io/rpc' 52 | case 421613: 53 | return 'https://goerli-rollup.arbitrum.io/rpc' 54 | default: 55 | throw new Error('No valid chainId') 56 | } 57 | } 58 | 59 | /** 60 | * Get the full token name from L1 ERC20 contract 61 | * @param provider 62 | * @param contractAddress 63 | * @param abi 64 | * @returns tokenInfos 65 | */ 66 | export async function getERC20Info(provider: any, contractAddress: string, abi: any) { 67 | const contract = new ethers.Contract(contractAddress, abi, provider) 68 | const [decimalsRes, nameRes, symbolRes] = await Promise.allSettled([contract.decimals(), contract.name(), contract.symbol()]) 69 | 70 | const tokenInfos: any = { address: contractAddress } 71 | tokenInfos.decimals = decimalsRes.status === 'fulfilled' ? decimalsRes.value : null 72 | tokenInfos.name = nameRes.status === 'fulfilled' ? nameRes.value : null 73 | tokenInfos.symbol = symbolRes.status === 'fulfilled' ? symbolRes.value : null 74 | 75 | return tokenInfos 76 | } 77 | 78 | export function getNewToken() { 79 | return randomBytes(64).toString('hex') 80 | } 81 | 82 | export function getFeeEstimationMarket(chainId: number) { 83 | switch (chainId) { 84 | case 42161: 85 | return 'USDC-USDT' 86 | case 421613: 87 | return 'DAI-USDC' 88 | default: 89 | throw new Error('No valid chainId') 90 | } 91 | } 92 | 93 | export function getReadableTxError(errorMsg: string): string { 94 | if (errorMsg.includes('orders not crossed')) return 'orders not crossed' 95 | 96 | if (errorMsg.includes('mismatched tokens')) return 'mismatched tokens' 97 | 98 | if (errorMsg.includes('invalid taker signature')) return 'invalid taker signature' 99 | 100 | if (errorMsg.includes('invalid maker signature')) return 'invalid maker signature' 101 | 102 | if (errorMsg.includes('taker order not enough balance')) return 'taker order not enough balance' 103 | 104 | if (errorMsg.includes('maker order not enough balance')) return 'maker order not enough balance' 105 | 106 | if (errorMsg.includes('taker order not enough balance for fee')) return 'taker order not enough balance for fee' 107 | 108 | if (errorMsg.includes('maker order not enough balance for fee')) return 'maker order not enough balance for fee' 109 | 110 | if (errorMsg.includes('order is filled')) return 'order is filled' 111 | 112 | if (errorMsg.includes('order expired')) return 'order expired' 113 | 114 | if (errorMsg.includes('order canceled')) return 'order canceled' 115 | 116 | if (errorMsg.includes('self swap not allowed')) return 'self swap not allowed' 117 | 118 | if (errorMsg.includes('ERC20: transfer amount exceeds allowance')) return 'ERC20: transfer amount exceeds allowance' 119 | 120 | // this might be a new error, log it 121 | console.log(`getReadableTxError: unparsed error: ${errorMsg}`) 122 | return 'Internal error: A' 123 | } 124 | 125 | export function sortMarketPair(tokenInputA: string, tokenInputB: string): string { 126 | const tokenA = ethers.BigNumber.from(tokenInputA) 127 | const tokenB = ethers.BigNumber.from(tokenInputB) 128 | if (tokenA.lt(tokenB)) { 129 | return `${tokenInputA.toLowerCase()}-${tokenInputB.toLowerCase()}` 130 | } 131 | 132 | return `${tokenInputB.toLowerCase()}-${tokenInputA.toLowerCase()}` 133 | } 134 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"], 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "strict": true, 10 | "declaration": true, 11 | "sourceMap": true, 12 | "inlineSources": true, 13 | "types": ["node"], 14 | "stripInternal": true, 15 | "incremental": true, 16 | "skipLibCheck": true, 17 | "esModuleInterop": true, 18 | "importsNotUsedAsValues": "error", 19 | "baseUrl": ".", 20 | "paths": { 21 | "src/*": ["./src/*"] 22 | } 23 | }, 24 | "include": [ 25 | "src/**/*" 26 | ], 27 | "typedocOptions": { 28 | "entryPoints": ["./src/app.ts"], 29 | "readme": "none", 30 | "out": "apidocs/" 31 | }, 32 | "files": [ 33 | "index.d.ts" 34 | ] 35 | } --------------------------------------------------------------------------------