├── .eslintrc.js ├── .gitignore ├── .markdownlint.json ├── .nvmrc ├── .prettierrc ├── .solcover.js ├── GOVERNANCE.md ├── LICENSE ├── README.md ├── assets ├── coverage-branches.svg ├── coverage-functions.svg ├── coverage-lines.svg ├── coverage-statements.svg ├── logo.png └── tests.svg ├── audits └── IDEX V2 - Report.pdf ├── bin └── get-badges.js ├── contracts ├── Custodian.sol ├── Exchange.sol ├── Governance.sol ├── Migrations.sol ├── Owned.sol ├── libraries │ ├── AssetRegistry.sol │ ├── AssetTransfers.sol │ ├── AssetUnitConversions.sol │ ├── Interfaces.sol │ ├── SafeMath64.sol │ ├── Signatures.sol │ └── UUID.sol └── test │ ├── AssetsMock.sol │ ├── ExchangeMock.sol │ ├── GovernanceMock.sol │ ├── NonCompliantToken.sol │ ├── SafeMath64Mock.sol │ ├── SkimmingTestToken.sol │ ├── TestToken.sol │ └── UUIDMock.sol ├── lib └── index.ts ├── migrations └── 001_initial_migration.js ├── package.json ├── test ├── assets.ts ├── custodian.ts ├── deposit.ts ├── exchange.ts ├── exit.ts ├── governance.ts ├── helpers.ts ├── invalidate.ts ├── safemath.ts ├── trade-block-time.ts ├── trade.ts ├── uuid.ts └── withdraw.ts ├── truffle-config.js ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | tsconfigRootDir: './', 5 | project: 'tsconfig.json', 6 | }, 7 | env: { 8 | node: true, 9 | mocha: true, 10 | 'truffle/globals': true, 11 | }, 12 | extends: [ 13 | 'airbnb-base', 14 | 'plugin:@typescript-eslint/recommended', 15 | 'plugin:import/typescript', 16 | 'prettier', 17 | 'prettier/@typescript-eslint', 18 | 'plugin:prettier/recommended', 19 | 'plugin:chai-expect/recommended', 20 | ], 21 | globals: { BigInt: true, expect: true }, 22 | rules: { 23 | '@typescript-eslint/no-use-before-define': 'off', 24 | // cant handle Category$Name at the moment, although 25 | // pascal case should be enforced. 26 | '@typescript-eslint/class-name-casing': 'off', 27 | 'class-methods-use-this': 'off', 28 | 'comma-dangle': ['error', 'always-multiline'], 29 | 'consistent-return': 'off', 30 | curly: ['error', 'all'], 31 | 'no-restricted-syntax': 'off', 32 | 'no-multi-assign': 'off', 33 | 'no-unused-expressions': 'off', 34 | 'no-use-before-define': 'off', 35 | 'no-console': 'off', 36 | 'no-underscore-dangle': 'off', 37 | // typescript type imports suffer from this 38 | 'import/extensions': [ 39 | 'error', 40 | 'ignorePackages', 41 | { 42 | js: 'never', 43 | jsx: 'never', 44 | ts: 'never', 45 | tsx: 'never', 46 | }, 47 | ], 48 | 'import/no-cycle': 'off', 49 | 'import/prefer-default-export': 'off', 50 | 'import/no-extraneous-dependencies': [ 51 | 'error', 52 | { 53 | devDependencies: ['dev/**'], 54 | }, 55 | ], 56 | 'prettier/prettier': 'error', 57 | quotes: ['error', 'single', { avoidEscape: true }], 58 | 'object-curly-spacing': ['error', 'always'], 59 | }, 60 | plugins: [ 61 | 'import', 62 | 'promise', 63 | 'prettier', 64 | '@typescript-eslint', 65 | 'truffle', 66 | 'chai-expect', 67 | ], 68 | settings: { 69 | 'import/resolver': { 70 | typescript: { 71 | directory: './tsconfig.json', 72 | }, 73 | }, 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage.json 3 | yarn-error.log 4 | build/ 5 | coverage/ 6 | node_modules/ 7 | types/ 8 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "fenced-code-language": false, 4 | "line-length": { 5 | "line_length": 120, 6 | "code_blocks": false, 7 | "headings": false, 8 | "tables": false 9 | }, 10 | "no-bare-urls": false, 11 | "no-duplicate-header": false, 12 | "single-h1": false 13 | } 14 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.16.1 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "tabWidth": 2 6 | } 7 | -------------------------------------------------------------------------------- /.solcover.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | istanbulReporter: ['json-summary', 'html', 'text'], 3 | }; 4 | -------------------------------------------------------------------------------- /GOVERNANCE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Controls and Governance 4 | 5 | ## Overview 6 | 7 | Whistler on-chain components span three contracts, each with attendant controls and governance. 8 | 9 | ## Custodian Contract 10 | 11 | The Custodian contract custodies user funds with minimal additional logic. Specifically, it tracks two control contract addresses: 12 | 13 | - Exchange: the Exchange contract address is the only agent whitelisted to authorize transfers of funds out of the Custodian. 14 | - Governance: the Governance contract address is the only agent whitelisted to authorize changing the Exchange and 15 | Governance contract addresses within the Custodian. 16 | 17 | The Custodian has no control logic itself beyond the above authorizations. Its logic is limited by design to maximize 18 | future upgradability without requiring fund migration. 19 | 20 | ## Governance Contract 21 | 22 | The Governance contract implements the contract upgrade logic while enforcing governance constraints. 23 | 24 | - The Governance contract has a single owner, and the owner cannot be changed. 25 | - The Governance contract has a single admin, and the admin can be changed with no delay by the owner. 26 | - The admin is the only agent whitelisted to change the Custodian’s Exchange or Governance contract addresses, but the 27 | change is a two-step process. 28 | - The admin first calls an upgrade authorization with the new contract address, which initiates the Contract Upgrade 29 | Period. 30 | - Once the Contract Upgrade Period expires, the admin can make a second call that completes the change to the new 31 | contract address. 32 | - At any time during the Contract Upgrade Period, the admin can cancel the upgrade immediately. 33 | 34 | ### Fixed Parameter Settings 35 | 36 | These settings have been pre-determined and may be hard-coded or implicit in the contract logic. 37 | 38 | - Admin Change Period: immediate 39 | - Contract Upgrade Period: 1 week 40 | - Contract Upgrade Cancellation Period: immediate 41 | 42 | ## Exchange Contract 43 | 44 | The Exchange contract implements the majority of exchange functionality, including wallet asset balance tracking. As 45 | such, it contains several fine-grained control and protection mechanisms: 46 | 47 | - The Exchange contract has a single owner, and the owner cannot be changed. 48 | - The Exchange contract has a single admin, and the admin can be changed with no delay by the owner. 49 | - The admin can add or remove addresses as Dispatch wallets with no delay. Dispatch wallets are authorized to call 50 | operator-only contract functions: executeTrade, withdraw. 51 | - The Exchange contract tracks a single fee wallet address, and the fee wallet can be changed with no delay by the admin. 52 | - Nonce invalidation (i.e. mining cancels in IDEX 1.0 terminology) is user-initiated rather than operator-initiated, as is the case in IDEX 1.0. 53 | - User calls a function on Exchange with a nonce before which all orders should be invalidated. 54 | - The Exchange records the invalidation, and starts enforcing it in the trade function after the Chain Propagation Period. 55 | - Off-chain, on detecting the nonce invalidation transaction, all open orders prior to the target nonce for the wallet 56 | are cancelled. 57 | - Wallet exits (i.e. escape hatch) are user-initiated, and 1) prevent the target wallet from deposits, trading, and normal withdrawals, 58 | and 2) subsequently allow the user to directly withdraw any balances. 59 | - User calls the `exitWallet` function on the Exchange. 60 | - The Exchange records the exit and block number, which immediately blocks deposits and initiates the Chain Propagation Period. 61 | - Once the Chain Propagation Period expires: 62 | - The Exchange Contract blocks any trades, or Dispatch withdrawals for the wallet. 63 | - The Exchange Contract allows the user to initiate exit withdrawal transactions for any wallet balances remaining on the Exchange. 64 | - Off-chain, on detecting the wallet exit transaction: 65 | - All Core Actions are disabled for the wallet. 66 | - The wallet is marked as exited, which prevents re-enabling any of the Core Actions. 67 | - All open orders are cancelled for the wallet. 68 | - An exited wallet can be reinstated for trading by calling the `clearWalletExit` function on the Exchange. 69 | - The admin can change the Chain Propagation Period with no delay, subject to the Minimum Chain Propagation Period and 70 | Maximum Chain Propagation Period limits. 71 | - Fee maximums are enforced by the Exchange and specified by the Maximum Maker Fee Rate, Maximum Taker Fee Rate, and 72 | Maximum Withdrawal Fee Rate, all defined as percentages. Fee Rate limits are not changeable. 73 | 74 | ### Fixed Parameter Settings 75 | 76 | These settings have been pre-determined and may be hard-coded or implicit in the contract logic. 77 | 78 | - Admin Change Period: immediate 79 | - Dispatch Change Period: immediate 80 | - Fee Change Period: immediate 81 | - Minimum Chain Propagation Period: 0 82 | - Maximum Chain Propagation Period: 1 week 83 | - Chain Propagation Change Period: immediate 84 | - Maximum Maker Fee Rate: 10% 85 | - Maximum Taker Fee Rate: 10% 86 | - Maximum Withdrawal Fee Rate: 10% 87 | 88 | ### Changable Parameters 89 | 90 | These settings should have the initial values below but should be changeable in the contract according to the above specs. 91 | 92 | - Chain Propagation Period: 1 hour 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # IDEX Smart Contracts 3 | 4 | ![Tests](./assets/tests.svg) 5 | ![Lines](./assets/coverage-lines.svg) 6 | ![Branches](./assets/coverage-branches.svg) 7 | ![Functions](./assets/coverage-functions.svg) 8 | ![Statements](./assets/coverage-statements.svg) 9 | 10 | ![Discord](https://img.shields.io/discord/455246457465733130?label=Discord&style=flat-square) 11 | ![GitHub](https://img.shields.io/github/license/idexio/idex-contracts?style=flat-square) 12 | ![GitHub issues](https://img.shields.io/github/issues/idexio/idex-sdk-js?style=flat-square) 13 | 14 | ![Twitter Follow](https://img.shields.io/twitter/follow/idexio?style=social) 15 | 16 | ## Overview 17 | 18 | This repo collects source code, tests, and documentation for the IDEX Whistler release Ethereum contracts. 19 | 20 | ## Install 21 | 22 | Download and install [nvm](https://github.com/nvm-sh/nvm#installing-and-updating), 23 | [yarn](https://classic.yarnpkg.com/en/docs/install), and [python3](https://www.python.org/downloads/). Then: 24 | 25 | ```console 26 | pip3 install slither-analyzer 27 | ``` 28 | 29 | ## Usage 30 | 31 | This repo is setup as a [Truffle](https://www.trufflesuite.com/docs/truffle/overview) project, with library and test 32 | code written in Typescript. To build: 33 | 34 | ```console 35 | nvm use 36 | yarn && yarn build 37 | ``` 38 | 39 | To run test suite, generate coverage report, and perform static analysis: 40 | 41 | ```console 42 | yarn coverage 43 | yarn analyze 44 | ``` 45 | 46 | ## Background 47 | 48 | IDEX is in development on a series of major product releases that together comprise IDEX 2.0. 49 | 50 | - Release 1 (Whistler): The Whistler release contains all of the off-chain upgrades of IDEX 2.0, including new web and 51 | mobile UIs, new REST and WS APIs, and a high-performance in-memory trading engine that supports advanced order types. 52 | Whistler also includes new smart contracts that custody funds and settle trades on-chain. Unlike later releases, the 53 | Whistler smart contracts are structurally similar to the [IDEX 1.0 contract](https://etherscan.io/address/0x2a0c0dbecc7e4d658f48e01e3fa353f44050c208#code 54 | ) design in that each trade results in a single layer-1 transaction to update the on-contract balances. 55 | 56 | - Release 2 (Peak2Peak): An original layer-2 scaling solution, known as Optimized Optimistic Rollup (O2R), is part of 57 | the larger IDEX 2.0 initiative. Unlike the Whistler contracts, the O2R contracts roll individual trades up into 58 | periodic Merkle root state summaries that are published to layer 1. The Peak2Peak release launches the O2R smart 59 | contracts and accompanying infrastructure to run in parallel with the Whistler smart contracts. During P2P, the Whistler 60 | contracts continue to settle trades and maintain the canonical wallet balances. Running the O2R contracts in parallel 61 | allows testing on real workloads and tuning system parameters prior to switching to O2R exclusively. 62 | 63 | - Release 3 (Blackcomb): The Blackcomb release switches settlement and balance tracking from the Whistler contracts to 64 | the O2R layer-2 system. 65 | 66 | This documentation covers a security audit for the Whistler smart contracts only, with O2R contract audits to follow. 67 | 68 | ## Contract Structure 69 | 70 | The Whistler on-chain infrastructure includes three main contracts and a host of supporting libraries. 71 | 72 | - Custodian: custodies user funds with minimal additional logic. 73 | - Governance: implements [upgrade logic](#upgradability) while enforcing [governance constraints](#controls-and-governance). 74 | - Exchange: implements the majority of exchange functionality, including wallet asset balance tracking. 75 | 76 | ## User Interaction Lifecycle 77 | 78 | Whistler supports trading Ethereum and ERC-20 tokens, and requires users to deposit Eth and tokens into the Whistler 79 | smart contracts before trading. The interaction lifecycle spans three steps. 80 | 81 | ### Deposit 82 | 83 | Users must deposit funds into the Whistler contracts before they are available for trading on IDEX. Depositing ETH 84 | requires calling `depositEther` on the Exchange contract; depositing tokens requires an `approve` call on the token itself 85 | before calling `depositTokenByAddress` on the Exchange contract. 86 | 87 | - The `depositEther` and `depositTokenByAddress` are functions on the Exchange contract, but the funds are ultimately 88 | held in the Custody contract. As part of the deposit process, tokens are transferred first to the Exchange contract, 89 | which tracks wallet asset balances, and then transferred again to the Custody contract. Separate exchange logic and fund 90 | custody supports IDEX 2.0’s [upgrade design](#upgradability). 91 | 92 | - Deposits are only allowed for [registered tokens](#token-symbol-registry). 93 | 94 | - Deposit amounts are adjusted to IDEX 2.0’s [normalized precision design](#precision-and-pips) to prevent depositing 95 | any dust. 96 | 97 | - Deposits from [exited wallets](#wallet-exits) are rejected. 98 | 99 | ### Trade 100 | 101 | In Whistler, all order management and trade matching happens off-chain while trades are ultimately settled on-chain. A 102 | trade is considered settled when the Exchange contract’s wallet asset balances reflect the new values agreed to in the 103 | trade. Exchange’s `executeTrade` function is responsible for settling trades. 104 | 105 | - Unlike deposits, trade settlement can only be initiated via a whitelisted Dispatch wallet controlled by IDEX. Users do 106 | not settle trades directly; only IDEX can submit trades for settlement. Because IDEX alone controls dispatch, IDEX’s 107 | off-chain components can guarantee eventual on-chain trade settlement order and thus allow users to trade in real-time 108 | without waiting for dispatch or mining. 109 | 110 | - The primary responsibility of the trade function is order and trade validation. In the case that IDEX off-chain 111 | infrastructure is compromised, the validations ensure that funds can only move in accordance with orders signed by the 112 | depositing wallet. 113 | 114 | - Due to business requirements, orders are specified by symbol, eg “UBT-ETH” rather than by token contract addresses. 115 | A number of validations result from the [token symbol registration system](#token-symbol-registry). Note the `trade` 116 | parameter to the `executeTrade` function includes the symbol strings separately. This is a gas optimization to order 117 | signature verification as string concat is cheaper than split. 118 | 119 | - Due to business requirements, order quantity and price are specified as strings in 120 | [PIP precision](#precision-and-pips), hence the need for order signature validation to convert the provided values 121 | to strings. 122 | 123 | - IDEX 2.0 supports partial fills on orders, which requires additional bookkeeping to prevent overfills and replays. 124 | 125 | - Fees are assessed as part of trade settlement. The off-chain trading engine computes fees, but the trade function is 126 | responsible for enforcing that fees are within previously defined limits. Business rules require that makers and takers 127 | are charged different fees. Fees are deducted from the quantity of asset each party is receiving. 128 | 129 | ### Withdraw 130 | 131 | Similar to trade settlement, withdrawals are initiated by users via IDEX’s off-chain components, but calls to the 132 | Exchange contract’s `withdraw` function are restricted to whitelisted Dispatch wallets. `withdraw` calls are limited to the 133 | Dispatch wallet in order to guarantee the balance update sequence and thus support trading ahead of settlement. There 134 | is also a [wallet exit](#wallet-exits) 135 | mechanism to prevent withdrawal censorship by IDEX. 136 | 137 | - Withdrawals may be requested by asset symbol or by token contract address. Withdrawal by asset symbol is the standard 138 | approach as dictated by business rules and requires a lookup of the token contract address in the 139 | [token symbol registry](#token-symbol-registry). Withdrawal by token contract asset exists to cover the case where an 140 | asset has been relisted under the same symbol, for example in the case of a token swap. 141 | 142 | - IDEX collects fees on withdrawals in order to cover the gas costs of the `withdraw` function call. Because only an 143 | IDEX-controlled Dispatch wallet can make the `withdraw` call, IDEX is the immediate gas payer for user withdrawals. 144 | IDEX passes along the estimated gas costs to users by collecting a fee out of the withdrawn amount. 145 | 146 | - Despite the `withdraw` function being part of the Exchange contract, funds are returned to the user’s wallet from the 147 | Custody contract. 148 | 149 | ## Upgradability 150 | 151 | Upon the Whistler release, IDEX users must withdraw funds from the IDEX 1.0 contract and deposit funds into the Whistler 152 | contract to continue trading. For an improved UX going forward, Whistler’s contracts include upgrade logic that enables 153 | the rollout of the subsequent Blackcomb release without requiring users to withdraw and redeposit funds into a new 154 | Custody contract. The upgrade logic is minimalist by design. 155 | 156 | - The Custody contract tracks the Governance contract and Exchange contract. The Governance contract is the only actor 157 | authorized to change the Custody contract’s Governance or Exchange target, and implements the rules under which such 158 | changes may be made. 159 | 160 | - Exchange state data is stored in the Exchange contract itself. Because state data, such as wallet asset balances, is 161 | not held in an external contract, any upgrade to the Exchange contract requires actively migrating the state data to the 162 | upgraded contract. 163 | 164 | - The anticipated target of the upgrade is the Blackcomb release’s O2R layer-2 system, where the Exchange state data will 165 | be moved off chain going forward. 166 | 167 | ## Controls and Governance 168 | 169 | The Whistler controls and governance design is captured in its own [spec](./GOVERNANCE.md). 170 | 171 | ## Additional Mechanics 172 | 173 | ### Token Symbol Registry 174 | 175 | Business rules require orders to be specified in asset symbol terms rather than token contract address terms. For 176 | example, an order specifies the market as `"UBT-ETH"` rather than `{ "base": "0xb705268213d593b8fd88d3fdeff93aff5cbdcfae", 177 | "quote": "0x0" }`. Deposits, withdrawals and asset balance tracking, however, must be implemented in token contract 178 | address terms. In order to support both usage modes, Whistler includes a token registry that maps symbols to token contract 179 | addresses along with additional token metadata, such as precision. Only registered tokens are accepted for deposit. 180 | 181 | - Token registration is a two-transaction process, requiring separate calls to `registerToken` and `confirmTokenRegistration`. 182 | Two steps reduce the likelihood of data entry errors when registering a new token. 183 | 184 | - Occasionally projects upgrade their token address via a token swap but need to retain the same trading symbol. To 185 | support this use case, the token registration mechanism can track multiple token contract addresses for a symbol. The 186 | registry includes registration time stamps to ensure orders and withdrawals are only executed against the intended 187 | token contract address, as validated against the order or withdrawal [nonce](#nonces-and-invalidation). Off-chain 188 | business process rules ensure orders are not accepted during new token registration of the same symbol to prevent race 189 | conditions. 190 | 191 | ### Precision and Pips 192 | 193 | In its off-chain components, IDEX 2.0 normalizes all assets to a maximum of 8 decimals of precision, with 1e-8 referred 194 | to as a "pip". Because deposit and withdrawals must account for the true token precision, however, the token registry 195 | includes token decimals as well as functions to convert `pipsToAssetUnits` and `assetUnitsToPips`. All wallet asset 196 | balances are tracked in pips. 197 | 198 | ### Nonces and Invalidation 199 | 200 | Orders include nonces to prevent replay attacks. IDEX 2.0 uses [version-1 UUIDs](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_1_(date-time_and_MAC_address)) 201 | as nonces, which include a timestamp as part of the value. 202 | 203 | IDEX’s hybrid off-chain/on-chain architecture is vulnerable to a cancelled-order submission attack if the off-chain 204 | components are compromised. In this scenario, an attacker gains access to the Dispatch wallet and a set of cancelled 205 | orders by compromising the off-chain order book. Because the orders themselves include valid signatures from the 206 | placing wallet, the Whistler contract cannot distinguish between active orders placed by users and those the user has 207 | since cancelled. 208 | 209 | Nonce invalidation via `invalidateOrderNonce` allows users to invalidate all orders prior to a specified nonce, making it 210 | impossible to submit those orders in a subsequent cancelled-order submission attack. The 211 | [controls and governance](#controls-and-governance) spec covers the exact mechanics and parameters of the mechanism. 212 | 213 | ### Wallet Exits 214 | 215 | Whistler includes a wallet exit mechanism, which allows users to withdraw funds in the case IDEX is offline or 216 | maliciously censoring withdrawals. Calling `exitWallet` initiates the exit process, which prevents 217 | the wallet from subsequent deposits, trades, or normal withdrawals. Wallet exits are a two-step process as defined in 218 | [controls](#controls-and-governance). 219 | 220 | ## License 221 | 222 | The IDEX Whistler Smart Contracts and related code are released under the [GNU Lesser General Public License v3.0](https://www.gnu.org/licenses/lgpl-3.0.en.html). 223 | -------------------------------------------------------------------------------- /assets/coverage-branches.svg: -------------------------------------------------------------------------------- 1 | coverage:branches: 100%coverage:branches100% -------------------------------------------------------------------------------- /assets/coverage-functions.svg: -------------------------------------------------------------------------------- 1 | coverage:functions: 100%coverage:functions100% -------------------------------------------------------------------------------- /assets/coverage-lines.svg: -------------------------------------------------------------------------------- 1 | coverage:lines: 100%coverage:lines100% -------------------------------------------------------------------------------- /assets/coverage-statements.svg: -------------------------------------------------------------------------------- 1 | coverage:statements: 100%coverage:statements100% -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabid/idex-contracts-whistler/fb1480499d6f457d239c006ab0fe0e87510dd75e/assets/logo.png -------------------------------------------------------------------------------- /assets/tests.svg: -------------------------------------------------------------------------------- 1 | tests: 188 passingtests188 passing -------------------------------------------------------------------------------- /audits/IDEX V2 - Report.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabid/idex-contracts-whistler/fb1480499d6f457d239c006ab0fe0e87510dd75e/audits/IDEX V2 - Report.pdf -------------------------------------------------------------------------------- /bin/get-badges.js: -------------------------------------------------------------------------------- 1 | const { get } = require('https'); 2 | const { readFile, writeFile } = require('fs'); 3 | 4 | const getColour = (coverage) => { 5 | if (coverage < 80) { 6 | return 'red'; 7 | } 8 | if (coverage < 90) { 9 | return 'yellow'; 10 | } 11 | return 'brightgreen'; 12 | }; 13 | 14 | const getTestsBadge = (report) => { 15 | if (!(report && report.stats && report.stats.passes)) { 16 | throw new Error('malformed coverage report'); 17 | } 18 | 19 | // TODO Show pending, failed 20 | return `https://img.shields.io/badge/tests-${report.stats.passes}${encodeURI( 21 | ' ', 22 | )}passing-brightgreen.svg`; 23 | }; 24 | 25 | const getCoverageBadge = (report, type) => { 26 | if (!(report && report.total && report.total[type])) { 27 | throw new Error('malformed coverage report'); 28 | } 29 | 30 | const coverage = report.total[type].pct; 31 | const colour = getColour(coverage); 32 | 33 | return `https://img.shields.io/badge/coverage:${type}-${coverage}${encodeURI( 34 | '%', 35 | )}-${colour}.svg`; 36 | }; 37 | 38 | const download = (url, cb) => { 39 | get(url, (res) => { 40 | let file = ''; 41 | res.on('data', (chunk) => (file += chunk)); 42 | res.on('end', () => cb(null, file)); 43 | }).on('error', (err) => cb(err)); 44 | }; 45 | 46 | const mochaReportPath = './coverage/mocha-summary.json'; 47 | const coverageReportPath = './coverage/coverage-summary.json'; 48 | const outputBasePath = './assets'; 49 | 50 | readFile(coverageReportPath, 'utf8', (err, res) => { 51 | if (err) throw err; 52 | const report = JSON.parse(res); 53 | ['statements', 'functions', 'branches', 'lines'].forEach((type) => { 54 | const url = getCoverageBadge(report, type); 55 | download(url, (err, res) => { 56 | if (err) throw err; 57 | const outputPath = `${outputBasePath}/coverage-${type}.svg`; 58 | writeFile(outputPath, res, 'utf8', (err) => { 59 | console.log(`Saved ${outputPath}`); 60 | if (err) throw err; 61 | }); 62 | }); 63 | }); 64 | }); 65 | 66 | readFile(mochaReportPath, 'utf8', (err, res) => { 67 | if (err) throw err; 68 | const report = JSON.parse(res); 69 | const url = getTestsBadge(report); 70 | download(url, (err, res) => { 71 | if (err) throw err; 72 | const outputPath = `${outputBasePath}/tests.svg`; 73 | writeFile(outputPath, res, 'utf8', (err) => { 74 | console.log(`Saved ${outputPath}`); 75 | if (err) throw err; 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /contracts/Custodian.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { Address } from '@openzeppelin/contracts/utils/Address.sol'; 7 | 8 | import { ICustodian } from './libraries/Interfaces.sol'; 9 | import { Owned } from './Owned.sol'; 10 | import { AssetTransfers } from './libraries/AssetTransfers.sol'; 11 | 12 | 13 | /** 14 | * @notice The Custodian contract. Holds custody of all deposited funds for whitelisted Exchange 15 | * contract with minimal additional logic 16 | */ 17 | contract Custodian is ICustodian, Owned { 18 | // Events // 19 | 20 | /** 21 | * @notice Emitted on construction and when Governance upgrades the Exchange contract address 22 | */ 23 | event ExchangeChanged(address oldExchange, address newExchange); 24 | /** 25 | * @notice Emitted on construction and when Governance replaces itself by upgrading the Governance contract address 26 | */ 27 | event GovernanceChanged(address oldGovernance, address newGovernance); 28 | 29 | address _exchange; 30 | address _governance; 31 | 32 | /** 33 | * @notice Instantiate a new Custodian 34 | * 35 | * @dev Sets `owner` and `admin` to `msg.sender`. Sets initial values for Exchange and Governance 36 | * contract addresses, after which they can only be changed by the currently set Governance contract 37 | * itself 38 | * 39 | * @param exchange Address of deployed Exchange contract to whitelist 40 | * @param governance ddress of deployed Governance contract to whitelist 41 | */ 42 | constructor(address exchange, address governance) public Owned() { 43 | require(Address.isContract(exchange), 'Invalid exchange contract address'); 44 | require( 45 | Address.isContract(governance), 46 | 'Invalid governance contract address' 47 | ); 48 | 49 | _exchange = exchange; 50 | _governance = governance; 51 | 52 | emit ExchangeChanged(address(0x0), exchange); 53 | emit GovernanceChanged(address(0x0), governance); 54 | } 55 | 56 | /** 57 | * @notice ETH can only be sent by the Exchange 58 | */ 59 | receive() external override payable onlyExchange {} 60 | 61 | /** 62 | * @notice Withdraw any asset and amount to a target wallet 63 | * 64 | * @dev No balance checking performed 65 | * 66 | * @param wallet The wallet to which assets will be returned 67 | * @param asset The address of the asset to withdraw (ETH or ERC-20 contract) 68 | * @param quantityInAssetUnits The quantity in asset units to withdraw 69 | */ 70 | function withdraw( 71 | address payable wallet, 72 | address asset, 73 | uint256 quantityInAssetUnits 74 | ) external override onlyExchange { 75 | AssetTransfers.transferTo(wallet, asset, quantityInAssetUnits); 76 | } 77 | 78 | /** 79 | * @notice Load address of the currently whitelisted Exchange contract 80 | * 81 | * @return The address of the currently whitelisted Exchange contract 82 | */ 83 | function loadExchange() external override view returns (address) { 84 | return _exchange; 85 | } 86 | 87 | /** 88 | * @notice Sets a new Exchange contract address 89 | * 90 | * @param newExchange The address of the new whitelisted Exchange contract 91 | */ 92 | function setExchange(address newExchange) external override onlyGovernance { 93 | require(Address.isContract(newExchange), 'Invalid contract address'); 94 | 95 | address oldExchange = _exchange; 96 | _exchange = newExchange; 97 | 98 | emit ExchangeChanged(oldExchange, newExchange); 99 | } 100 | 101 | /** 102 | * @notice Load address of the currently whitelisted Governance contract 103 | * 104 | * @return The address of the currently whitelisted Governance contract 105 | */ 106 | function loadGovernance() external override view returns (address) { 107 | return _governance; 108 | } 109 | 110 | /** 111 | * @notice Sets a new Governance contract address 112 | * 113 | * @param newGovernance The address of the new whitelisted Governance contract 114 | */ 115 | function setGovernance(address newGovernance) 116 | external 117 | override 118 | onlyGovernance 119 | { 120 | require(Address.isContract(newGovernance), 'Invalid contract address'); 121 | 122 | address oldGovernance = _governance; 123 | _governance = newGovernance; 124 | 125 | emit GovernanceChanged(oldGovernance, newGovernance); 126 | } 127 | 128 | // RBAC // 129 | 130 | modifier onlyExchange() { 131 | require(msg.sender == _exchange, 'Caller must be Exchange contract'); 132 | _; 133 | } 134 | 135 | modifier onlyGovernance() { 136 | require(msg.sender == _governance, 'Caller must be Governance contract'); 137 | _; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /contracts/Governance.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { Address } from '@openzeppelin/contracts/utils/Address.sol'; 7 | import { 8 | SafeMath as SafeMath256 9 | } from '@openzeppelin/contracts/math/SafeMath.sol'; 10 | 11 | import { ICustodian } from './libraries/Interfaces.sol'; 12 | import { Owned } from './Owned.sol'; 13 | 14 | 15 | contract Governance is Owned { 16 | using SafeMath256 for uint256; 17 | 18 | /** 19 | * @notice Emitted when admin initiates upgrade of `Exchange` contract address on `Custodian` via 20 | * `initiateExchangeUpgrade` 21 | */ 22 | event ExchangeUpgradeInitiated( 23 | address oldExchange, 24 | address newExchange, 25 | uint256 blockThreshold 26 | ); 27 | /** 28 | * @notice Emitted when admin cancels previously started `Exchange` upgrade with `cancelExchangeUpgrade` 29 | */ 30 | event ExchangeUpgradeCanceled(address oldExchange, address newExchange); 31 | /** 32 | * @notice Emitted when admin finalizes `Exchange` upgrade via `finalizeExchangeUpgrade` 33 | */ 34 | event ExchangeUpgradeFinalized(address oldExchange, address newExchange); 35 | /** 36 | * @notice Emitted when admin initiates upgrade of `Governance` contract address on `Custodian` via 37 | * `initiateGovernanceUpgrade` 38 | */ 39 | event GovernanceUpgradeInitiated( 40 | address oldGovernance, 41 | address newGovernance, 42 | uint256 blockThreshold 43 | ); 44 | /** 45 | * @notice Emitted when admin cancels previously started `Governance` upgrade with `cancelGovernanceUpgrade` 46 | */ 47 | event GovernanceUpgradeCanceled(address oldGovernance, address newGovernance); 48 | /** 49 | * @notice Emitted when admin finalizes `Governance` upgrade via `finalizeGovernanceUpgrade`, effectively replacing 50 | * this contract and rendering it non-functioning 51 | */ 52 | event GovernanceUpgradeFinalized( 53 | address oldGovernance, 54 | address newGovernance 55 | ); 56 | 57 | // Internally used structs // 58 | 59 | struct ContractUpgrade { 60 | bool exists; 61 | address newContract; 62 | uint256 blockThreshold; 63 | } 64 | 65 | // Storage // 66 | 67 | uint256 immutable _blockDelay; 68 | ICustodian _custodian; 69 | ContractUpgrade _currentExchangeUpgrade; 70 | ContractUpgrade _currentGovernanceUpgrade; 71 | 72 | /** 73 | * @notice Instantiate a new `Governance` contract 74 | * 75 | * @dev Sets `owner` and `admin` to `msg.sender`. Sets the values for `_blockDelay` governing `Exchange` 76 | * and `Governance` upgrades. This value is immutable, and cannot be changed after construction 77 | * 78 | * @param blockDelay The minimum number of blocks that must be mined after initiating an `Exchange` 79 | * or `Governance` upgrade before the upgrade may be finalized 80 | */ 81 | constructor(uint256 blockDelay) public Owned() { 82 | _blockDelay = blockDelay; 83 | } 84 | 85 | /** 86 | * @notice Sets the address of the `Custodian` contract. The `Custodian` accepts `Exchange` and 87 | * `Governance` addresses in its constructor, after which they can only be changed by the 88 | * `Governance` contract itself. Therefore the `Custodian` must be deployed last and its address 89 | * set here on an existing `Governance` contract. This value is immutable once set and cannot be 90 | * changed again 91 | * 92 | * @param newCustodian The address of the `Custodian` contract deployed against this `Governance` 93 | * contract's address 94 | */ 95 | function setCustodian(ICustodian newCustodian) external onlyAdmin { 96 | require(_custodian == ICustodian(0x0), 'Custodian can only be set once'); 97 | require(Address.isContract(address(newCustodian)), 'Invalid address'); 98 | 99 | _custodian = newCustodian; 100 | } 101 | 102 | // Exchange upgrade // 103 | 104 | /** 105 | * @notice Initiates `Exchange` contract upgrade proccess on `Custodian`. Once `blockDelay` has passed 106 | * the process can be finalized with `finalizeExchangeUpgrade` 107 | * 108 | * @param newExchange The address of the new `Exchange` contract 109 | */ 110 | function initiateExchangeUpgrade(address newExchange) external onlyAdmin { 111 | require(Address.isContract(address(newExchange)), 'Invalid address'); 112 | require( 113 | newExchange != _custodian.loadExchange(), 114 | 'Must be different from current Exchange' 115 | ); 116 | require( 117 | !_currentExchangeUpgrade.exists, 118 | 'Exchange upgrade already in progress' 119 | ); 120 | 121 | _currentExchangeUpgrade = ContractUpgrade( 122 | true, 123 | newExchange, 124 | block.number.add(_blockDelay) 125 | ); 126 | 127 | emit ExchangeUpgradeInitiated( 128 | _custodian.loadExchange(), 129 | newExchange, 130 | _currentExchangeUpgrade.blockThreshold 131 | ); 132 | } 133 | 134 | /** 135 | * @notice Cancels an in-flight `Exchange` contract upgrade that has not yet been finalized 136 | */ 137 | function cancelExchangeUpgrade() external onlyAdmin { 138 | require(_currentExchangeUpgrade.exists, 'No Exchange upgrade in progress'); 139 | 140 | address newExchange = _currentExchangeUpgrade.newContract; 141 | delete _currentExchangeUpgrade; 142 | 143 | emit ExchangeUpgradeCanceled(_custodian.loadExchange(), newExchange); 144 | } 145 | 146 | /** 147 | * @notice Finalizes the `Exchange` contract upgrade by changing the contract address on the `Custodian` 148 | * contract with `setExchange`. The number of blocks specified by `_blockDelay` must have passed since calling 149 | * `initiateExchangeUpgrade` 150 | * 151 | * @param newExchange The address of the new `Exchange` contract. Must equal the address provided to 152 | * `initiateExchangeUpgrade` 153 | */ 154 | function finalizeExchangeUpgrade(address newExchange) external onlyAdmin { 155 | require(_currentExchangeUpgrade.exists, 'No Exchange upgrade in progress'); 156 | require( 157 | _currentExchangeUpgrade.newContract == newExchange, 158 | 'Address mismatch' 159 | ); 160 | require( 161 | block.number >= _currentExchangeUpgrade.blockThreshold, 162 | 'Block threshold not yet reached' 163 | ); 164 | 165 | address oldExchange = _custodian.loadExchange(); 166 | _custodian.setExchange(newExchange); 167 | delete _currentExchangeUpgrade; 168 | 169 | emit ExchangeUpgradeFinalized(oldExchange, newExchange); 170 | } 171 | 172 | // Governance upgrade // 173 | 174 | /** 175 | * @notice Initiates `Governance` contract upgrade proccess on `Custodian`. Once `blockDelay` has passed 176 | * the process can be finalized with `finalizeGovernanceUpgrade` 177 | * 178 | * @param newGovernance The address of the new `Governance` contract 179 | */ 180 | function initiateGovernanceUpgrade(address newGovernance) external onlyAdmin { 181 | require(Address.isContract(address(newGovernance)), 'Invalid address'); 182 | require( 183 | newGovernance != _custodian.loadGovernance(), 184 | 'Must be different from current Governance' 185 | ); 186 | require( 187 | !_currentGovernanceUpgrade.exists, 188 | 'Governance upgrade already in progress' 189 | ); 190 | 191 | _currentGovernanceUpgrade = ContractUpgrade( 192 | true, 193 | newGovernance, 194 | block.number.add(_blockDelay) 195 | ); 196 | 197 | emit GovernanceUpgradeInitiated( 198 | _custodian.loadGovernance(), 199 | newGovernance, 200 | _currentGovernanceUpgrade.blockThreshold 201 | ); 202 | } 203 | 204 | /** 205 | * @notice Cancels an in-flight `Governance` contract upgrade that has not yet been finalized 206 | */ 207 | function cancelGovernanceUpgrade() external onlyAdmin { 208 | require( 209 | _currentGovernanceUpgrade.exists, 210 | 'No Governance upgrade in progress' 211 | ); 212 | 213 | address newGovernance = _currentGovernanceUpgrade.newContract; 214 | delete _currentGovernanceUpgrade; 215 | 216 | emit GovernanceUpgradeCanceled(_custodian.loadGovernance(), newGovernance); 217 | } 218 | 219 | /** 220 | * @notice Finalizes the `Governance` contract upgrade by changing the contract address on the `Custodian` 221 | * contract with `setGovernance`. The number of blocks specified by `_blockDelay` must have passed since calling 222 | * `initiateGovernanceUpgrade`. 223 | * 224 | * @dev After successfully calling this function, this contract will become useless since it is no 225 | * longer whitelisted in the `Custodian` 226 | * 227 | * @param newGovernance The address of the new `Governance` contract. Must equal the address provided to 228 | * `initiateGovernanceUpgrade` 229 | */ 230 | function finalizeGovernanceUpgrade(address newGovernance) external onlyAdmin { 231 | require( 232 | _currentGovernanceUpgrade.exists, 233 | 'No Governance upgrade in progress' 234 | ); 235 | require( 236 | _currentGovernanceUpgrade.newContract == newGovernance, 237 | 'Address mismatch' 238 | ); 239 | require( 240 | block.number >= _currentGovernanceUpgrade.blockThreshold, 241 | 'Block threshold not yet reached' 242 | ); 243 | 244 | address oldGovernance = _custodian.loadGovernance(); 245 | _custodian.setGovernance(newGovernance); 246 | delete _currentGovernanceUpgrade; 247 | 248 | emit GovernanceUpgradeFinalized(oldGovernance, newGovernance); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /contracts/Migrations.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | // IGNORE This is generated by Truffle 4 | // https://www.trufflesuite.com/docs/truffle/getting-started/running-migrations#initial-migration 5 | 6 | pragma solidity 0.6.8; 7 | 8 | 9 | contract Migrations { 10 | address public owner; 11 | uint256 public last_completed_migration; 12 | 13 | constructor() public { 14 | owner = msg.sender; 15 | } 16 | 17 | modifier restricted() { 18 | if (msg.sender == owner) _; 19 | } 20 | 21 | function setCompleted(uint256 completed) public restricted { 22 | last_completed_migration = completed; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /contracts/Owned.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | 5 | 6 | /** 7 | * @notice Mixin that provide separate owner and admin roles for RBAC 8 | */ 9 | abstract contract Owned { 10 | address immutable _owner; 11 | address _admin; 12 | 13 | modifier onlyOwner { 14 | require(msg.sender == _owner, 'Caller must be owner'); 15 | _; 16 | } 17 | modifier onlyAdmin { 18 | require(msg.sender == _admin, 'Caller must be admin'); 19 | _; 20 | } 21 | 22 | /** 23 | * @notice Sets both the owner and admin roles to the contract creator 24 | */ 25 | constructor() public { 26 | _owner = msg.sender; 27 | _admin = msg.sender; 28 | } 29 | 30 | /** 31 | * @notice Sets a new whitelisted admin wallet 32 | * 33 | * @param newAdmin The new whitelisted admin wallet. Must be different from the current one 34 | */ 35 | function setAdmin(address newAdmin) external onlyOwner { 36 | require(newAdmin != address(0x0), 'Invalid wallet address'); 37 | require(newAdmin != _admin, 'Must be different from current admin'); 38 | 39 | _admin = newAdmin; 40 | } 41 | 42 | /** 43 | * @notice Clears the currently whitelisted admin wallet, effectively disabling any functions requiring 44 | * the admin role 45 | */ 46 | function removeAdmin() external onlyOwner { 47 | _admin = address(0x0); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /contracts/libraries/AssetRegistry.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { Address } from '@openzeppelin/contracts/utils/Address.sol'; 7 | 8 | import { IERC20, Structs } from './Interfaces.sol'; 9 | 10 | 11 | /** 12 | * @notice Library helper functions for managing a registry of asset descriptors indexed by address and symbol 13 | */ 14 | library AssetRegistry { 15 | struct Storage { 16 | mapping(address => Structs.Asset) assetsByAddress; 17 | // Mapping value is array since the same symbol can be re-used for a different address 18 | // (usually as a result of a token swap or upgrade) 19 | mapping(string => Structs.Asset[]) assetsBySymbol; 20 | } 21 | 22 | function registerToken( 23 | Storage storage self, 24 | IERC20 tokenAddress, 25 | string memory symbol, 26 | uint8 decimals 27 | ) internal { 28 | require(decimals <= 32, 'Token cannot have more than 32 decimals'); 29 | require( 30 | tokenAddress != IERC20(0x0) && Address.isContract(address(tokenAddress)), 31 | 'Invalid token address' 32 | ); 33 | // The string type does not have a length property so cast to bytes to check for empty string 34 | require(bytes(symbol).length > 0, 'Invalid token symbol'); 35 | require( 36 | !self.assetsByAddress[address(tokenAddress)].isConfirmed, 37 | 'Token already finalized' 38 | ); 39 | 40 | self.assetsByAddress[address(tokenAddress)] = Structs.Asset({ 41 | exists: true, 42 | assetAddress: address(tokenAddress), 43 | symbol: symbol, 44 | decimals: decimals, 45 | isConfirmed: false, 46 | confirmedTimestampInMs: 0 47 | }); 48 | } 49 | 50 | function confirmTokenRegistration( 51 | Storage storage self, 52 | IERC20 tokenAddress, 53 | string memory symbol, 54 | uint8 decimals 55 | ) internal { 56 | Structs.Asset memory asset = self.assetsByAddress[address(tokenAddress)]; 57 | require(asset.exists, 'Unknown token'); 58 | require(!asset.isConfirmed, 'Token already finalized'); 59 | require(isStringEqual(asset.symbol, symbol), 'Symbols do not match'); 60 | require(asset.decimals == decimals, 'Decimals do not match'); 61 | 62 | asset.isConfirmed = true; 63 | asset.confirmedTimestampInMs = uint64(block.timestamp * 1000); // Block timestamp is in seconds, store ms 64 | self.assetsByAddress[address(tokenAddress)] = asset; 65 | self.assetsBySymbol[symbol].push(asset); 66 | } 67 | 68 | function addTokenSymbol( 69 | Storage storage self, 70 | IERC20 tokenAddress, 71 | string memory symbol 72 | ) internal { 73 | Structs.Asset memory asset = self.assetsByAddress[address(tokenAddress)]; 74 | require( 75 | asset.exists && asset.isConfirmed, 76 | 'Registration of token not finalized' 77 | ); 78 | require(!isStringEqual(symbol, 'ETH'), 'ETH symbol reserved for Ether'); 79 | 80 | // This will prevent swapping assets for previously existing orders 81 | uint64 msInOneSecond = 1000; 82 | asset.confirmedTimestampInMs = uint64(block.timestamp * msInOneSecond); 83 | 84 | self.assetsBySymbol[symbol].push(asset); 85 | } 86 | 87 | /** 88 | * @dev Resolves an asset address into corresponding Asset struct 89 | * 90 | * @param assetAddress Ethereum address of asset 91 | */ 92 | function loadAssetByAddress(Storage storage self, address assetAddress) 93 | internal 94 | view 95 | returns (Structs.Asset memory) 96 | { 97 | if (assetAddress == address(0x0)) { 98 | return getEthAsset(); 99 | } 100 | 101 | Structs.Asset memory asset = self.assetsByAddress[assetAddress]; 102 | require( 103 | asset.exists && asset.isConfirmed, 104 | 'No confirmed asset found for address' 105 | ); 106 | 107 | return asset; 108 | } 109 | 110 | /** 111 | * @dev Resolves a asset symbol into corresponding Asset struct 112 | * 113 | * @param symbol Asset symbol, e.g. 'IDEX' 114 | * @param timestampInMs Milliseconds since Unix epoch, usually parsed from a UUID v1 order nonce. 115 | * Constrains symbol resolution to the asset most recently confirmed prior to timestampInMs. Reverts 116 | * if no such asset exists 117 | */ 118 | function loadAssetBySymbol( 119 | Storage storage self, 120 | string memory symbol, 121 | uint64 timestampInMs 122 | ) internal view returns (Structs.Asset memory) { 123 | if (isStringEqual('ETH', symbol)) { 124 | return getEthAsset(); 125 | } 126 | 127 | Structs.Asset memory asset; 128 | if (self.assetsBySymbol[symbol].length > 0) { 129 | for (uint8 i = 0; i < self.assetsBySymbol[symbol].length; i++) { 130 | if ( 131 | self.assetsBySymbol[symbol][i].confirmedTimestampInMs <= timestampInMs 132 | ) { 133 | asset = self.assetsBySymbol[symbol][i]; 134 | } 135 | } 136 | } 137 | require( 138 | asset.exists && asset.isConfirmed, 139 | 'No confirmed asset found for symbol' 140 | ); 141 | 142 | return asset; 143 | } 144 | 145 | /** 146 | * @dev ETH is modeled as an always-confirmed Asset struct for programmatic consistency 147 | */ 148 | function getEthAsset() private pure returns (Structs.Asset memory) { 149 | return Structs.Asset(true, address(0x0), 'ETH', 18, true, 0); 150 | } 151 | 152 | // See https://solidity.readthedocs.io/en/latest/types.html#bytes-and-strings-as-arrays 153 | function isStringEqual(string memory a, string memory b) 154 | private 155 | pure 156 | returns (bool) 157 | { 158 | return keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b)); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /contracts/libraries/AssetTransfers.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { 7 | SafeMath as SafeMath256 8 | } from '@openzeppelin/contracts/math/SafeMath.sol'; 9 | 10 | import { IERC20 } from './Interfaces.sol'; 11 | 12 | 13 | /** 14 | * @notice This library provides helper utilities for transfering assets in and out of contracts. 15 | * It further validates ERC-20 compliant balance updates in the case of token assets 16 | */ 17 | library AssetTransfers { 18 | using SafeMath256 for uint256; 19 | 20 | /** 21 | * @dev Transfers tokens from a wallet into a contract during deposits. `wallet` must already 22 | * have called `approve` on the token contract for at least `tokenQuantity`. Note this only 23 | * applies to tokens since ETH is sent in the deposit transaction via `msg.value` 24 | */ 25 | function transferFrom( 26 | address wallet, 27 | IERC20 tokenAddress, 28 | uint256 quantityInAssetUnits 29 | ) internal { 30 | uint256 balanceBefore = tokenAddress.balanceOf(address(this)); 31 | 32 | // Because we check for the expected balance change we can safely ignore the return value of transferFrom 33 | tokenAddress.transferFrom(wallet, address(this), quantityInAssetUnits); 34 | 35 | uint256 balanceAfter = tokenAddress.balanceOf(address(this)); 36 | require( 37 | balanceAfter.sub(balanceBefore) == quantityInAssetUnits, 38 | 'Token contract returned transferFrom success without expected balance change' 39 | ); 40 | } 41 | 42 | /** 43 | * @dev Transfers ETH or token assets from a contract to 1) another contract, when `Exchange` 44 | * forwards funds to `Custodian` during deposit or 2) a wallet, when withdrawing 45 | */ 46 | function transferTo( 47 | address payable walletOrContract, 48 | address asset, 49 | uint256 quantityInAssetUnits 50 | ) internal { 51 | if (asset == address(0x0)) { 52 | require( 53 | walletOrContract.send(quantityInAssetUnits), 54 | 'ETH transfer failed' 55 | ); 56 | } else { 57 | uint256 balanceBefore = IERC20(asset).balanceOf(walletOrContract); 58 | 59 | // Because we check for the expected balance change we can safely ignore the return value of transfer 60 | IERC20(asset).transfer(walletOrContract, quantityInAssetUnits); 61 | 62 | uint256 balanceAfter = IERC20(asset).balanceOf(walletOrContract); 63 | require( 64 | balanceAfter.sub(balanceBefore) == quantityInAssetUnits, 65 | 'Token contract returned transfer success without expected balance change' 66 | ); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /contracts/libraries/AssetUnitConversions.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { 7 | SafeMath as SafeMath256 8 | } from '@openzeppelin/contracts/math/SafeMath.sol'; 9 | 10 | 11 | /** 12 | * @notice Library helpers for converting asset quantities between asset units and pips 13 | */ 14 | library AssetUnitConversions { 15 | using SafeMath256 for uint256; 16 | 17 | function pipsToAssetUnits(uint64 quantityInPips, uint8 assetDecimals) 18 | internal 19 | pure 20 | returns (uint256) 21 | { 22 | require(assetDecimals <= 32, 'Asset cannot have more than 32 decimals'); 23 | 24 | // Exponents cannot be negative, so divide or multiply based on exponent signedness 25 | if (assetDecimals > 8) { 26 | return uint256(quantityInPips).mul(uint256(10)**(assetDecimals - 8)); 27 | } 28 | return uint256(quantityInPips).div(uint256(10)**(8 - assetDecimals)); 29 | } 30 | 31 | function assetUnitsToPips(uint256 quantityInAssetUnits, uint8 assetDecimals) 32 | internal 33 | pure 34 | returns (uint64) 35 | { 36 | require(assetDecimals <= 32, 'Asset cannot have more than 32 decimals'); 37 | 38 | uint256 quantityInPips; 39 | // Exponents cannot be negative, so divide or multiply based on exponent signedness 40 | if (assetDecimals > 8) { 41 | quantityInPips = quantityInAssetUnits.div( 42 | uint256(10)**(assetDecimals - 8) 43 | ); 44 | } else { 45 | quantityInPips = quantityInAssetUnits.mul( 46 | uint256(10)**(8 - assetDecimals) 47 | ); 48 | } 49 | require(quantityInPips < 2**64, 'Pip quantity overflows uint64'); 50 | 51 | return uint64(quantityInPips); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /contracts/libraries/Interfaces.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | 7 | /** 8 | * @notice Enums used in `Order` and `Withdrawal` structs 9 | */ 10 | contract Enums { 11 | enum OrderSelfTradePrevention { 12 | // Decrement and cancel 13 | dc, 14 | // Cancel oldest 15 | co, 16 | // Cancel newest 17 | cn, 18 | // Cancel both 19 | cb 20 | } 21 | enum OrderSide { Buy, Sell } 22 | enum OrderTimeInForce { 23 | // Good until cancelled 24 | gtc, 25 | // Good until time 26 | gtt, 27 | // Immediate or cancel 28 | ioc, 29 | // Fill or kill 30 | fok 31 | } 32 | enum OrderType { 33 | Market, 34 | Limit, 35 | LimitMaker, 36 | StopLoss, 37 | StopLossLimit, 38 | TakeProfit, 39 | TakeProfitLimit 40 | } 41 | enum WithdrawalType { BySymbol, ByAddress } 42 | } 43 | 44 | 45 | /** 46 | * @notice Struct definitions 47 | */ 48 | contract Structs { 49 | /** 50 | * @notice Argument type for `Exchange.executeTrade` and `Signatures.getOrderWalletHash` 51 | */ 52 | struct Order { 53 | // Not currently used but reserved for future use. Must be 1 54 | uint8 signatureHashVersion; 55 | // UUIDv1 unique to wallet 56 | uint128 nonce; 57 | // Wallet address that placed order and signed hash 58 | address walletAddress; 59 | // Type of order 60 | Enums.OrderType orderType; 61 | // Order side wallet is on 62 | Enums.OrderSide side; 63 | // Order quantity in base or quote asset terms depending on isQuantityInQuote flag 64 | uint64 quantityInPips; 65 | // Is quantityInPips in quote terms 66 | bool isQuantityInQuote; 67 | // For limit orders, price in decimal pips * 10^8 in quote terms 68 | uint64 limitPriceInPips; 69 | // For stop orders, stop loss or take profit price in decimal pips * 10^8 in quote terms 70 | uint64 stopPriceInPips; 71 | // Optional custom client order ID 72 | string clientOrderId; 73 | // TIF option specified by wallet for order 74 | Enums.OrderTimeInForce timeInForce; 75 | // STP behavior specified by wallet for order 76 | Enums.OrderSelfTradePrevention selfTradePrevention; 77 | // Cancellation time specified by wallet for GTT TIF order 78 | uint64 cancelAfter; 79 | // The ECDSA signature of the order hash as produced by Signatures.getOrderWalletHash 80 | bytes walletSignature; 81 | } 82 | 83 | /** 84 | * @notice Return type for `Exchange.loadAssetBySymbol`, and `Exchange.loadAssetByAddress`; also 85 | * used internally by `AssetRegistry` 86 | */ 87 | struct Asset { 88 | // Flag to distinguish from empty struct 89 | bool exists; 90 | // The asset's address 91 | address assetAddress; 92 | // The asset's symbol 93 | string symbol; 94 | // The asset's decimal precision 95 | uint8 decimals; 96 | // Flag set when asset registration confirmed. Asset deposits, trades, or withdrawals only allowed if true 97 | bool isConfirmed; 98 | // Timestamp as ms since Unix epoch when isConfirmed was asserted 99 | uint64 confirmedTimestampInMs; 100 | } 101 | 102 | /** 103 | * @notice Argument type for `Exchange.executeTrade` specifying execution parameters for matching orders 104 | */ 105 | struct Trade { 106 | // Base asset symbol 107 | string baseAssetSymbol; 108 | // Quote asset symbol 109 | string quoteAssetSymbol; 110 | // Base asset address 111 | address baseAssetAddress; 112 | // Quote asset address 113 | address quoteAssetAddress; 114 | // Gross amount including fees of base asset executed 115 | uint64 grossBaseQuantityInPips; 116 | // Gross amount including fees of quote asset executed 117 | uint64 grossQuoteQuantityInPips; 118 | // Net amount of base asset received by buy side wallet after fees 119 | uint64 netBaseQuantityInPips; 120 | // Net amount of quote asset received by sell side wallet after fees 121 | uint64 netQuoteQuantityInPips; 122 | // Asset address for liquidity maker's fee 123 | address makerFeeAssetAddress; 124 | // Asset address for liquidity taker's fee 125 | address takerFeeAssetAddress; 126 | // Fee paid by liquidity maker 127 | uint64 makerFeeQuantityInPips; 128 | // Fee paid by liquidity taker 129 | uint64 takerFeeQuantityInPips; 130 | // Execution price of trade in decimal pips * 10^8 in quote terms 131 | uint64 priceInPips; 132 | // Which side of the order (buy or sell) the liquidity maker was on 133 | Enums.OrderSide makerSide; 134 | } 135 | 136 | /** 137 | * @notice Argument type for `Exchange.withdraw` and `Signatures.getWithdrawalWalletHash` 138 | */ 139 | struct Withdrawal { 140 | // Distinguishes between withdrawals by asset symbol or address 141 | Enums.WithdrawalType withdrawalType; 142 | // UUIDv1 unique to wallet 143 | uint128 nonce; 144 | // Address of wallet to which funds will be returned 145 | address payable walletAddress; 146 | // Asset symbol 147 | string assetSymbol; 148 | // Asset address 149 | address assetAddress; // Used when assetSymbol not specified 150 | // Withdrawal quantity 151 | uint64 quantityInPips; 152 | // Gas fee deducted from withdrawn quantity to cover dispatcher tx costs 153 | uint64 gasFeeInPips; 154 | // Not currently used but reserved for future use. Must be true 155 | bool autoDispatchEnabled; 156 | // The ECDSA signature of the withdrawal hash as produced by Signatures.getWithdrawalWalletHash 157 | bytes walletSignature; 158 | } 159 | } 160 | 161 | 162 | /** 163 | * @notice Interface of the ERC20 standard as defined in the EIP, but with no return values for 164 | * transfer and transferFrom. By asserting expected balance changes when calling these two methods 165 | * we can safely ignore their return values. This allows support of non-compliant tokens that do not 166 | * return a boolean. See https://github.com/ethereum/solidity/issues/4116 167 | */ 168 | interface IERC20 { 169 | /** 170 | * @notice Returns the amount of tokens in existence. 171 | */ 172 | function totalSupply() external view returns (uint256); 173 | 174 | /** 175 | * @notice Returns the amount of tokens owned by `account`. 176 | */ 177 | function balanceOf(address account) external view returns (uint256); 178 | 179 | /** 180 | * @notice Moves `amount` tokens from the caller's account to `recipient`. 181 | * 182 | * Most implementing contracts return a boolean value indicating whether the operation succeeded, but 183 | * we ignore this and rely on asserting balance changes instead 184 | * 185 | * Emits a {Transfer} event. 186 | */ 187 | function transfer(address recipient, uint256 amount) external; 188 | 189 | /** 190 | * @notice Returns the remaining number of tokens that `spender` will be 191 | * allowed to spend on behalf of `owner` through {transferFrom}. This is 192 | * zero by default. 193 | * 194 | * This value changes when {approve} or {transferFrom} are called. 195 | */ 196 | function allowance(address owner, address spender) 197 | external 198 | view 199 | returns (uint256); 200 | 201 | /** 202 | * @notice Sets `amount` as the allowance of `spender` over the caller's tokens. 203 | * 204 | * Returns a boolean value indicating whether the operation succeeded. 205 | * 206 | * IMPORTANT: Beware that changing an allowance with this method brings the risk 207 | * that someone may use both the old and the new allowance by unfortunate 208 | * transaction ordering. One possible solution to mitigate this race 209 | * condition is to first reduce the spender's allowance to 0 and set the 210 | * desired value afterwards: 211 | * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 212 | * 213 | * Emits an {Approval} event. 214 | */ 215 | function approve(address spender, uint256 amount) external returns (bool); 216 | 217 | /** 218 | * @notice Moves `amount` tokens from `sender` to `recipient` using the 219 | * allowance mechanism. `amount` is then deducted from the caller's 220 | * allowance. 221 | * 222 | * Most implementing contracts return a boolean value indicating whether the operation succeeded, but 223 | * we ignore this and rely on asserting balance changes instead 224 | * 225 | * Emits a {Transfer} event. 226 | */ 227 | function transferFrom( 228 | address sender, 229 | address recipient, 230 | uint256 amount 231 | ) external; 232 | 233 | /** 234 | * @notice Emitted when `value` tokens are moved from one account (`from`) to 235 | * another (`to`). 236 | * 237 | * Note that `value` may be zero. 238 | */ 239 | event Transfer(address indexed from, address indexed to, uint256 value); 240 | 241 | /** 242 | * @notice Emitted when the allowance of a `spender` for an `owner` is set by 243 | * a call to {approve}. `value` is the new allowance. 244 | */ 245 | event Approval(address indexed owner, address indexed spender, uint256 value); 246 | } 247 | 248 | 249 | /** 250 | * @notice Interface to Custodian contract. Used by Exchange and Governance contracts for internal 251 | * delegate calls 252 | */ 253 | interface ICustodian { 254 | /** 255 | * @notice ETH can only be sent by the Exchange 256 | */ 257 | receive() external payable; 258 | 259 | /** 260 | * @notice Withdraw any asset and amount to a target wallet 261 | * 262 | * @dev No balance checking performed 263 | * 264 | * @param wallet The wallet to which assets will be returned 265 | * @param asset The address of the asset to withdraw (ETH or ERC-20 contract) 266 | * @param quantityInAssetUnits The quantity in asset units to withdraw 267 | */ 268 | function withdraw( 269 | address payable wallet, 270 | address asset, 271 | uint256 quantityInAssetUnits 272 | ) external; 273 | 274 | /** 275 | * @notice Load address of the currently whitelisted Exchange contract 276 | * 277 | * @return The address of the currently whitelisted Exchange contract 278 | */ 279 | function loadExchange() external view returns (address); 280 | 281 | /** 282 | * @notice Sets a new Exchange contract address 283 | * 284 | * @param newExchange The address of the new whitelisted Exchange contract 285 | */ 286 | function setExchange(address newExchange) external; 287 | 288 | /** 289 | * @notice Load address of the currently whitelisted Governance contract 290 | * 291 | * @return The address of the currently whitelisted Governance contract 292 | */ 293 | function loadGovernance() external view returns (address); 294 | 295 | /** 296 | * @notice Sets a new Governance contract address 297 | * 298 | * @param newGovernance The address of the new whitelisted Governance contract 299 | */ 300 | function setGovernance(address newGovernance) external; 301 | } 302 | 303 | 304 | /** 305 | * @notice Interface to Exchange contract. Provided only to document struct usage 306 | */ 307 | interface IExchange { 308 | /** 309 | * @notice Settles a trade between two orders submitted and matched off-chain 310 | * 311 | * @param buy A `Structs.Order` struct encoding the parameters of the buy-side order (receiving base, giving quote) 312 | * @param sell A `Structs.Order` struct encoding the parameters of the sell-side order (giving base, receiving quote) 313 | * @param trade A `Structs.Trade` struct encoding the parameters of this trade execution of the counterparty orders 314 | */ 315 | function executeTrade( 316 | Structs.Order calldata buy, 317 | Structs.Order calldata sell, 318 | Structs.Trade calldata trade 319 | ) external; 320 | 321 | /** 322 | * @notice Settles a user withdrawal submitted off-chain. Calls restricted to currently whitelisted Dispatcher wallet 323 | * 324 | * @param withdrawal A `Structs.Withdrawal` struct encoding the parameters of the withdrawal 325 | */ 326 | function withdraw(Structs.Withdrawal calldata withdrawal) external; 327 | } 328 | -------------------------------------------------------------------------------- /contracts/libraries/SafeMath64.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | 5 | 6 | /** 7 | * @dev Wrappers over Solidity's arithmetic operations with added overflow 8 | * checks. 9 | * 10 | * Arithmetic operations in Solidity wrap on overflow. This can easily result 11 | * in bugs, because programmers usually assume that an overflow raises an 12 | * error, which is the standard behavior in high level programming languages. 13 | * `SafeMath` restores this intuition by reverting the transaction when an 14 | * operation overflows. 15 | * 16 | * Using this library instead of the unchecked operations eliminates an entire 17 | * class of bugs, so it's recommended to use it always. 18 | */ 19 | library SafeMath64 { 20 | /** 21 | * @dev Returns the addition of two unsigned integers, reverting on 22 | * overflow. 23 | * 24 | * Counterpart to Solidity's `+` operator. 25 | * 26 | * Requirements: 27 | * - Addition cannot overflow. 28 | */ 29 | function add(uint64 a, uint64 b) internal pure returns (uint64) { 30 | uint64 c = a + b; 31 | require(c >= a, 'SafeMath: addition overflow'); 32 | 33 | return c; 34 | } 35 | 36 | /** 37 | * @dev Returns the subtraction of two unsigned integers, reverting on 38 | * overflow (when the result is negative). 39 | * 40 | * Counterpart to Solidity's `-` operator. 41 | * 42 | * Requirements: 43 | * - Subtraction cannot overflow. 44 | */ 45 | function sub(uint64 a, uint64 b) internal pure returns (uint64) { 46 | return sub(a, b, 'SafeMath: subtraction overflow'); 47 | } 48 | 49 | /** 50 | * @dev Returns the subtraction of two unsigned integers, reverting with custom message on 51 | * overflow (when the result is negative). 52 | * 53 | * Counterpart to Solidity's `-` operator. 54 | * 55 | * Requirements: 56 | * - Subtraction cannot overflow. 57 | * 58 | * _Available since v2.4.0._ 59 | */ 60 | function sub( 61 | uint64 a, 62 | uint64 b, 63 | string memory errorMessage 64 | ) internal pure returns (uint64) { 65 | require(b <= a, errorMessage); 66 | uint64 c = a - b; 67 | 68 | return c; 69 | } 70 | 71 | /** 72 | * @dev Returns the multiplication of two unsigned integers, reverting on 73 | * overflow. 74 | * 75 | * Counterpart to Solidity's `*` operator. 76 | * 77 | * Requirements: 78 | * - Multiplication cannot overflow. 79 | */ 80 | function mul(uint64 a, uint64 b) internal pure returns (uint64) { 81 | // Gas optimization: this is cheaper than requiring 'a' not being zero, but the 82 | // benefit is lost if 'b' is also tested. 83 | // See: https://github.com/OpenZeppelin/openzeppelin-contracts/pull/522 84 | if (a == 0) { 85 | return 0; 86 | } 87 | 88 | uint64 c = a * b; 89 | require(c / a == b, 'SafeMath: multiplication overflow'); 90 | 91 | return c; 92 | } 93 | 94 | /** 95 | * @dev Returns the integer division of two unsigned integers. Reverts on 96 | * division by zero. The result is rounded towards zero. 97 | * 98 | * Counterpart to Solidity's `/` operator. Note: this function uses a 99 | * `revert` opcode (which leaves remaining gas untouched) while Solidity 100 | * uses an invalid opcode to revert (consuming all remaining gas). 101 | * 102 | * Requirements: 103 | * - The divisor cannot be zero. 104 | */ 105 | function div(uint64 a, uint64 b) internal pure returns (uint64) { 106 | return div(a, b, 'SafeMath: division by zero'); 107 | } 108 | 109 | /** 110 | * @dev Returns the integer division of two unsigned integers. Reverts with custom message on 111 | * division by zero. The result is rounded towards zero. 112 | * 113 | * Counterpart to Solidity's `/` operator. Note: this function uses a 114 | * `revert` opcode (which leaves remaining gas untouched) while Solidity 115 | * uses an invalid opcode to revert (consuming all remaining gas). 116 | * 117 | * Requirements: 118 | * - The divisor cannot be zero. 119 | * 120 | * _Available since v2.4.0._ 121 | */ 122 | function div( 123 | uint64 a, 124 | uint64 b, 125 | string memory errorMessage 126 | ) internal pure returns (uint64) { 127 | // Solidity only automatically asserts when dividing by 0 128 | require(b > 0, errorMessage); 129 | uint64 c = a / b; 130 | // assert(a == b * c + a % b); // There is no case in which this doesn't hold 131 | 132 | return c; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /contracts/libraries/Signatures.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { ECDSA } from '@openzeppelin/contracts/cryptography/ECDSA.sol'; 7 | 8 | import { Enums, Structs } from './Interfaces.sol'; 9 | 10 | 11 | /** 12 | * Library helpers for building hashes and verifying wallet signatures on `Order` and `Withdrawal` structs 13 | */ 14 | library Signatures { 15 | function isSignatureValid( 16 | bytes32 hash, 17 | bytes memory signature, 18 | address signer 19 | ) internal pure returns (bool) { 20 | return 21 | ECDSA.recover(ECDSA.toEthSignedMessageHash(hash), signature) == signer; 22 | } 23 | 24 | function getOrderWalletHash( 25 | Structs.Order memory order, 26 | string memory baseSymbol, 27 | string memory quoteSymbol 28 | ) internal pure returns (bytes32) { 29 | require( 30 | order.signatureHashVersion == 1, 31 | 'Signature hash version must be 1' 32 | ); 33 | return 34 | keccak256( 35 | // Placing all the fields in a single `abi.encodePacked` call causes a `stack too deep` error 36 | abi.encodePacked( 37 | abi.encodePacked( 38 | order.signatureHashVersion, 39 | order.nonce, 40 | order.walletAddress, 41 | getMarketSymbol(baseSymbol, quoteSymbol), 42 | uint8(order.orderType), 43 | uint8(order.side), 44 | // Ledger qtys and prices are in pip, but order was signed by wallet owner with decimal values 45 | pipToDecimal(order.quantityInPips) 46 | ), 47 | abi.encodePacked( 48 | order.isQuantityInQuote, 49 | order.limitPriceInPips > 0 50 | ? pipToDecimal(order.limitPriceInPips) 51 | : '', 52 | order.stopPriceInPips > 0 53 | ? pipToDecimal(order.stopPriceInPips) 54 | : '', 55 | order.clientOrderId, 56 | uint8(order.timeInForce), 57 | uint8(order.selfTradePrevention), 58 | order.cancelAfter 59 | ) 60 | ) 61 | ); 62 | } 63 | 64 | function getWithdrawalWalletHash(Structs.Withdrawal memory withdrawal) 65 | internal 66 | pure 67 | returns (bytes32) 68 | { 69 | return 70 | keccak256( 71 | abi.encodePacked( 72 | withdrawal.nonce, 73 | withdrawal.walletAddress, 74 | // Ternary branches must resolve to the same type, so wrap in idempotent encodePacked 75 | withdrawal.withdrawalType == Enums.WithdrawalType.BySymbol 76 | ? abi.encodePacked(withdrawal.assetSymbol) 77 | : abi.encodePacked(withdrawal.assetAddress), 78 | pipToDecimal(withdrawal.quantityInPips), 79 | withdrawal.autoDispatchEnabled 80 | ) 81 | ); 82 | } 83 | 84 | /** 85 | * @dev Combines base and quote asset symbols into the market symbol originally signed by the 86 | * wallet. For example if base is 'IDEX' and quote is 'ETH', the resulting market symbol is 87 | * 'IDEX-ETH'. This approach is used rather than passing in the market symbol and splitting it 88 | * since the latter incurs a higher gas cost 89 | */ 90 | function getMarketSymbol(string memory baseSymbol, string memory quoteSymbol) 91 | private 92 | pure 93 | returns (string memory) 94 | { 95 | bytes memory baseSymbolBytes = bytes(baseSymbol); 96 | bytes memory hyphenBytes = bytes('-'); 97 | bytes memory quoteSymbolBytes = bytes(quoteSymbol); 98 | 99 | bytes memory marketSymbolBytes = bytes( 100 | new string( 101 | baseSymbolBytes.length + quoteSymbolBytes.length + hyphenBytes.length 102 | ) 103 | ); 104 | 105 | uint256 i; 106 | uint256 j; 107 | 108 | for (i = 0; i < baseSymbolBytes.length; i++) { 109 | marketSymbolBytes[j++] = baseSymbolBytes[i]; 110 | } 111 | 112 | // Hyphen is one byte 113 | marketSymbolBytes[j++] = hyphenBytes[0]; 114 | 115 | for (i = 0; i < quoteSymbolBytes.length; i++) { 116 | marketSymbolBytes[j++] = quoteSymbolBytes[i]; 117 | } 118 | 119 | return string(marketSymbolBytes); 120 | } 121 | 122 | /** 123 | * @dev Converts an integer pip quantity back into the fixed-precision decimal pip string 124 | * originally signed by the wallet. For example, 1234567890 becomes '12.34567890' 125 | */ 126 | function pipToDecimal(uint256 pips) private pure returns (string memory) { 127 | // Inspired by https://github.com/provable-things/ethereum-api/blob/831f4123816f7a3e57ebea171a3cdcf3b528e475/oraclizeAPI_0.5.sol#L1045-L1062 128 | uint256 copy = pips; 129 | uint256 length; 130 | while (copy != 0) { 131 | length++; 132 | copy /= 10; 133 | } 134 | if (length < 9) { 135 | length = 9; // a zero before the decimal point plus 8 decimals 136 | } 137 | length++; // for the decimal point 138 | 139 | bytes memory decimal = new bytes(length); 140 | for (uint256 i = length; i > 0; i--) { 141 | if (length - i == 8) { 142 | decimal[i - 1] = bytes1(uint8(46)); // period 143 | } else { 144 | decimal[i - 1] = bytes1(uint8(48 + (pips % 10))); 145 | pips /= 10; 146 | } 147 | } 148 | return string(decimal); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /contracts/libraries/UUID.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | 5 | import { SafeMath64 } from './SafeMath64.sol'; 6 | 7 | 8 | /** 9 | * Library helper for extracting timestamp component of Version 1 UUIDs 10 | */ 11 | library UUID { 12 | using SafeMath64 for uint64; 13 | 14 | /** 15 | * Extracts the timestamp component of a Version 1 UUID. Used to make time-based assertions 16 | * against a wallet-privided nonce 17 | */ 18 | function getTimestampInMsFromUuidV1(uint128 uuid) 19 | internal 20 | pure 21 | returns (uint64 msSinceUnixEpoch) 22 | { 23 | // https://tools.ietf.org/html/rfc4122#section-4.1.2 24 | uint128 version = (uuid >> 76) & 0x0000000000000000000000000000000F; 25 | require(version == 1, 'Must be v1 UUID'); 26 | 27 | // Time components are in reverse order so shift+mask each to reassemble 28 | uint128 timeHigh = (uuid >> 16) & 0x00000000000000000FFF000000000000; 29 | uint128 timeMid = (uuid >> 48) & 0x00000000000000000000FFFF00000000; 30 | uint128 timeLow = (uuid >> 96) & 0x000000000000000000000000FFFFFFFF; 31 | uint128 nsSinceGregorianEpoch = (timeHigh | timeMid | timeLow); 32 | // Gregorian offset given in seconds by https://www.wolframalpha.com/input/?i=convert+1582-10-15+UTC+to+unix+time 33 | msSinceUnixEpoch = uint64(nsSinceGregorianEpoch / 10000).sub( 34 | 12219292800000 35 | ); 36 | 37 | return msSinceUnixEpoch; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /contracts/test/AssetsMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { AssetUnitConversions } from '../libraries/AssetUnitConversions.sol'; 7 | 8 | 9 | contract AssetsMock { 10 | function pipsToAssetUnits(uint64 quantityInPips, uint8 assetDecimals) 11 | external 12 | pure 13 | returns (uint256) 14 | { 15 | return AssetUnitConversions.pipsToAssetUnits(quantityInPips, assetDecimals); 16 | } 17 | 18 | function assetUnitsToPips(uint256 quantityInAssetUnits, uint8 assetDecimals) 19 | external 20 | pure 21 | returns (uint64) 22 | { 23 | return 24 | AssetUnitConversions.assetUnitsToPips( 25 | quantityInAssetUnits, 26 | assetDecimals 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /contracts/test/ExchangeMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity ^0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { AssetTransfers } from '../libraries/AssetTransfers.sol'; 7 | 8 | 9 | interface ICustodian { 10 | receive() external payable; 11 | 12 | function withdraw( 13 | address payable wallet, 14 | address asset, 15 | uint256 quantityInAssetUnits 16 | ) external; 17 | } 18 | 19 | 20 | contract ExchangeMock { 21 | ICustodian _custodian; 22 | 23 | receive() external payable { 24 | AssetTransfers.transferTo(address(_custodian), address(0x0), msg.value); 25 | } 26 | 27 | function setCustodian(ICustodian newCustodian) external { 28 | _custodian = newCustodian; 29 | } 30 | 31 | function withdraw( 32 | address payable wallet, 33 | address asset, 34 | uint256 quantityInAssetUnits 35 | ) external { 36 | _custodian.withdraw(wallet, asset, quantityInAssetUnits); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /contracts/test/GovernanceMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity ^0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { AssetTransfers } from '../libraries/AssetTransfers.sol'; 7 | 8 | 9 | interface ICustodian { 10 | receive() external payable; 11 | 12 | function setExchange(address exchange) external; 13 | 14 | function setGovernance(address governance) external; 15 | } 16 | 17 | 18 | contract GovernanceMock { 19 | ICustodian _custodian; 20 | 21 | function setCustodian(ICustodian newCustodian) external { 22 | _custodian = newCustodian; 23 | } 24 | 25 | function setExchange(address newExchange) external { 26 | _custodian.setExchange(newExchange); 27 | } 28 | 29 | function setGovernance(address newGovernance) external { 30 | _custodian.setGovernance(newGovernance); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /contracts/test/NonCompliantToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity ^0.6.8; 4 | 5 | 6 | contract NonCompliantToken { 7 | event Transfer(address indexed _from, address indexed _to, uint256 _value); 8 | event Approval( 9 | address indexed _owner, 10 | address indexed _spender, 11 | uint256 _value 12 | ); 13 | 14 | uint256 public totalSupply; 15 | uint256 private constant MAX_UINT256 = 2**256 - 1; 16 | mapping(address => uint256) public balances; 17 | mapping(address => mapping(address => uint256)) public allowed; 18 | /* 19 | NOTE: 20 | The following variables are OPTIONAL vanities. One does not have to include them. 21 | They allow one to customise the token contract & in no way influences the core functionality. 22 | Some wallets/interfaces might not even bother to look at this information. 23 | */ 24 | string public name; //fancy name: eg Simon Bucks 25 | uint8 public decimals; //How many decimals to show. 26 | string public symbol; //An identifier: eg SBX 27 | 28 | constructor() public { 29 | balances[msg.sender] = 1000000000000000000000; // Give the creator all initial tokens 30 | totalSupply = 1000000000000000000000; // Update total supply 31 | name = 'NoncompliantToken'; // Set the name for display purposes 32 | decimals = 18; // Amount of decimals for display purposes 33 | symbol = 'NCT'; // Set the symbol for display purposes 34 | } 35 | 36 | function transfer(address _to, uint256 _value) public { 37 | balances[msg.sender] -= _value; 38 | balances[_to] += _value; 39 | emit Transfer(msg.sender, _to, _value); //solhint-disable-line indent, no-unused-vars 40 | } 41 | 42 | function transferFrom( 43 | address _from, 44 | address _to, 45 | uint256 _value 46 | ) public { 47 | balances[_to] += _value; 48 | balances[_from] -= _value; 49 | allowed[_from][msg.sender] -= _value; 50 | emit Transfer(_from, _to, _value); //solhint-disable-line indent, no-unused-vars 51 | } 52 | 53 | function balanceOf(address _owner) public view returns (uint256 balance) { 54 | return balances[_owner]; 55 | } 56 | 57 | function approve(address _spender, uint256 _value) 58 | public 59 | returns (bool success) 60 | { 61 | allowed[msg.sender][_spender] = _value; 62 | emit Approval(msg.sender, _spender, _value); //solhint-disable-line indent, no-unused-vars 63 | return true; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /contracts/test/SafeMath64Mock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | 5 | import '../libraries/SafeMath64.sol'; 6 | 7 | 8 | contract SafeMath64Mock { 9 | function mul(uint64 a, uint64 b) public pure returns (uint64) { 10 | return SafeMath64.mul(a, b); 11 | } 12 | 13 | function div(uint64 a, uint64 b) public pure returns (uint64) { 14 | return SafeMath64.div(a, b); 15 | } 16 | 17 | function sub(uint64 a, uint64 b) public pure returns (uint64) { 18 | return SafeMath64.sub(a, b); 19 | } 20 | 21 | function add(uint64 a, uint64 b) public pure returns (uint64) { 22 | return SafeMath64.add(a, b); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /contracts/test/SkimmingTestToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | 5 | import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; 6 | 7 | 8 | /** 9 | * @dev Used to test `Transfers` library's balance change verification 10 | */ 11 | contract SkimmingTestToken is ERC20 { 12 | uint256 public INITIAL_SUPPLY = 1000000000000000000000; 13 | 14 | bool _shouldSkim; 15 | 16 | constructor() public ERC20('TestToken', 'TKN') { 17 | _mint(msg.sender, INITIAL_SUPPLY); 18 | } 19 | 20 | function setShouldSkim(bool shouldSkim) external { 21 | _shouldSkim = shouldSkim; 22 | } 23 | 24 | function transfer(address recipient, uint256 amount) 25 | public 26 | virtual 27 | override 28 | returns (bool) 29 | { 30 | if (_shouldSkim) { 31 | _transfer(_msgSender(), recipient, amount - 1); // Skim 1 32 | } else { 33 | _transfer(_msgSender(), recipient, amount); 34 | } 35 | return true; 36 | } 37 | 38 | function transferFrom( 39 | address sender, 40 | address recipient, 41 | uint256 amount 42 | ) public virtual override returns (bool) { 43 | if (_shouldSkim) { 44 | _transfer(sender, recipient, amount - 1); // Skim 1 45 | } else { 46 | _transfer(sender, recipient, amount); 47 | } 48 | return true; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /contracts/test/TestToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | 5 | import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; 6 | 7 | 8 | contract TestToken is ERC20 { 9 | uint256 public INITIAL_SUPPLY = 10**32; 10 | 11 | constructor() public ERC20('TestToken', 'TKN') { 12 | _mint(msg.sender, INITIAL_SUPPLY); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /contracts/test/UUIDMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | 3 | pragma solidity 0.6.8; 4 | pragma experimental ABIEncoderV2; 5 | 6 | import { UUID } from '../libraries/UUID.sol'; 7 | 8 | 9 | contract UUIDMock { 10 | function getTimestampInMsFromUuidV1(uint128 uuid) 11 | external 12 | pure 13 | returns (uint64) 14 | { 15 | return UUID.getTimestampInMsFromUuidV1(uuid); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { ethers } from 'ethers'; 3 | 4 | import { ExchangeInstance } from '../types/truffle-contracts/Exchange'; 5 | 6 | /** The fixed number of digits following the decimal in quantities expressed as pips */ 7 | export const pipsDecimals = 8; 8 | 9 | export enum OrderSelfTradePrevention { 10 | DecreaseAndCancel, 11 | CancelOldest, 12 | CancelNewest, 13 | CancelBoth, 14 | } 15 | export enum OrderSide { 16 | Buy, 17 | Sell, 18 | } 19 | export enum OrderTimeInForce { 20 | GTC, 21 | GTT, 22 | IOC, 23 | FOK, 24 | } 25 | export enum OrderType { 26 | Market, 27 | Limit, 28 | LimitMaker, 29 | StopLoss, 30 | StopLossLimit, 31 | TakeProfit, 32 | TakeProfitLimit, 33 | } 34 | export interface Order { 35 | signatureHashVersion: number; 36 | nonce: string; 37 | wallet: string; 38 | market: string; 39 | type: OrderType; 40 | side: OrderSide; 41 | timeInForce?: OrderTimeInForce; 42 | quantity: string; 43 | isQuantityInQuote: boolean; 44 | price: string; 45 | stopPrice?: string; 46 | clientOrderId?: string; 47 | selfTradePrevention?: OrderSelfTradePrevention; 48 | cancelAfter?: number; 49 | } 50 | export interface Trade { 51 | baseAssetAddress: string; 52 | quoteAssetAddress: string; 53 | grossBaseQuantity: string; 54 | grossQuoteQuantity: string; 55 | netBaseQuantity: string; 56 | netQuoteQuantity: string; 57 | makerFeeAssetAddress: string; 58 | takerFeeAssetAddress: string; 59 | makerFeeQuantity: string; 60 | takerFeeQuantity: string; 61 | price: string; 62 | makerSide: OrderSide; 63 | } 64 | 65 | enum WithdrawalType { 66 | BySymbol, 67 | ByAddress, 68 | } 69 | export interface Withdrawal { 70 | nonce: string; 71 | wallet: string; 72 | quantity: string; // Decimal string 73 | autoDispatchEnabled: boolean; // Currently has no effect 74 | asset?: string; 75 | assetContractAddress?: string; 76 | } 77 | 78 | export const ethAddress = '0x0000000000000000000000000000000000000000'; 79 | 80 | export const getOrderHash = (order: Order): string => 81 | solidityHashOfParams([ 82 | ['uint8', order.signatureHashVersion], // Signature hash version - only version 1 supported 83 | ['uint128', uuidToUint8Array(order.nonce)], 84 | ['address', order.wallet], 85 | ['string', order.market], 86 | ['uint8', order.type], 87 | ['uint8', order.side], 88 | ['string', order.quantity], 89 | ['bool', order.isQuantityInQuote], 90 | ['string', order.price || ''], 91 | ['string', order.stopPrice || ''], 92 | ['string', order.clientOrderId || ''], 93 | ['uint8', order.timeInForce || 0], 94 | ['uint8', order.selfTradePrevention || 0], 95 | ['uint64', order.cancelAfter || 0], 96 | ]); 97 | 98 | export const getWithdrawalHash = (withdrawal: Withdrawal): string => { 99 | if ( 100 | (withdrawal.asset && withdrawal.assetContractAddress) || 101 | (!withdrawal.asset && !withdrawal.assetContractAddress) 102 | ) { 103 | throw new Error( 104 | 'Withdrawal must specify exactly one of asset or assetContractAddress', 105 | ); 106 | } 107 | 108 | return solidityHashOfParams([ 109 | ['uint128', uuidToUint8Array(withdrawal.nonce)], 110 | ['address', withdrawal.wallet], 111 | withdrawal.asset 112 | ? ['string', withdrawal.asset] 113 | : ['address', withdrawal.assetContractAddress as string], 114 | ['string', withdrawal.quantity], 115 | ['bool', true], // autoDispatchEnabled 116 | ]); 117 | }; 118 | 119 | export const getTradeArguments = ( 120 | buyOrder: Order, 121 | buyWalletSignature: string, 122 | sellOrder: Order, 123 | sellWalletSignature: string, 124 | trade: Trade, 125 | ): ExchangeInstance['executeTrade']['arguments'] => { 126 | const orderToArgumentStruct = (o: Order, walletSignature: string) => { 127 | return { 128 | signatureHashVersion: o.signatureHashVersion, 129 | nonce: uuidToHexString(o.nonce), 130 | walletAddress: o.wallet, 131 | orderType: o.type, 132 | side: o.side, 133 | quantityInPips: decimalToPips(o.quantity), 134 | isQuantityInQuote: o.isQuantityInQuote, 135 | limitPriceInPips: decimalToPips(o.price || '0'), 136 | stopPriceInPips: decimalToPips(o.stopPrice || '0'), 137 | clientOrderId: o.clientOrderId || '', 138 | timeInForce: o.timeInForce || 0, 139 | selfTradePrevention: o.selfTradePrevention || 0, 140 | cancelAfter: o.cancelAfter || 0, 141 | walletSignature, 142 | }; 143 | }; 144 | const tradeToArgumentStruct = (t: Trade) => { 145 | return { 146 | baseAssetSymbol: buyOrder.market.split('-')[0], 147 | quoteAssetSymbol: buyOrder.market.split('-')[1], 148 | baseAssetAddress: t.baseAssetAddress, 149 | quoteAssetAddress: t.quoteAssetAddress, 150 | grossBaseQuantityInPips: decimalToPips(t.grossBaseQuantity), 151 | grossQuoteQuantityInPips: decimalToPips(t.grossQuoteQuantity), 152 | netBaseQuantityInPips: decimalToPips(t.netBaseQuantity), 153 | netQuoteQuantityInPips: decimalToPips(t.netQuoteQuantity), 154 | makerFeeAssetAddress: t.makerFeeAssetAddress, 155 | takerFeeAssetAddress: t.takerFeeAssetAddress, 156 | makerFeeQuantityInPips: decimalToPips(t.makerFeeQuantity), 157 | takerFeeQuantityInPips: decimalToPips(t.takerFeeQuantity), 158 | priceInPips: decimalToPips(t.price), 159 | makerSide: t.makerSide, 160 | }; 161 | }; 162 | return [ 163 | orderToArgumentStruct(buyOrder, buyWalletSignature), 164 | orderToArgumentStruct(sellOrder, sellWalletSignature), 165 | tradeToArgumentStruct(trade), 166 | ] as const; 167 | }; 168 | 169 | export const getWithdrawArguments = ( 170 | withdrawal: Withdrawal, 171 | gasFee: string, 172 | walletSignature: string, 173 | ): ExchangeInstance['withdraw']['arguments'] => { 174 | return [ 175 | { 176 | withdrawalType: withdrawal.asset 177 | ? WithdrawalType.BySymbol 178 | : WithdrawalType.ByAddress, 179 | nonce: uuidToHexString(withdrawal.nonce), 180 | walletAddress: withdrawal.wallet, 181 | assetSymbol: withdrawal.asset || '', 182 | assetAddress: withdrawal.assetContractAddress || ethAddress, 183 | quantityInPips: decimalToPips(withdrawal.quantity), 184 | gasFeeInPips: decimalToPips(gasFee), 185 | autoDispatchEnabled: true, 186 | walletSignature, 187 | }, 188 | ]; 189 | }; 190 | 191 | type TypeValuePair = 192 | | ['string' | 'address', string] 193 | | ['uint128', string | Uint8Array] 194 | | ['uint8' | 'uint64', number] 195 | | ['bool', boolean]; 196 | 197 | const solidityHashOfParams = (params: TypeValuePair[]): string => { 198 | const fields = params.map((param) => param[0]); 199 | const values = params.map((param) => param[1]); 200 | return ethers.utils.solidityKeccak256(fields, values); 201 | }; 202 | 203 | export const uuidToUint8Array = (uuid: string): Uint8Array => 204 | ethers.utils.arrayify(uuidToHexString(uuid)); 205 | 206 | export const uuidToHexString = (uuid: string): string => 207 | `0x${uuid.replace(/-/g, '')}`; 208 | 209 | /** 210 | * Convert decimal quantity string to integer pips as expected by contract structs. Truncates 211 | * anything beyond 8 decimals 212 | */ 213 | export const decimalToPips = (decimal: string): string => 214 | new BigNumber(decimal) 215 | .shiftedBy(8) 216 | .integerValue(BigNumber.ROUND_DOWN) 217 | .toFixed(0); 218 | 219 | /** 220 | * Convert pips to native token quantity, taking the nunmber of decimals into account 221 | */ 222 | export const pipsToAssetUnits = (pips: string, decimals: number): string => 223 | new BigNumber(pips) 224 | .shiftedBy(decimals - 8) // This is still correct when decimals < 8 225 | .integerValue(BigNumber.ROUND_DOWN) 226 | .toFixed(0); 227 | 228 | /** 229 | * Convert pips to native token quantity, taking the nunmber of decimals into account 230 | */ 231 | export const assetUnitsToPips = ( 232 | assetUnits: string, 233 | decimals: number, 234 | ): string => 235 | new BigNumber(assetUnits) 236 | .shiftedBy(8 - decimals) // This is still correct when decimals > 8 237 | .integerValue(BigNumber.ROUND_DOWN) 238 | .toString(); 239 | 240 | export const decimalToAssetUnits = ( 241 | decimal: string, 242 | decimals: number, 243 | ): string => pipsToAssetUnits(decimalToPips(decimal), decimals); 244 | -------------------------------------------------------------------------------- /migrations/001_initial_migration.js: -------------------------------------------------------------------------------- 1 | const Migrations = artifacts.require("Migrations"); 2 | 3 | module.exports = function(deployer) { 4 | deployer.deploy(Migrations); 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@idexio/idex-contracts", 3 | "repository": { 4 | "type": "git", 5 | "url": "git+https://github.com/idexio/idex-contracts.git" 6 | }, 7 | "private": true, 8 | "scripts": { 9 | "analyze": "slither .", 10 | "build": "yarn build:sol && yarn build:ts", 11 | "build:sol": "truffle compile && yarn generate-types", 12 | "build:ts": "tsc -p .", 13 | "clean": "rm -rf build; rm -rf types/truffle-contracts; rm -rf coverage", 14 | "coverage": "multi='spec=- json=./coverage/mocha-summary.json' truffle run coverage && node bin/get-badges.js", 15 | "generate-types": "typechain --target=truffle-v5 'build/contracts/*.json'", 16 | "lint:markdown": "markdownlint README.md", 17 | "prettier": "prettier --write **/*.sol", 18 | "test": "multi='spec=- json=./coverage/mocha-summary.json' truffle test", 19 | "verify": "truffle run verify" 20 | }, 21 | "dependencies": { 22 | "@openzeppelin/test-helpers": "^0.5.5", 23 | "bignumber.js": "^9.0.0", 24 | "chai": "^4.2.0", 25 | "chai-bn": "^0.2.1", 26 | "ethers": "^4.0.47", 27 | "uuid": "^8.0.0" 28 | }, 29 | "devDependencies": { 30 | "@commitlint/cli": "^8.2.0", 31 | "@commitlint/config-conventional": "^8.2.0", 32 | "@openzeppelin/contracts": "3.2.0", 33 | "@truffle/debug-utils": "^4.1.1", 34 | "@typechain/truffle-v5": "^2.0.0", 35 | "@types/chai": "^4.2.11", 36 | "@types/mocha": "^7.0.2", 37 | "@types/uuid": "^7.0.3", 38 | "@typescript-eslint/eslint-plugin": "^2.3.2", 39 | "@typescript-eslint/parser": "^2.3.2", 40 | "eslint": "^6.8.0", 41 | "eslint-config-airbnb-base": "^14.0.0", 42 | "eslint-config-prettier": "^6.3.0", 43 | "eslint-import-resolver-typescript": "^2.0.0", 44 | "eslint-plugin-chai-expect": "^2.1.0", 45 | "eslint-plugin-import": "^2.18.2", 46 | "eslint-plugin-prettier": "^3.1.1", 47 | "eslint-plugin-promise": "^4.2.1", 48 | "eslint-plugin-truffle": "^0.3.1", 49 | "husky": "^4.2.5", 50 | "markdownlint-cli": "^0.22.0", 51 | "mocha-multi": "^1.1.3", 52 | "prettier": "^2.0.2", 53 | "prettier-eslint": "^9.0.0", 54 | "prettier-eslint-cli": "^5.0.0", 55 | "prettier-plugin-solidity": "^1.0.0-alpha.50", 56 | "solidity-coverage": "^0.7.5", 57 | "truffle": "^5.1.24", 58 | "truffle-security": "^1.7.1", 59 | "ts-node": "^8.4.1", 60 | "ts-node-dev": "^1.0.0-pre.43", 61 | "typechain": "^2.0.0", 62 | "typescript": "3.8.3", 63 | "web3": "^1.2.7", 64 | "web3-eth-contract": "^1.2.7" 65 | }, 66 | "husky": { 67 | "hooks": { 68 | "commit-msg": "[[ -n $HUSKY_BYPASS ]] || commitlint -E HUSKY_GIT_PARAMS", 69 | "pre-commit": "yarn lint:markdown && yarn clean && yarn build && yarn coverage && git add assets/coverage-*.svg && git add assets/tests.svg" 70 | } 71 | }, 72 | "commitlint": { 73 | "extends": [ 74 | "@commitlint/config-conventional" 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/assets.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | 3 | import { 4 | deployAndAssociateContracts, 5 | deployAndRegisterToken, 6 | ethSymbol, 7 | } from './helpers'; 8 | import { ethAddress } from '../lib'; 9 | import { AssetsMockInstance } from '../types/truffle-contracts/AssetsMock'; 10 | 11 | contract('Exchange (tokens)', () => { 12 | const AssetsMock = artifacts.require('AssetsMock'); 13 | const Token = artifacts.require('TestToken'); 14 | 15 | const tokenSymbol = 'TKN'; 16 | 17 | describe('registerToken', () => { 18 | it('should work', async () => { 19 | const { exchange } = await deployAndAssociateContracts(); 20 | const token = await Token.new(); 21 | 22 | await exchange.registerToken(token.address, tokenSymbol, 18); 23 | }); 24 | 25 | it('should revert when token has too many decimals', async () => { 26 | const { exchange } = await deployAndAssociateContracts(); 27 | const token = await Token.new(); 28 | 29 | let error; 30 | try { 31 | await exchange.registerToken(token.address, tokenSymbol, 100); 32 | } catch (e) { 33 | error = e; 34 | } 35 | expect(error).to.not.be.undefined; 36 | expect(error.message).to.match( 37 | /token cannot have more than 32 decimals/i, 38 | ); 39 | }); 40 | 41 | it('should revert for ETH address', async () => { 42 | const { exchange } = await deployAndAssociateContracts(); 43 | 44 | let error; 45 | try { 46 | await exchange.registerToken(ethAddress, tokenSymbol, 18); 47 | } catch (e) { 48 | error = e; 49 | } 50 | expect(error).to.not.be.undefined; 51 | expect(error.message).to.match(/invalid token address/i); 52 | }); 53 | 54 | it('should revert for blank symbol', async () => { 55 | const { exchange } = await deployAndAssociateContracts(); 56 | const token = await Token.new(); 57 | 58 | let error; 59 | try { 60 | await exchange.registerToken(token.address, '', 18); 61 | } catch (e) { 62 | error = e; 63 | } 64 | expect(error).to.not.be.undefined; 65 | expect(error.message).to.match(/invalid token symbol/i); 66 | }); 67 | 68 | it('should revert when already finalized', async () => { 69 | const { exchange } = await deployAndAssociateContracts(); 70 | const token = await Token.new(); 71 | 72 | await exchange.registerToken(token.address, tokenSymbol, 18); 73 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 74 | 75 | let error; 76 | try { 77 | await exchange.registerToken(token.address, tokenSymbol, 18); 78 | } catch (e) { 79 | error = e; 80 | } 81 | expect(error).to.not.be.undefined; 82 | expect(error.message).to.match(/already finalized/i); 83 | }); 84 | }); 85 | 86 | describe('confirmTokenRegistration', () => { 87 | it('should work', async () => { 88 | const { exchange } = await deployAndAssociateContracts(); 89 | const token = await Token.new(); 90 | 91 | await exchange.registerToken(token.address, tokenSymbol, 18); 92 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 93 | }); 94 | 95 | it('should revert for unknown token address', async () => { 96 | const { exchange } = await deployAndAssociateContracts(); 97 | const token = await Token.new(); 98 | const unknownToken = await Token.new(); 99 | await exchange.registerToken(token.address, tokenSymbol, 18); 100 | 101 | let error; 102 | try { 103 | await exchange.confirmTokenRegistration( 104 | unknownToken.address, 105 | tokenSymbol, 106 | 18, 107 | ); 108 | } catch (e) { 109 | error = e; 110 | } 111 | expect(error).to.not.be.undefined; 112 | expect(error.message).to.match(/unknown token/i); 113 | }); 114 | 115 | it('should revert when already finalized', async () => { 116 | const { exchange } = await deployAndAssociateContracts(); 117 | const token = await Token.new(); 118 | 119 | await exchange.registerToken(token.address, tokenSymbol, 18); 120 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 121 | 122 | let error; 123 | try { 124 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 125 | } catch (e) { 126 | error = e; 127 | } 128 | expect(error).to.not.be.undefined; 129 | expect(error.message).to.match(/already finalized/i); 130 | }); 131 | 132 | it('should revert when symbols do not match', async () => { 133 | const { exchange } = await deployAndAssociateContracts(); 134 | const token = await Token.new(); 135 | 136 | await exchange.registerToken(token.address, tokenSymbol, 18); 137 | 138 | let error; 139 | try { 140 | await exchange.confirmTokenRegistration( 141 | token.address, 142 | `${tokenSymbol}123`, 143 | 18, 144 | ); 145 | } catch (e) { 146 | error = e; 147 | } 148 | expect(error).to.not.be.undefined; 149 | expect(error.message).to.match(/symbols do not match/i); 150 | }); 151 | 152 | it('should revert when decimals do not match', async () => { 153 | const { exchange } = await deployAndAssociateContracts(); 154 | const token = await Token.new(); 155 | 156 | await exchange.registerToken(token.address, tokenSymbol, 18); 157 | 158 | let error; 159 | try { 160 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 17); 161 | } catch (e) { 162 | error = e; 163 | } 164 | expect(error).to.not.be.undefined; 165 | expect(error.message).to.match(/decimals do not match/i); 166 | }); 167 | }); 168 | 169 | describe('addTokenSymbol', () => { 170 | it('should work', async () => { 171 | const { exchange } = await deployAndAssociateContracts(); 172 | const token = await Token.new(); 173 | 174 | await exchange.registerToken(token.address, tokenSymbol, 18); 175 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 176 | await exchange.addTokenSymbol(token.address, 'NEW'); 177 | 178 | const events = await exchange.getPastEvents('TokenSymbolAdded', { 179 | fromBlock: 0, 180 | }); 181 | expect(events).to.be.an('array'); 182 | expect(events.length).to.equal(1); 183 | expect(events[0].returnValues.assetAddress).to.equal(token.address); 184 | expect(events[0].returnValues.assetSymbol).to.equal('NEW'); 185 | }); 186 | 187 | it('should revert for unregistered token', async () => { 188 | const { exchange } = await deployAndAssociateContracts(); 189 | const token = await Token.new(); 190 | 191 | let error; 192 | try { 193 | await exchange.addTokenSymbol(token.address, 'NEW'); 194 | } catch (e) { 195 | error = e; 196 | } 197 | expect(error).to.not.be.undefined; 198 | expect(error.message).to.match(/not finalized/i); 199 | }); 200 | 201 | it('should revert for unconfirmed token', async () => { 202 | const { exchange } = await deployAndAssociateContracts(); 203 | const token = await Token.new(); 204 | 205 | await exchange.registerToken(token.address, tokenSymbol, 18); 206 | 207 | let error; 208 | try { 209 | await exchange.addTokenSymbol(token.address, 'NEW'); 210 | } catch (e) { 211 | error = e; 212 | } 213 | expect(error).to.not.be.undefined; 214 | expect(error.message).to.match(/not finalized/i); 215 | }); 216 | 217 | it('should revert for reserved ETH symbol', async () => { 218 | const { exchange } = await deployAndAssociateContracts(); 219 | const token = await Token.new(); 220 | 221 | await exchange.registerToken(token.address, tokenSymbol, 18); 222 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 223 | 224 | let error; 225 | try { 226 | await exchange.addTokenSymbol(token.address, 'ETH'); 227 | } catch (e) { 228 | error = e; 229 | } 230 | expect(error).to.not.be.undefined; 231 | expect(error.message).to.match(/ETH symbol reserved/i); 232 | }); 233 | 234 | it('should revert for ETH address', async () => { 235 | const { exchange } = await deployAndAssociateContracts(); 236 | const token = await Token.new(); 237 | 238 | await exchange.registerToken(token.address, tokenSymbol, 18); 239 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 240 | 241 | let error; 242 | try { 243 | await exchange.addTokenSymbol(ethAddress, 'TKN'); 244 | } catch (e) { 245 | error = e; 246 | } 247 | expect(error).to.not.be.undefined; 248 | expect(error.message).to.match(/not finalized/i); 249 | }); 250 | }); 251 | 252 | describe('loadAssetBySymbol', () => { 253 | it('should work for ETH', async () => { 254 | const { exchange } = await deployAndAssociateContracts(); 255 | 256 | const registeredAddress = ( 257 | await exchange.loadAssetBySymbol(ethSymbol, new Date().getTime()) 258 | ).assetAddress; 259 | 260 | expect(registeredAddress).to.equal(ethAddress); 261 | }); 262 | 263 | it('should work for registered token', async () => { 264 | const { exchange } = await deployAndAssociateContracts(); 265 | const token = await deployAndRegisterToken(exchange, tokenSymbol); 266 | 267 | const registeredAddress = ( 268 | await exchange.loadAssetBySymbol(tokenSymbol, new Date().getTime()) 269 | ).assetAddress; 270 | 271 | expect(registeredAddress).to.equal(token.address); 272 | }); 273 | 274 | it('should revert when no token registered for symbol', async () => { 275 | const { exchange } = await deployAndAssociateContracts(); 276 | await deployAndRegisterToken(exchange, tokenSymbol); 277 | 278 | let error; 279 | try { 280 | await exchange.loadAssetBySymbol( 281 | `${tokenSymbol}123`, 282 | new Date().getTime(), 283 | ); 284 | } catch (e) { 285 | error = e; 286 | } 287 | expect(error).to.not.be.undefined; 288 | expect(error.message).to.match(/no confirmed asset found for symbol/i); 289 | }); 290 | 291 | it('should revert when no token registered for symbol prior to timestamp', async () => { 292 | const timestampBeforeTokenRegistered = new Date().getTime() - 10000000; 293 | const { exchange } = await deployAndAssociateContracts(); 294 | await deployAndRegisterToken(exchange, tokenSymbol); 295 | 296 | let error; 297 | try { 298 | await exchange.loadAssetBySymbol( 299 | tokenSymbol, 300 | timestampBeforeTokenRegistered, 301 | ); 302 | } catch (e) { 303 | error = e; 304 | } 305 | expect(error).to.not.be.undefined; 306 | expect(error.message).to.match(/no confirmed asset found for symbol/i); 307 | }); 308 | }); 309 | 310 | describe('assetUnitsToPips', async () => { 311 | let assetsMock: AssetsMockInstance; 312 | const assetUnitsToPips = async ( 313 | quantity: string, 314 | decimals: string, 315 | ): Promise => 316 | (await assetsMock.assetUnitsToPips(quantity, decimals)).toString(); 317 | 318 | beforeEach(async () => { 319 | assetsMock = await AssetsMock.new(); 320 | }); 321 | 322 | it('should succeed', async () => { 323 | expect(await assetUnitsToPips('10000000000', '18')).to.equal('1'); 324 | expect(await assetUnitsToPips('10000000000000', '18')).to.equal('1000'); 325 | expect(await assetUnitsToPips('1', '8')).to.equal('1'); 326 | expect(await assetUnitsToPips('1', '2')).to.equal('1000000'); 327 | expect(await assetUnitsToPips('1', '0')).to.equal('100000000'); 328 | }); 329 | 330 | it('should truncate fractions of a pip', async () => { 331 | expect(await assetUnitsToPips('19', '9')).to.equal('1'); 332 | expect(await assetUnitsToPips('1', '9')).to.equal('0'); 333 | }); 334 | 335 | it('should revert on uint64 overflow', async () => { 336 | let error; 337 | try { 338 | await assetUnitsToPips( 339 | new BigNumber(2).exponentiatedBy(128).toFixed(), 340 | '8', 341 | ); 342 | } catch (e) { 343 | error = e; 344 | } 345 | expect(error).to.not.be.undefined; 346 | expect(error.message).to.match(/pip quantity overflows uint64/i); 347 | }); 348 | 349 | it('should revert when token has too many decimals', async () => { 350 | let error; 351 | try { 352 | await assetUnitsToPips(new BigNumber(1).toFixed(), '100'); 353 | } catch (e) { 354 | error = e; 355 | } 356 | expect(error).to.not.be.undefined; 357 | expect(error.message).to.match( 358 | /asset cannot have more than 32 decimals/i, 359 | ); 360 | }); 361 | }); 362 | 363 | describe('pipsToAssetUnits', async () => { 364 | let assetsMock: AssetsMockInstance; 365 | const pipsToAssetUnits = async ( 366 | quantity: string, 367 | decimals: string, 368 | ): Promise => 369 | (await assetsMock.pipsToAssetUnits(quantity, decimals)).toString(); 370 | 371 | beforeEach(async () => { 372 | assetsMock = await AssetsMock.new(); 373 | }); 374 | 375 | it('should succeed', async () => { 376 | expect(await pipsToAssetUnits('1', '18')).to.equal('10000000000'); 377 | expect(await pipsToAssetUnits('1000', '18')).to.equal('10000000000000'); 378 | expect(await pipsToAssetUnits('1', '8')).to.equal('1'); 379 | expect(await pipsToAssetUnits('1000000', '2')).to.equal('1'); 380 | expect(await pipsToAssetUnits('100000000', '0')).to.equal('1'); 381 | }); 382 | 383 | it('should revert when token has too many decimals', async () => { 384 | let error; 385 | try { 386 | await pipsToAssetUnits(new BigNumber(1).toFixed(), '100'); 387 | } catch (e) { 388 | error = e; 389 | } 390 | expect(error).to.not.be.undefined; 391 | expect(error.message).to.match( 392 | /asset cannot have more than 32 decimals/i, 393 | ); 394 | }); 395 | }); 396 | }); 397 | -------------------------------------------------------------------------------- /test/custodian.ts: -------------------------------------------------------------------------------- 1 | import { ethAddress } from './helpers'; 2 | import { CustodianInstance } from '../types/truffle-contracts/Custodian'; 3 | import { ExchangeInstance } from '../types/truffle-contracts/Exchange'; 4 | import { ExchangeMockInstance } from '../types/truffle-contracts/ExchangeMock'; 5 | import { GovernanceInstance } from '../types/truffle-contracts/Governance'; 6 | import { GovernanceMockInstance } from '../types/truffle-contracts/GovernanceMock'; 7 | import BigNumber from 'bignumber.js'; 8 | 9 | contract('Custodian', (accounts) => { 10 | const Custodian = artifacts.require('Custodian'); 11 | const Exchange = artifacts.require('Exchange'); 12 | const Governance = artifacts.require('Governance'); 13 | const GovernanceMock = artifacts.require('GovernanceMock'); 14 | const ExchangeMock = artifacts.require('ExchangeMock'); 15 | const Token = artifacts.require('TestToken'); 16 | 17 | let exchange: ExchangeInstance; 18 | let governance: GovernanceInstance; 19 | beforeEach(async () => { 20 | exchange = await Exchange.new(); 21 | governance = await Governance.new(10); 22 | }); 23 | 24 | describe('deploy', () => { 25 | it('should work', async () => { 26 | await Custodian.new(exchange.address, governance.address); 27 | }); 28 | 29 | it('should revert for invalid exchange address', async () => { 30 | let error; 31 | try { 32 | await Custodian.new(ethAddress, governance.address); 33 | } catch (e) { 34 | error = e; 35 | } 36 | expect(error).to.not.be.undefined; 37 | expect(error.message).to.match(/invalid exchange contract address/i); 38 | }); 39 | 40 | it('should revert for non-contract exchange address', async () => { 41 | let error; 42 | try { 43 | await Custodian.new(accounts[0], governance.address); 44 | } catch (e) { 45 | error = e; 46 | } 47 | expect(error).to.not.be.undefined; 48 | expect(error.message).to.match(/invalid exchange contract address/i); 49 | }); 50 | 51 | it('should revert for invalid governance address', async () => { 52 | let error; 53 | try { 54 | await Custodian.new(exchange.address, ethAddress); 55 | } catch (e) { 56 | error = e; 57 | } 58 | expect(error).to.not.be.undefined; 59 | expect(error.message).to.match(/invalid governance contract address/i); 60 | }); 61 | 62 | it('should revert for non-contract governance address', async () => { 63 | let error; 64 | try { 65 | await Custodian.new(exchange.address, accounts[0]); 66 | } catch (e) { 67 | error = e; 68 | } 69 | expect(error).to.not.be.undefined; 70 | expect(error.message).to.match(/invalid governance contract address/i); 71 | }); 72 | }); 73 | 74 | describe('receive', () => { 75 | let custodian: CustodianInstance; 76 | let exchangeMock: ExchangeMockInstance; 77 | beforeEach(async () => { 78 | exchangeMock = await ExchangeMock.new(); 79 | custodian = await Custodian.new(exchangeMock.address, governance.address); 80 | await exchangeMock.setCustodian(custodian.address); 81 | }); 82 | 83 | it('should work when sent from exchange address', async () => { 84 | await web3.eth.sendTransaction({ 85 | from: accounts[0], 86 | to: exchangeMock.address, 87 | value: web3.utils.toWei('1', 'ether'), 88 | }); 89 | }); 90 | 91 | it('should revert when not sent from exchange address', async () => { 92 | let error; 93 | try { 94 | await web3.eth.sendTransaction({ 95 | from: accounts[0], 96 | to: custodian.address, 97 | value: web3.utils.toWei('1', 'ether'), 98 | }); 99 | } catch (e) { 100 | error = e; 101 | } 102 | expect(error).to.not.be.undefined; 103 | expect(error.message).to.match(/caller must be exchange/i); 104 | }); 105 | }); 106 | 107 | describe('setExchange', () => { 108 | let custodian: CustodianInstance; 109 | let governanceMock: GovernanceMockInstance; 110 | beforeEach(async () => { 111 | governanceMock = await GovernanceMock.new(); 112 | custodian = await Custodian.new(exchange.address, governanceMock.address); 113 | governanceMock.setCustodian(custodian.address); 114 | }); 115 | 116 | it('should work when sent from governance address', async () => { 117 | const newExchange = await Exchange.new(); 118 | 119 | await governanceMock.setExchange(newExchange.address); 120 | 121 | const events = await custodian.getPastEvents('ExchangeChanged', { 122 | fromBlock: 0, 123 | }); 124 | expect(events).to.be.an('array'); 125 | expect(events.length).to.equal(2); 126 | }); 127 | 128 | it('should revert for invalid address', async () => { 129 | let error; 130 | try { 131 | await governanceMock.setExchange(ethAddress); 132 | } catch (e) { 133 | error = e; 134 | } 135 | expect(error).to.not.be.undefined; 136 | expect(error.message).to.match(/invalid contract address/i); 137 | }); 138 | 139 | it('should revert for non-contract address', async () => { 140 | let error; 141 | try { 142 | await governanceMock.setExchange(accounts[0]); 143 | } catch (e) { 144 | error = e; 145 | } 146 | expect(error).to.not.be.undefined; 147 | expect(error.message).to.match(/invalid contract address/i); 148 | }); 149 | 150 | it('should revert when not sent from governance address', async () => { 151 | let error; 152 | try { 153 | await custodian.setExchange(ethAddress, { 154 | from: accounts[1], 155 | }); 156 | } catch (e) { 157 | error = e; 158 | } 159 | expect(error).to.not.be.undefined; 160 | expect(error.message).to.match(/caller must be governance/i); 161 | }); 162 | }); 163 | 164 | describe('setGovernance', () => { 165 | let custodian: CustodianInstance; 166 | let governanceMock: GovernanceMockInstance; 167 | beforeEach(async () => { 168 | governanceMock = await GovernanceMock.new(); 169 | custodian = await Custodian.new(exchange.address, governanceMock.address); 170 | governanceMock.setCustodian(custodian.address); 171 | }); 172 | 173 | it('should work when sent from governance address', async () => { 174 | const newGovernance = await Governance.new(0); 175 | 176 | await governanceMock.setGovernance(newGovernance.address); 177 | 178 | const events = await custodian.getPastEvents('GovernanceChanged', { 179 | fromBlock: 0, 180 | }); 181 | expect(events).to.be.an('array'); 182 | expect(events.length).to.equal(2); 183 | }); 184 | 185 | it('should revert for invalid address', async () => { 186 | let error; 187 | try { 188 | await governanceMock.setGovernance(ethAddress); 189 | } catch (e) { 190 | error = e; 191 | } 192 | expect(error).to.not.be.undefined; 193 | expect(error.message).to.match(/invalid contract address/i); 194 | }); 195 | 196 | it('should revert for non-contract address', async () => { 197 | let error; 198 | try { 199 | await governanceMock.setGovernance(accounts[0]); 200 | } catch (e) { 201 | error = e; 202 | } 203 | expect(error).to.not.be.undefined; 204 | expect(error.message).to.match(/invalid contract address/i); 205 | }); 206 | 207 | it('should revert when not sent from governance address', async () => { 208 | let error; 209 | try { 210 | await custodian.setGovernance(ethAddress, { 211 | from: accounts[1], 212 | }); 213 | } catch (e) { 214 | error = e; 215 | } 216 | expect(error).to.not.be.undefined; 217 | expect(error.message).to.match(/caller must be governance/i); 218 | }); 219 | }); 220 | 221 | describe('withdraw', () => { 222 | let custodian: CustodianInstance; 223 | let exchangeMock: ExchangeMockInstance; 224 | beforeEach(async () => { 225 | exchangeMock = await ExchangeMock.new(); 226 | custodian = await Custodian.new(exchangeMock.address, governance.address); 227 | await exchangeMock.setCustodian(custodian.address); 228 | }); 229 | 230 | it('should work when sent from exchange', async () => { 231 | const [sourceWallet, destinationWallet] = accounts; 232 | await web3.eth.sendTransaction({ 233 | from: sourceWallet, 234 | to: exchangeMock.address, 235 | value: web3.utils.toWei('1', 'ether'), 236 | }); 237 | 238 | const balanceBefore = await web3.eth.getBalance(destinationWallet); 239 | 240 | await exchangeMock.withdraw( 241 | destinationWallet, 242 | ethAddress, 243 | web3.utils.toWei('1', 'ether'), 244 | ); 245 | 246 | const balanceAfter = await web3.eth.getBalance(destinationWallet); 247 | 248 | expect( 249 | new BigNumber(balanceAfter) 250 | .minus(new BigNumber(balanceBefore)) 251 | .toString(), 252 | ).to.equal(web3.utils.toWei('1', 'ether')); 253 | }); 254 | 255 | it('should revert withdrawing ETH not deposited', async () => { 256 | const [sourceWallet, destinationWallet] = accounts; 257 | 258 | let error; 259 | try { 260 | await exchangeMock.withdraw( 261 | destinationWallet, 262 | ethAddress, 263 | web3.utils.toWei('1', 'ether'), 264 | { from: sourceWallet }, 265 | ); 266 | } catch (e) { 267 | error = e; 268 | } 269 | expect(error).to.not.be.undefined; 270 | expect(error.message).to.match(/ETH transfer failed/i); 271 | }); 272 | 273 | it('should revert withdrawing tokens not deposited', async () => { 274 | const [sourceWallet, destinationWallet] = accounts; 275 | const token = await Token.new(); 276 | 277 | let error; 278 | try { 279 | await exchangeMock.withdraw( 280 | destinationWallet, 281 | token.address, 282 | web3.utils.toWei('1', 'ether'), 283 | { from: sourceWallet }, 284 | ); 285 | } catch (e) { 286 | error = e; 287 | } 288 | expect(error).to.not.be.undefined; 289 | expect(error.message).to.match(/transfer amount exceeds balance/i); 290 | }); 291 | 292 | it('should revert when not sent from exchange', async () => { 293 | const [sourceWallet, destinationWallet] = accounts; 294 | 295 | let error; 296 | try { 297 | await custodian.withdraw( 298 | destinationWallet, 299 | ethAddress, 300 | web3.utils.toWei('1', 'ether'), 301 | { from: sourceWallet }, 302 | ); 303 | } catch (e) { 304 | error = e; 305 | } 306 | expect(error).to.not.be.undefined; 307 | expect(error.message).to.match(/caller must be exchange/i); 308 | }); 309 | }); 310 | }); 311 | -------------------------------------------------------------------------------- /test/deposit.ts: -------------------------------------------------------------------------------- 1 | import { 2 | deployAndAssociateContracts, 3 | deployAndRegisterToken, 4 | minimumTokenQuantity, 5 | ethSymbol, 6 | } from './helpers'; 7 | import { assetUnitsToPips, ethAddress } from '../lib'; 8 | 9 | contract('Exchange (deposits)', (accounts) => { 10 | const Exchange = artifacts.require('Exchange'); 11 | const NonCompliantToken = artifacts.require('NonCompliantToken'); 12 | const SkimmingToken = artifacts.require('SkimmingTestToken'); 13 | const Token = artifacts.require('TestToken'); 14 | 15 | const tokenSymbol = 'TKN'; 16 | 17 | it('should revert when receiving ETH directly', async () => { 18 | const exchange = await Exchange.new(); 19 | 20 | let error; 21 | try { 22 | await web3.eth.sendTransaction({ 23 | from: accounts[0], 24 | to: exchange.address, 25 | value: web3.utils.toWei('1', 'ether'), 26 | }); 27 | } catch (e) { 28 | error = e; 29 | } 30 | expect(error).to.not.be.undefined; 31 | expect(error.message).to.match(/revert/i); 32 | }); 33 | 34 | // TODO Verify balances 35 | describe('depositEther', () => { 36 | it('should work for minimum quantity', async () => { 37 | const { exchange } = await deployAndAssociateContracts(); 38 | 39 | await exchange.depositEther({ 40 | value: minimumTokenQuantity, 41 | from: accounts[0], 42 | }); 43 | 44 | const events = await exchange.getPastEvents('Deposited', { 45 | fromBlock: 0, 46 | }); 47 | expect(events).to.be.an('array'); 48 | expect(events.length).to.equal(1); 49 | 50 | const { wallet, assetAddress, assetSymbol } = events[0].returnValues; 51 | 52 | expect(wallet).to.equal(accounts[0]); 53 | expect(assetAddress).to.equal(ethAddress); 54 | expect(assetSymbol).to.equal(ethSymbol); 55 | 56 | expect( 57 | ( 58 | await exchange.loadBalanceInAssetUnitsByAddress( 59 | accounts[0], 60 | ethAddress, 61 | ) 62 | ).toString(), 63 | ).to.equal(minimumTokenQuantity); 64 | expect( 65 | ( 66 | await exchange.loadBalanceInPipsByAddress(accounts[0], ethAddress) 67 | ).toString(), 68 | ).to.equal(assetUnitsToPips(minimumTokenQuantity, 18)); 69 | expect( 70 | ( 71 | await exchange.loadBalanceInAssetUnitsBySymbol(accounts[0], ethSymbol) 72 | ).toString(), 73 | ).to.equal(minimumTokenQuantity); 74 | expect( 75 | ( 76 | await exchange.loadBalanceInPipsBySymbol(accounts[0], ethSymbol) 77 | ).toString(), 78 | ).to.equal(assetUnitsToPips(minimumTokenQuantity, 18)); 79 | }); 80 | 81 | it('should revert below minimum quantity', async () => { 82 | const { exchange } = await deployAndAssociateContracts(); 83 | 84 | let error; 85 | try { 86 | await exchange.depositEther({ 87 | value: (BigInt(minimumTokenQuantity) - BigInt(1)).toString(), 88 | from: accounts[0], 89 | }); 90 | } catch (e) { 91 | error = e; 92 | } 93 | expect(error).to.not.be.undefined; 94 | expect(error.message).to.match(/Quantity is too low/i); 95 | }); 96 | }); 97 | 98 | describe('depositTokenBySymbol', () => { 99 | it('should work for minimum quantity', async () => { 100 | const { exchange } = await deployAndAssociateContracts(); 101 | const token = await deployAndRegisterToken(exchange, tokenSymbol); 102 | 103 | await token.approve(exchange.address, minimumTokenQuantity); 104 | await exchange.depositTokenBySymbol(tokenSymbol, minimumTokenQuantity); 105 | 106 | const events = await exchange.getPastEvents('Deposited', { 107 | fromBlock: 0, 108 | }); 109 | expect(events).to.be.an('array'); 110 | expect(events.length).to.equal(1); 111 | 112 | const { wallet, assetAddress, assetSymbol } = events[0].returnValues; 113 | 114 | expect(wallet).to.equal(accounts[0]); 115 | expect(assetAddress).to.equal(token.address); 116 | expect(assetSymbol).to.equal(tokenSymbol); 117 | }); 118 | 119 | it('should revert for ETH', async () => { 120 | const { exchange } = await deployAndAssociateContracts(); 121 | const token = await deployAndRegisterToken(exchange, tokenSymbol); 122 | 123 | let error; 124 | try { 125 | await token.approve(exchange.address, minimumTokenQuantity); 126 | await exchange.depositTokenBySymbol('ETH', minimumTokenQuantity); 127 | } catch (e) { 128 | error = e; 129 | } 130 | expect(error).to.not.be.undefined; 131 | expect(error.message).to.match(/use depositEther to deposit ETH/i); 132 | }); 133 | 134 | it('should revert for exited wallet', async () => { 135 | const { exchange } = await deployAndAssociateContracts(); 136 | const token = await deployAndRegisterToken(exchange, tokenSymbol); 137 | await token.approve(exchange.address, minimumTokenQuantity); 138 | await exchange.exitWallet(); 139 | 140 | let error; 141 | try { 142 | await exchange.depositTokenBySymbol(tokenSymbol, minimumTokenQuantity); 143 | } catch (e) { 144 | error = e; 145 | } 146 | expect(error).to.not.be.undefined; 147 | expect(error.message).to.match(/wallet exited/i); 148 | }); 149 | 150 | it('should revert when token quantity above wallet balance', async () => { 151 | const { exchange } = await deployAndAssociateContracts(); 152 | await deployAndRegisterToken(exchange, tokenSymbol); 153 | const [, wallet] = accounts; 154 | 155 | let error; 156 | try { 157 | await exchange.depositTokenBySymbol(tokenSymbol, minimumTokenQuantity, { 158 | from: wallet, 159 | }); 160 | } catch (e) { 161 | error = e; 162 | } 163 | expect(error).to.not.be.undefined; 164 | expect(error.message).to.match(/transfer amount exceeds balance/i); 165 | }); 166 | 167 | it('should revert for unknown token', async () => { 168 | const { exchange } = await deployAndAssociateContracts(); 169 | const [, wallet] = accounts; 170 | 171 | let error; 172 | try { 173 | await exchange.depositTokenBySymbol(tokenSymbol, minimumTokenQuantity, { 174 | from: wallet, 175 | }); 176 | } catch (e) { 177 | error = e; 178 | } 179 | expect(error).to.not.be.undefined; 180 | expect(error.message).to.match(/no confirmed asset found for symbol/i); 181 | }); 182 | }); 183 | 184 | describe('depositTokenByAddress', () => { 185 | it('should work for minimum quantity', async () => { 186 | const { exchange } = await deployAndAssociateContracts(); 187 | const token = await deployAndRegisterToken(exchange, tokenSymbol); 188 | 189 | await token.approve(exchange.address, minimumTokenQuantity); 190 | await exchange.depositTokenByAddress(token.address, minimumTokenQuantity); 191 | 192 | const events = await exchange.getPastEvents('Deposited', { 193 | fromBlock: 0, 194 | }); 195 | expect(events).to.be.an('array'); 196 | expect(events.length).to.equal(1); 197 | }); 198 | 199 | it('should work for minimum quantity with non-compliant token', async () => { 200 | const { exchange } = await deployAndAssociateContracts(); 201 | const token = await NonCompliantToken.new(); 202 | 203 | await exchange.registerToken(token.address, tokenSymbol, 18); 204 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 205 | await token.approve(exchange.address, minimumTokenQuantity); 206 | await exchange.depositTokenByAddress(token.address, minimumTokenQuantity); 207 | 208 | const events = await exchange.getPastEvents('Deposited', { 209 | fromBlock: 0, 210 | }); 211 | expect(events).to.be.an('array'); 212 | expect(events.length).to.equal(1); 213 | }); 214 | 215 | it('should revert for ETH', async () => { 216 | const { exchange } = await deployAndAssociateContracts(); 217 | const token = await deployAndRegisterToken(exchange, tokenSymbol); 218 | 219 | let error; 220 | try { 221 | await token.approve(exchange.address, minimumTokenQuantity); 222 | await exchange.depositTokenByAddress(ethAddress, minimumTokenQuantity); 223 | } catch (e) { 224 | error = e; 225 | } 226 | expect(error).to.not.be.undefined; 227 | expect(error.message).to.match(/use depositEther to deposit ether/i); 228 | }); 229 | 230 | it('should revert for unknown token', async () => { 231 | const { exchange } = await deployAndAssociateContracts(); 232 | const token = await Token.new(); 233 | const [, wallet] = accounts; 234 | 235 | let error; 236 | try { 237 | await exchange.depositTokenByAddress( 238 | token.address, 239 | minimumTokenQuantity, 240 | { 241 | from: wallet, 242 | }, 243 | ); 244 | } catch (e) { 245 | error = e; 246 | } 247 | expect(error).to.not.be.undefined; 248 | expect(error.message).to.match(/no confirmed asset found for address/i); 249 | }); 250 | 251 | it('should revert when token skims from transfer', async () => { 252 | const { exchange } = await deployAndAssociateContracts(); 253 | const token = await SkimmingToken.new(); 254 | await token.setShouldSkim(true); 255 | await exchange.registerToken(token.address, tokenSymbol, 18); 256 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 257 | await token.approve(exchange.address, minimumTokenQuantity); 258 | 259 | let error; 260 | try { 261 | await exchange.depositTokenByAddress( 262 | token.address, 263 | minimumTokenQuantity, 264 | ); 265 | } catch (e) { 266 | error = e; 267 | } 268 | expect(error).to.not.be.undefined; 269 | expect(error.message).to.match( 270 | /transferFrom success without expected balance change/i, 271 | ); 272 | }); 273 | }); 274 | }); 275 | -------------------------------------------------------------------------------- /test/exchange.ts: -------------------------------------------------------------------------------- 1 | import { deployAndAssociateContracts, ethSymbol } from './helpers'; 2 | 3 | contract('Exchange (tunable parameters)', (accounts) => { 4 | const Exchange = artifacts.require('Exchange'); 5 | 6 | const ethAddress = web3.utils.bytesToHex([...Buffer.alloc(20)]); 7 | 8 | it('should deploy', async () => { 9 | await Exchange.new(); 10 | }); 11 | 12 | it('should revert when receiving ETH directly', async () => { 13 | const exchange = await Exchange.new(); 14 | 15 | let error; 16 | try { 17 | await web3.eth.sendTransaction({ 18 | to: exchange.address, 19 | from: accounts[0], 20 | value: web3.utils.toWei('1', 'ether'), 21 | }); 22 | } catch (e) { 23 | error = e; 24 | } 25 | 26 | expect(error).to.not.be.undefined; 27 | expect(error.message).to.match(/revert/i); 28 | }); 29 | 30 | describe('loadBalanceInAssetUnitsByAddress', () => { 31 | it('should revert for invalid wallet', async () => { 32 | const { exchange } = await deployAndAssociateContracts(); 33 | 34 | let error; 35 | try { 36 | await exchange.loadBalanceInAssetUnitsByAddress(ethAddress, ethAddress); 37 | } catch (e) { 38 | error = e; 39 | } 40 | 41 | expect(error).to.not.be.undefined; 42 | expect(error.message).to.match(/invalid wallet address/i); 43 | }); 44 | }); 45 | 46 | describe('loadBalanceInPipsByAddress', () => { 47 | it('should revert for invalid wallet', async () => { 48 | const { exchange } = await deployAndAssociateContracts(); 49 | 50 | let error; 51 | try { 52 | await exchange.loadBalanceInPipsByAddress(ethAddress, ethAddress); 53 | } catch (e) { 54 | error = e; 55 | } 56 | 57 | expect(error).to.not.be.undefined; 58 | expect(error.message).to.match(/invalid wallet address/i); 59 | }); 60 | }); 61 | 62 | describe('loadBalanceInPipsBySymbol', () => { 63 | it('should revert for invalid wallet', async () => { 64 | const { exchange } = await deployAndAssociateContracts(); 65 | 66 | let error; 67 | try { 68 | await exchange.loadBalanceInPipsBySymbol(ethAddress, ethSymbol); 69 | } catch (e) { 70 | error = e; 71 | } 72 | 73 | expect(error).to.not.be.undefined; 74 | expect(error.message).to.match(/invalid wallet address/i); 75 | }); 76 | }); 77 | 78 | describe('loadBalanceInAssetUnitsBySymbol', () => { 79 | it('should revert for invalid wallet', async () => { 80 | const { exchange } = await deployAndAssociateContracts(); 81 | 82 | let error; 83 | try { 84 | await exchange.loadBalanceInAssetUnitsBySymbol(ethAddress, ethSymbol); 85 | } catch (e) { 86 | error = e; 87 | } 88 | 89 | expect(error).to.not.be.undefined; 90 | expect(error.message).to.match(/invalid wallet address/i); 91 | }); 92 | }); 93 | 94 | describe('setAdmin', async () => { 95 | it('should work for valid address', async () => { 96 | const exchange = await Exchange.new(); 97 | 98 | await exchange.setAdmin(accounts[1]); 99 | }); 100 | 101 | it('should revert for empty address', async () => { 102 | const exchange = await Exchange.new(); 103 | 104 | let error; 105 | try { 106 | await exchange.setAdmin(ethAddress); 107 | } catch (e) { 108 | error = e; 109 | } 110 | 111 | expect(error).to.not.be.undefined; 112 | expect(error.message).to.match(/invalid wallet address/i); 113 | }); 114 | 115 | it('should revert for setting same address as current', async () => { 116 | const { exchange } = await deployAndAssociateContracts(); 117 | await exchange.setAdmin(accounts[1]); 118 | 119 | let error; 120 | try { 121 | await exchange.setAdmin(accounts[1]); 122 | } catch (e) { 123 | error = e; 124 | } 125 | expect(error).to.not.be.undefined; 126 | expect(error.message).to.match(/must be different/i); 127 | }); 128 | 129 | it('should revert when not called by owner', async () => { 130 | const exchange = await Exchange.new(); 131 | 132 | let error; 133 | try { 134 | await exchange.setAdmin(accounts[1], { from: accounts[1] }); 135 | } catch (e) { 136 | error = e; 137 | } 138 | 139 | expect(error).to.not.be.undefined; 140 | expect(error.message).to.match(/caller must be owner/i); 141 | }); 142 | }); 143 | 144 | describe('removeAdmin', async () => { 145 | it('should work', async () => { 146 | const { exchange } = await deployAndAssociateContracts(); 147 | 148 | await exchange.removeAdmin(); 149 | }); 150 | }); 151 | 152 | describe('setCustodian', () => { 153 | it('should work for valid address', async () => { 154 | await deployAndAssociateContracts(); 155 | }); 156 | 157 | it('should revert for empty address', async () => { 158 | const exchange = await Exchange.new(); 159 | 160 | let error; 161 | try { 162 | await exchange.setCustodian(ethAddress); 163 | } catch (e) { 164 | error = e; 165 | } 166 | expect(error).to.not.be.undefined; 167 | expect(error.message).to.match(/invalid address/i); 168 | }); 169 | 170 | it('should revert after first call', async () => { 171 | const { custodian, exchange } = await deployAndAssociateContracts(); 172 | 173 | let error; 174 | try { 175 | await exchange.setCustodian(custodian.address); 176 | } catch (e) { 177 | error = e; 178 | } 179 | expect(error).to.not.be.undefined; 180 | expect(error.message).to.match(/custodian can only be set once/i); 181 | }); 182 | }); 183 | 184 | describe('setChainPropagationPeriod', () => { 185 | it('should work for value in bounds', async () => { 186 | const { exchange } = await deployAndAssociateContracts(); 187 | 188 | await exchange.setChainPropagationPeriod('10'); 189 | 190 | const events = await exchange.getPastEvents( 191 | 'ChainPropagationPeriodChanged', 192 | { 193 | fromBlock: 0, 194 | }, 195 | ); 196 | expect(events).to.be.an('array'); 197 | expect(events.length).to.equal(1); 198 | }); 199 | 200 | it('should revert for value out of bounds', async () => { 201 | const { exchange } = await deployAndAssociateContracts(); 202 | 203 | let error; 204 | try { 205 | await exchange.setChainPropagationPeriod('1000000000000000000000000'); 206 | } catch (e) { 207 | error = e; 208 | } 209 | expect(error).to.not.be.undefined; 210 | expect(error.message).to.match(/must be less than/i); 211 | }); 212 | }); 213 | 214 | describe('setDispatcher', () => { 215 | it('should work for valid address', async () => { 216 | const { exchange } = await deployAndAssociateContracts(); 217 | 218 | await exchange.setDispatcher(accounts[1]); 219 | 220 | const events = await exchange.getPastEvents('DispatcherChanged', { 221 | fromBlock: 0, 222 | }); 223 | expect(events).to.be.an('array'); 224 | expect(events.length).to.equal(1); 225 | }); 226 | 227 | it('should revert for empty address', async () => { 228 | const exchange = await Exchange.new(); 229 | 230 | let error; 231 | try { 232 | await exchange.setDispatcher(ethAddress); 233 | } catch (e) { 234 | error = e; 235 | } 236 | 237 | expect(error).to.not.be.undefined; 238 | expect(error.message).to.match(/invalid wallet address/i); 239 | }); 240 | 241 | it('should revert for setting same address as current', async () => { 242 | const { exchange } = await deployAndAssociateContracts(); 243 | await exchange.setDispatcher(accounts[1]); 244 | 245 | let error; 246 | try { 247 | await exchange.setDispatcher(accounts[1]); 248 | } catch (e) { 249 | error = e; 250 | } 251 | expect(error).to.not.be.undefined; 252 | expect(error.message).to.match(/must be different/i); 253 | }); 254 | }); 255 | 256 | describe('removeDispatcher', () => { 257 | it('should set wallet to zero', async () => { 258 | const { exchange } = await deployAndAssociateContracts(); 259 | 260 | await exchange.setDispatcher(accounts[1]); 261 | await exchange.removeDispatcher(); 262 | 263 | const events = await exchange.getPastEvents('DispatcherChanged', { 264 | fromBlock: 0, 265 | }); 266 | expect(events).to.be.an('array'); 267 | expect(events.length).to.equal(2); 268 | expect(events[1].returnValues.newValue).to.equal(ethAddress); 269 | }); 270 | }); 271 | 272 | describe('setFeeWallet', () => { 273 | it('should work for valid address', async () => { 274 | const { exchange } = await deployAndAssociateContracts(); 275 | 276 | await exchange.setFeeWallet(accounts[1]); 277 | 278 | const events = await exchange.getPastEvents('FeeWalletChanged', { 279 | fromBlock: 0, 280 | }); 281 | expect(events).to.be.an('array'); 282 | expect(events.length).to.equal(1); 283 | 284 | expect(await exchange.loadFeeWallet()).to.equal(accounts[1]); 285 | }); 286 | 287 | it('should revert for empty address', async () => { 288 | const exchange = await Exchange.new(); 289 | 290 | let error; 291 | try { 292 | await exchange.setFeeWallet(ethAddress); 293 | } catch (e) { 294 | error = e; 295 | } 296 | 297 | expect(error).to.not.be.undefined; 298 | expect(error.message).to.match(/invalid wallet address/i); 299 | }); 300 | 301 | it('should revert for setting same address as current', async () => { 302 | const { exchange } = await deployAndAssociateContracts(); 303 | await exchange.setFeeWallet(accounts[1]); 304 | 305 | let error; 306 | try { 307 | await exchange.setFeeWallet(accounts[1]); 308 | } catch (e) { 309 | error = e; 310 | } 311 | expect(error).to.not.be.undefined; 312 | expect(error.message).to.match(/must be different/i); 313 | }); 314 | }); 315 | }); 316 | -------------------------------------------------------------------------------- /test/exit.ts: -------------------------------------------------------------------------------- 1 | import { deployAndAssociateContracts, minimumTokenQuantity } from './helpers'; 2 | import { ethAddress, pipsToAssetUnits } from '../lib'; 3 | 4 | contract('Exchange (exits)', (accounts) => { 5 | describe('exitWallet', () => { 6 | it('should work for non-exited wallet', async () => { 7 | const { exchange } = await deployAndAssociateContracts(); 8 | 9 | await exchange.exitWallet({ from: accounts[0] }); 10 | 11 | const events = await exchange.getPastEvents('WalletExited', { 12 | fromBlock: 0, 13 | }); 14 | expect(events).to.be.an('array'); 15 | expect(events.length).to.equal(1); 16 | expect(events[0].returnValues.wallet).to.equal(accounts[0]); 17 | expect( 18 | parseInt(events[0].returnValues.effectiveBlockNumber, 10), 19 | ).to.equal(await web3.eth.getBlockNumber()); 20 | }); 21 | 22 | it('should revert for wallet already exited', async () => { 23 | const { exchange } = await deployAndAssociateContracts(); 24 | await exchange.exitWallet({ from: accounts[0] }); 25 | 26 | let error; 27 | try { 28 | await exchange.exitWallet({ from: accounts[0] }); 29 | } catch (e) { 30 | error = e; 31 | } 32 | expect(error).to.not.be.undefined; 33 | expect(error.message).to.match(/wallet already exited/i); 34 | }); 35 | }); 36 | 37 | describe('withdrawExit', () => { 38 | it('should work for ETH', async () => { 39 | const { exchange } = await deployAndAssociateContracts(); 40 | 41 | await exchange.depositEther({ 42 | value: minimumTokenQuantity, 43 | from: accounts[0], 44 | }); 45 | await exchange.exitWallet({ from: accounts[0] }); 46 | 47 | await exchange.withdrawExit(ethAddress); 48 | 49 | const events = await exchange.getPastEvents('WalletExitWithdrawn', { 50 | fromBlock: 0, 51 | }); 52 | expect(events).to.be.an('array'); 53 | expect(events.length).to.equal(1); 54 | expect(events[0].returnValues.wallet).to.equal(accounts[0]); 55 | expect(events[0].returnValues.assetAddress).to.equal(ethAddress); 56 | expect( 57 | pipsToAssetUnits(events[0].returnValues.quantityInPips, 18), 58 | ).to.equal(minimumTokenQuantity); 59 | }); 60 | 61 | it('should revert for wallet not exited', async () => { 62 | const { exchange } = await deployAndAssociateContracts(); 63 | 64 | let error; 65 | try { 66 | await exchange.withdrawExit(ethAddress); 67 | } catch (e) { 68 | error = e; 69 | } 70 | expect(error).to.not.be.undefined; 71 | expect(error.message).to.match(/wallet exit not finalized/i); 72 | }); 73 | 74 | it('should revert for wallet exit not finalized', async () => { 75 | const { exchange } = await deployAndAssociateContracts(); 76 | await exchange.setChainPropagationPeriod(10); 77 | await exchange.exitWallet({ from: accounts[0] }); 78 | 79 | let error; 80 | try { 81 | await exchange.withdrawExit(ethAddress); 82 | } catch (e) { 83 | error = e; 84 | } 85 | expect(error).to.not.be.undefined; 86 | expect(error.message).to.match(/wallet exit not finalized/i); 87 | }); 88 | 89 | it('should revert for asset with no balance', async () => { 90 | const { exchange } = await deployAndAssociateContracts(); 91 | await exchange.exitWallet({ from: accounts[0] }); 92 | 93 | let error; 94 | try { 95 | await exchange.withdrawExit(ethAddress); 96 | } catch (e) { 97 | error = e; 98 | } 99 | expect(error).to.not.be.undefined; 100 | expect(error.message).to.match(/no balance for asset/i); 101 | }); 102 | }); 103 | 104 | describe('clearWalletExit', () => { 105 | it('should work for non-exited wallet', async () => { 106 | const { exchange } = await deployAndAssociateContracts(); 107 | 108 | await exchange.exitWallet({ from: accounts[0] }); 109 | await exchange.clearWalletExit({ from: accounts[0] }); 110 | 111 | const events = await exchange.getPastEvents('WalletExitCleared', { 112 | fromBlock: 0, 113 | }); 114 | expect(events).to.be.an('array'); 115 | expect(events.length).to.equal(1); 116 | expect(events[0].returnValues.wallet).to.equal(accounts[0]); 117 | }); 118 | 119 | it('should revert for wallet not exited', async () => { 120 | const { exchange } = await deployAndAssociateContracts(); 121 | 122 | let error; 123 | try { 124 | await exchange.clearWalletExit(); 125 | } catch (e) { 126 | error = e; 127 | } 128 | expect(error).to.not.be.undefined; 129 | expect(error.message).to.match(/wallet not exited/i); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /test/governance.ts: -------------------------------------------------------------------------------- 1 | import { deployAndAssociateContracts } from './helpers'; 2 | 3 | contract('Governance', (accounts) => { 4 | const Exchange = artifacts.require('Exchange'); 5 | const Governance = artifacts.require('Governance'); 6 | 7 | const ethAddress = web3.utils.bytesToHex([...Buffer.alloc(20)]); 8 | 9 | it('should deploy', async () => { 10 | await Governance.new(0); 11 | }); 12 | 13 | describe('setAdmin', () => { 14 | it('should work for valid address', async () => { 15 | const governance = await Governance.new(0); 16 | await governance.setAdmin(accounts[1]); 17 | }); 18 | 19 | it('should revert for empty address', async () => { 20 | const governance = await Governance.new(0); 21 | 22 | let error; 23 | try { 24 | await governance.setAdmin(ethAddress); 25 | } catch (e) { 26 | error = e; 27 | } 28 | expect(error).to.not.be.undefined; 29 | expect(error.message).to.match(/invalid wallet address/i); 30 | }); 31 | }); 32 | 33 | describe('setCustodian', () => { 34 | it('should work for valid address', async () => { 35 | await deployAndAssociateContracts(); 36 | }); 37 | 38 | it('should revert for empty address', async () => { 39 | const governance = await Governance.new(0); 40 | 41 | let error; 42 | try { 43 | await governance.setCustodian(ethAddress); 44 | } catch (e) { 45 | error = e; 46 | } 47 | expect(error).to.not.be.undefined; 48 | expect(error.message).to.match(/invalid address/i); 49 | }); 50 | 51 | it('should revert for non-contract address', async () => { 52 | const governance = await Governance.new(0); 53 | 54 | let error; 55 | try { 56 | await governance.setCustodian(accounts[0]); 57 | } catch (e) { 58 | error = e; 59 | } 60 | expect(error).to.not.be.undefined; 61 | expect(error.message).to.match(/invalid address/i); 62 | }); 63 | 64 | it('should revert after first call', async () => { 65 | const { custodian, governance } = await deployAndAssociateContracts(); 66 | 67 | let error; 68 | try { 69 | await governance.setCustodian(custodian.address); 70 | } catch (e) { 71 | error = e; 72 | } 73 | expect(error).to.not.be.undefined; 74 | expect(error.message).to.match(/custodian can only be set once/i); 75 | }); 76 | 77 | it('should revert when not called by admin', async () => { 78 | const { custodian, governance } = await deployAndAssociateContracts(); 79 | await governance.setAdmin(accounts[1]); 80 | let error; 81 | try { 82 | await governance.setCustodian(custodian.address, { from: accounts[0] }); 83 | } catch (e) { 84 | error = e; 85 | } 86 | expect(error).to.not.be.undefined; 87 | expect(error.message).to.match(/caller must be admin/i); 88 | }); 89 | }); 90 | 91 | describe('initiateExchangeUpgrade', () => { 92 | it('should work for valid contract address', async () => { 93 | const { 94 | exchange: oldExchange, 95 | governance, 96 | } = await deployAndAssociateContracts(); 97 | const newExchange = await Exchange.new(); 98 | 99 | await governance.initiateExchangeUpgrade(newExchange.address); 100 | 101 | const events = await governance.getPastEvents( 102 | 'ExchangeUpgradeInitiated', 103 | { 104 | fromBlock: 0, 105 | }, 106 | ); 107 | expect(events).to.be.an('array'); 108 | expect(events.length).to.equal(1); 109 | expect(events[0].returnValues.oldExchange).to.equal(oldExchange.address); 110 | expect(events[0].returnValues.newExchange).to.equal(newExchange.address); 111 | expect(parseInt(events[0].returnValues.blockThreshold, 10)).to.equal( 112 | await web3.eth.getBlockNumber(), // No delay 113 | ); 114 | }); 115 | 116 | it('should revert for invalid contract address', async () => { 117 | const { governance } = await deployAndAssociateContracts(); 118 | 119 | let error; 120 | try { 121 | await governance.initiateExchangeUpgrade(ethAddress); 122 | } catch (e) { 123 | error = e; 124 | } 125 | expect(error).to.not.be.undefined; 126 | expect(error.message).to.match(/invalid address/i); 127 | }); 128 | 129 | it('should revert for non-contract address', async () => { 130 | const governance = await Governance.new(0); 131 | 132 | let error; 133 | try { 134 | await governance.initiateExchangeUpgrade(accounts[0]); 135 | } catch (e) { 136 | error = e; 137 | } 138 | expect(error).to.not.be.undefined; 139 | expect(error.message).to.match(/invalid address/i); 140 | }); 141 | 142 | it('should revert for same Exchange address', async () => { 143 | const { exchange, governance } = await deployAndAssociateContracts(); 144 | 145 | let error; 146 | try { 147 | await governance.initiateExchangeUpgrade(exchange.address); 148 | } catch (e) { 149 | error = e; 150 | } 151 | expect(error).to.not.be.undefined; 152 | expect(error.message).to.match( 153 | /must be different from current exchange/i, 154 | ); 155 | }); 156 | 157 | it('should revert when upgrade already in progress', async () => { 158 | const { governance } = await deployAndAssociateContracts(); 159 | const newExchange = await Exchange.new(); 160 | await governance.initiateExchangeUpgrade(newExchange.address); 161 | 162 | let error; 163 | try { 164 | await governance.initiateExchangeUpgrade(newExchange.address); 165 | } catch (e) { 166 | error = e; 167 | } 168 | expect(error).to.not.be.undefined; 169 | expect(error.message).to.match(/exchange upgrade already in progress/i); 170 | }); 171 | }); 172 | 173 | describe('cancelExchangeUpgrade', () => { 174 | it('should work when in progress', async () => { 175 | const { 176 | exchange: oldExchange, 177 | governance, 178 | } = await deployAndAssociateContracts(); 179 | const newExchange = await Exchange.new(); 180 | 181 | await governance.initiateExchangeUpgrade(newExchange.address); 182 | await governance.cancelExchangeUpgrade(); 183 | 184 | const events = await governance.getPastEvents('ExchangeUpgradeCanceled', { 185 | fromBlock: 0, 186 | }); 187 | expect(events).to.be.an('array'); 188 | expect(events.length).to.equal(1); 189 | expect(events[0].returnValues.oldExchange).to.equal(oldExchange.address); 190 | expect(events[0].returnValues.newExchange).to.equal(newExchange.address); 191 | }); 192 | 193 | it('should revert when no upgrade in progress', async () => { 194 | const { governance } = await deployAndAssociateContracts(); 195 | 196 | let error; 197 | try { 198 | await governance.cancelExchangeUpgrade(); 199 | } catch (e) { 200 | error = e; 201 | } 202 | expect(error).to.not.be.undefined; 203 | expect(error.message).to.match(/no exchange upgrade in progress/i); 204 | }); 205 | }); 206 | 207 | describe('finalizeExchangeUpgrade', () => { 208 | it('should work when in progress and addresses match', async () => { 209 | const { custodian, governance } = await deployAndAssociateContracts(); 210 | const newExchange = await Exchange.new(); 211 | 212 | await governance.initiateExchangeUpgrade(newExchange.address); 213 | await governance.finalizeExchangeUpgrade(newExchange.address); 214 | 215 | const events = await governance.getPastEvents( 216 | 'ExchangeUpgradeFinalized', 217 | { 218 | fromBlock: 0, 219 | }, 220 | ); 221 | expect(events).to.be.an('array'); 222 | expect(events.length).to.equal(1); 223 | expect(await custodian.loadExchange()).to.equal(newExchange.address); 224 | }); 225 | 226 | it('should revert when no upgrade in progress', async () => { 227 | const { governance } = await deployAndAssociateContracts(); 228 | 229 | let error; 230 | try { 231 | await governance.finalizeExchangeUpgrade(ethAddress); 232 | } catch (e) { 233 | error = e; 234 | } 235 | expect(error).to.not.be.undefined; 236 | expect(error.message).to.match(/no exchange upgrade in progress/i); 237 | }); 238 | 239 | it('should revert on address mismatch', async () => { 240 | const { governance } = await deployAndAssociateContracts(); 241 | const newExchange = await Exchange.new(); 242 | await governance.initiateExchangeUpgrade(newExchange.address); 243 | 244 | let error; 245 | try { 246 | await governance.finalizeExchangeUpgrade(ethAddress); 247 | } catch (e) { 248 | error = e; 249 | } 250 | expect(error).to.not.be.undefined; 251 | expect(error.message).to.match(/address mismatch/i); 252 | }); 253 | 254 | it('should revert when block threshold not reached', async () => { 255 | const blockDelay = 10; 256 | const { governance } = await deployAndAssociateContracts(blockDelay); 257 | const newExchange = await Exchange.new(); 258 | await governance.initiateExchangeUpgrade(newExchange.address); 259 | 260 | let error; 261 | try { 262 | await governance.finalizeExchangeUpgrade(newExchange.address); 263 | } catch (e) { 264 | error = e; 265 | } 266 | expect(error).to.not.be.undefined; 267 | expect(error.message).to.match(/block threshold not yet reached/i); 268 | }); 269 | }); 270 | 271 | describe('initiateGovernanceUpgrade', () => { 272 | it('should work for valid contract address', async () => { 273 | const { governance: oldGovernance } = await deployAndAssociateContracts(); 274 | const newGovernance = await Governance.new(0); 275 | 276 | await oldGovernance.initiateGovernanceUpgrade(newGovernance.address); 277 | 278 | const events = await oldGovernance.getPastEvents( 279 | 'GovernanceUpgradeInitiated', 280 | { 281 | fromBlock: 0, 282 | }, 283 | ); 284 | expect(events).to.be.an('array'); 285 | expect(events.length).to.equal(1); 286 | expect(events[0].returnValues.oldGovernance).to.equal( 287 | oldGovernance.address, 288 | ); 289 | expect(events[0].returnValues.newGovernance).to.equal( 290 | newGovernance.address, 291 | ); 292 | expect(parseInt(events[0].returnValues.blockThreshold, 10)).to.equal( 293 | await web3.eth.getBlockNumber(), // No delay 294 | ); 295 | }); 296 | 297 | it('should revert for invalid contract address', async () => { 298 | const { governance } = await deployAndAssociateContracts(); 299 | 300 | let error; 301 | try { 302 | await governance.initiateGovernanceUpgrade(ethAddress); 303 | } catch (e) { 304 | error = e; 305 | } 306 | expect(error).to.not.be.undefined; 307 | expect(error.message).to.match(/invalid address/i); 308 | }); 309 | 310 | it('should revert for non-contract address', async () => { 311 | const governance = await Governance.new(0); 312 | 313 | let error; 314 | try { 315 | await governance.initiateGovernanceUpgrade(accounts[0]); 316 | } catch (e) { 317 | error = e; 318 | } 319 | expect(error).to.not.be.undefined; 320 | expect(error.message).to.match(/invalid address/i); 321 | }); 322 | 323 | it('should revert for same Governance address', async () => { 324 | const { governance } = await deployAndAssociateContracts(); 325 | 326 | let error; 327 | try { 328 | await governance.initiateGovernanceUpgrade(governance.address); 329 | } catch (e) { 330 | error = e; 331 | } 332 | expect(error).to.not.be.undefined; 333 | expect(error.message).to.match( 334 | /must be different from current governance/i, 335 | ); 336 | }); 337 | 338 | it('should revert when upgrade already in progress', async () => { 339 | const { governance } = await deployAndAssociateContracts(); 340 | const newGovernance = await Governance.new(0); 341 | await governance.initiateGovernanceUpgrade(newGovernance.address); 342 | 343 | let error; 344 | try { 345 | await governance.initiateGovernanceUpgrade(newGovernance.address); 346 | } catch (e) { 347 | error = e; 348 | } 349 | expect(error).to.not.be.undefined; 350 | expect(error.message).to.match(/governance upgrade already in progress/i); 351 | }); 352 | }); 353 | 354 | describe('cancelGovernanceUpgrade', () => { 355 | it('should work when in progress', async () => { 356 | const { governance } = await deployAndAssociateContracts(); 357 | const newGovernance = await Governance.new(0); 358 | 359 | await governance.initiateGovernanceUpgrade(newGovernance.address); 360 | await governance.cancelGovernanceUpgrade(); 361 | 362 | const events = await governance.getPastEvents( 363 | 'GovernanceUpgradeCanceled', 364 | { 365 | fromBlock: 0, 366 | }, 367 | ); 368 | expect(events).to.be.an('array'); 369 | expect(events.length).to.equal(1); 370 | expect(events[0].returnValues.oldGovernance).to.equal(governance.address); 371 | expect(events[0].returnValues.newGovernance).to.equal( 372 | newGovernance.address, 373 | ); 374 | }); 375 | 376 | it('should revert when no upgrade in progress', async () => { 377 | const { governance } = await deployAndAssociateContracts(); 378 | 379 | let error; 380 | try { 381 | await governance.cancelGovernanceUpgrade(); 382 | } catch (e) { 383 | error = e; 384 | } 385 | expect(error).to.not.be.undefined; 386 | expect(error.message).to.match(/no governance upgrade in progress/i); 387 | }); 388 | }); 389 | 390 | describe('finalizeGovernanceUpgrade', () => { 391 | it('should work when in progress and addresses match', async () => { 392 | const { custodian, governance } = await deployAndAssociateContracts(); 393 | const newGovernance = await Governance.new(0); 394 | 395 | await governance.initiateGovernanceUpgrade(newGovernance.address); 396 | await governance.finalizeGovernanceUpgrade(newGovernance.address); 397 | 398 | const events = await governance.getPastEvents( 399 | 'GovernanceUpgradeFinalized', 400 | { 401 | fromBlock: 0, 402 | }, 403 | ); 404 | expect(events).to.be.an('array'); 405 | expect(events.length).to.equal(1); 406 | 407 | expect(await custodian.loadGovernance()).to.equal(newGovernance.address); 408 | }); 409 | 410 | it('should revert when no upgrade in progress', async () => { 411 | const { governance } = await deployAndAssociateContracts(); 412 | 413 | let error; 414 | try { 415 | await governance.finalizeGovernanceUpgrade(ethAddress); 416 | } catch (e) { 417 | error = e; 418 | } 419 | expect(error).to.not.be.undefined; 420 | expect(error.message).to.match(/no governance upgrade in progress/i); 421 | }); 422 | 423 | it('should revert on address mismatch', async () => { 424 | const { governance } = await deployAndAssociateContracts(); 425 | const newGovernance = await Governance.new(0); 426 | await governance.initiateGovernanceUpgrade(newGovernance.address); 427 | 428 | let error; 429 | try { 430 | await governance.finalizeGovernanceUpgrade(ethAddress); 431 | } catch (e) { 432 | error = e; 433 | } 434 | expect(error).to.not.be.undefined; 435 | expect(error.message).to.match(/address mismatch/i); 436 | }); 437 | 438 | it('should revert when called before block threshold reached', async () => { 439 | const { governance } = await deployAndAssociateContracts(10); 440 | const newGovernance = await Governance.new(10); 441 | await governance.initiateGovernanceUpgrade(newGovernance.address); 442 | 443 | let error; 444 | try { 445 | await governance.finalizeGovernanceUpgrade(newGovernance.address); 446 | } catch (e) { 447 | error = e; 448 | } 449 | expect(error).to.not.be.undefined; 450 | expect(error.message).to.match(/block threshold not yet reached/i); 451 | }); 452 | }); 453 | }); 454 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { CustodianInstance } from '../types/truffle-contracts/Custodian'; 2 | import type { ExchangeInstance } from '../types/truffle-contracts/Exchange'; 3 | import type { GovernanceInstance } from '../types/truffle-contracts/Governance'; 4 | import type { TestTokenInstance } from '../types/truffle-contracts/TestToken'; 5 | import type { Withdrawal } from '../lib'; 6 | 7 | import { 8 | decimalToAssetUnits, 9 | getWithdrawArguments, 10 | getWithdrawalHash, 11 | } from '../lib'; 12 | 13 | export const ethAddress = web3.utils.bytesToHex([...Buffer.alloc(20)]); 14 | export const ethSymbol = 'ETH'; 15 | 16 | // TODO Test tokens with decimals other than 18 17 | export const minimumDecimalQuantity = '0.00000001'; 18 | export const minimumTokenQuantity = decimalToAssetUnits( 19 | minimumDecimalQuantity, 20 | 18, 21 | ); 22 | export const deployAndAssociateContracts = async ( 23 | blockDelay = 0, 24 | ): Promise<{ 25 | custodian: CustodianInstance; 26 | exchange: ExchangeInstance; 27 | governance: GovernanceInstance; 28 | }> => { 29 | const Custodian = artifacts.require('Custodian'); 30 | const Exchange = artifacts.require('Exchange'); 31 | const Governance = artifacts.require('Governance'); 32 | 33 | const [exchange, governance] = await Promise.all([ 34 | Exchange.new(), 35 | Governance.new(blockDelay), 36 | ]); 37 | const custodian = await Custodian.new(exchange.address, governance.address); 38 | await exchange.setCustodian(custodian.address); 39 | await governance.setCustodian(custodian.address); 40 | 41 | return { custodian, exchange, governance }; 42 | }; 43 | 44 | export const deployAndRegisterToken = async ( 45 | exchange: ExchangeInstance, 46 | tokenSymbol: string, 47 | decimals = 18, 48 | ): Promise => { 49 | const Token = artifacts.require('TestToken'); 50 | const token = await Token.new(); 51 | await exchange.registerToken(token.address, tokenSymbol, decimals); 52 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, decimals); 53 | 54 | return token; 55 | }; 56 | 57 | export const getSignature = async ( 58 | web3: Web3, 59 | data: string, 60 | wallet: string, 61 | ): Promise => { 62 | const signature = await web3.eth.sign(data, wallet); 63 | // https://github.com/OpenZeppelin/openzeppelin-contracts/issues/2190 64 | // The Ethereum spec requires a v value of 27 or 28, but ganache's RPC signature returns 65 | // a 0 or 1 instead. Add 27 in this case to make compatible with ECDSA recover 66 | let v = parseInt(signature.slice(130, 132), 16); 67 | if (v < 27) { 68 | v += 27; 69 | } 70 | const vHex = v.toString(16); 71 | return signature.slice(0, 130) + vHex; 72 | }; 73 | 74 | export const withdraw = async ( 75 | web3: Web3, 76 | exchange: ExchangeInstance, 77 | withdrawal: Withdrawal, 78 | wallet: string, 79 | gasFee = '0.00000000', 80 | ): Promise => { 81 | const [withdrawalStruct] = await getWithdrawArguments( 82 | withdrawal, 83 | gasFee, 84 | await getSignature(web3, getWithdrawalHash(withdrawal), wallet), 85 | ); 86 | 87 | await exchange.withdraw(withdrawalStruct); 88 | }; 89 | -------------------------------------------------------------------------------- /test/invalidate.ts: -------------------------------------------------------------------------------- 1 | import { v1 as uuidv1, v4 as uuidv4 } from 'uuid'; 2 | 3 | import { deployAndAssociateContracts } from './helpers'; 4 | import { uuidToHexString } from '../lib'; 5 | 6 | // See trade.ts for tests covering executeTrade behavior for invalidated order nonces 7 | contract('Exchange (invalidations)', (accounts) => { 8 | describe('invalidateOrderNonce', async () => { 9 | it('should work on initial call', async () => { 10 | const { exchange } = await deployAndAssociateContracts(); 11 | 12 | await exchange.invalidateOrderNonce(uuidToHexString(uuidv1())); 13 | 14 | const events = await exchange.getPastEvents('OrderNonceInvalidated', { 15 | fromBlock: 0, 16 | }); 17 | expect(events).to.be.an('array'); 18 | expect(events.length).to.equal(1); 19 | }); 20 | 21 | it('should work on subsequent call with a later timestamp', async () => { 22 | const { exchange } = await deployAndAssociateContracts(); 23 | 24 | await exchange.invalidateOrderNonce(uuidToHexString(uuidv1())); 25 | await exchange.invalidateOrderNonce(uuidToHexString(uuidv1())); 26 | 27 | const events = await exchange.getPastEvents('OrderNonceInvalidated', { 28 | fromBlock: 0, 29 | }); 30 | expect(events).to.be.an('array'); 31 | expect(events.length).to.equal(2); 32 | }); 33 | 34 | it('should revert for nonce with timestamp too far in the future', async () => { 35 | const { exchange } = await deployAndAssociateContracts(); 36 | const uuid = uuidv1(); 37 | await exchange.invalidateOrderNonce(uuidToHexString(uuid)); 38 | 39 | let error; 40 | try { 41 | await exchange.invalidateOrderNonce( 42 | uuidToHexString( 43 | uuidv1({ msecs: new Date().getTime() + 48 * 60 * 60 * 1000 }), // 2 days, max is 1 44 | ), 45 | ); 46 | } catch (e) { 47 | error = e; 48 | } 49 | 50 | expect(error).to.not.be.undefined; 51 | expect(error.message).to.match(/nonce timestamp too far in future/i); 52 | }); 53 | 54 | it('should revert on subsequent call with same timestamp', async () => { 55 | const { exchange } = await deployAndAssociateContracts(); 56 | const uuid = uuidv1(); 57 | await exchange.invalidateOrderNonce(uuidToHexString(uuid)); 58 | 59 | let error; 60 | try { 61 | await exchange.invalidateOrderNonce(uuidToHexString(uuid)); 62 | } catch (e) { 63 | error = e; 64 | } 65 | 66 | expect(error).to.not.be.undefined; 67 | expect(error.message).to.match(/nonce timestamp already invalidated/i); 68 | }); 69 | 70 | it('should revert on subsequent call before block threshold of previous', async () => { 71 | const { exchange } = await deployAndAssociateContracts(); 72 | await exchange.setChainPropagationPeriod(10); 73 | await exchange.invalidateOrderNonce(uuidToHexString(uuidv1())); 74 | 75 | let error; 76 | try { 77 | await exchange.invalidateOrderNonce(uuidToHexString(uuidv1())); 78 | } catch (e) { 79 | error = e; 80 | } 81 | 82 | expect(error).to.not.be.undefined; 83 | expect(error.message).to.match( 84 | /previous invalidation awaiting chain propagation/i, 85 | ); 86 | }); 87 | 88 | it('should revert for non-V1 UUID', async () => { 89 | const { exchange } = await deployAndAssociateContracts(); 90 | 91 | let error; 92 | try { 93 | await exchange.invalidateOrderNonce(uuidToHexString(uuidv4())); 94 | } catch (e) { 95 | error = e; 96 | } 97 | 98 | expect(error).to.not.be.undefined; 99 | expect(error.message).to.match(/must be v1 UUID/i); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/safemath.ts: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/OpenZeppelin/openzeppelin-contracts/blob/5f92adc2e76fe92d7ab952710ff3fb6d76066a35/test/math/SafeMath.test.js 2 | // 3 | import chai from 'chai'; 4 | 5 | import { SafeMath64MockInstance } from '../types/truffle-contracts'; 6 | 7 | /* eslint-disable-next-line @typescript-eslint/no-var-requires */ 8 | const { BN, expectRevert } = require('@openzeppelin/test-helpers'); 9 | 10 | const MAX_UINT64 = new BN('2').pow(new BN('64')).sub(new BN('1')); 11 | 12 | describe('SafeMath64', () => { 13 | const SafeMathMock = artifacts.require('SafeMath64Mock'); 14 | let safeMath: SafeMath64MockInstance; 15 | 16 | beforeEach(async () => { 17 | safeMath = await SafeMathMock.new(); 18 | }); 19 | 20 | async function testCommutative( 21 | fn: any, 22 | lhs: BN, 23 | rhs: BN, 24 | expected: BN, 25 | ): Promise { 26 | expect((await fn(lhs, rhs)).toString()).to.equal(expected.toString()); 27 | expect((await fn(rhs, lhs)).toString()).to.equal(expected.toString()); 28 | } 29 | 30 | async function testFailsCommutative( 31 | fn: any, 32 | lhs: BN, 33 | rhs: BN, 34 | reason: string, 35 | ): Promise { 36 | await expectRevert(fn(lhs, rhs), reason); 37 | await expectRevert(fn(rhs, lhs), reason); 38 | } 39 | 40 | describe('add', () => { 41 | it('adds correctly', async () => { 42 | const a = new BN('5678'); 43 | const b = new BN('1234'); 44 | 45 | await testCommutative(safeMath.add, a, b, a.add(b)); 46 | }); 47 | 48 | it('reverts on addition overflow', async () => { 49 | const a = MAX_UINT64; 50 | const b = new BN('1'); 51 | 52 | await testFailsCommutative( 53 | safeMath.add, 54 | a, 55 | b, 56 | 'SafeMath: addition overflow', 57 | ); 58 | }); 59 | }); 60 | 61 | describe('sub', () => { 62 | it('subtracts correctly', async () => { 63 | const a = new BN('5678'); 64 | const b = new BN('1234'); 65 | 66 | expect((await safeMath.sub(a, b)).toString()).to.equal( 67 | a.sub(b).toString(), 68 | ); 69 | }); 70 | 71 | it('reverts if subtraction result would be negative', async () => { 72 | const a = new BN('1234'); 73 | const b = new BN('5678'); 74 | 75 | await expectRevert(safeMath.sub(a, b), 'SafeMath: subtraction overflow'); 76 | }); 77 | }); 78 | 79 | describe('mul', () => { 80 | it('multiplies correctly', async () => { 81 | const a = new BN('1234'); 82 | const b = new BN('5678'); 83 | 84 | await testCommutative(safeMath.mul, a, b, a.mul(b)); 85 | }); 86 | 87 | it('multiplies by zero correctly', async () => { 88 | const a = new BN('0'); 89 | const b = new BN('5678'); 90 | 91 | await testCommutative(safeMath.mul, a, b, new BN('0')); 92 | }); 93 | 94 | it('reverts on multiplication overflow', async () => { 95 | const a = MAX_UINT64; 96 | const b = new BN('2'); 97 | 98 | await testFailsCommutative( 99 | safeMath.mul, 100 | a, 101 | b, 102 | 'SafeMath: multiplication overflow', 103 | ); 104 | }); 105 | }); 106 | 107 | describe('div', () => { 108 | it('divides correctly', async () => { 109 | const a = new BN('5678'); 110 | const b = new BN('5678'); 111 | 112 | expect((await safeMath.div(a, b)).toString()).to.equal( 113 | a.div(b).toString(), 114 | ); 115 | }); 116 | 117 | it('divides zero correctly', async () => { 118 | const a = new BN('0'); 119 | const b = new BN('5678'); 120 | 121 | expect((await safeMath.div(a, b)).toString()).to.equal('0'); 122 | }); 123 | 124 | it('returns complete number result on non-even division', async () => { 125 | const a = new BN('7000'); 126 | const b = new BN('5678'); 127 | 128 | expect((await safeMath.div(a, b)).toString()).to.equal('1'); 129 | }); 130 | 131 | it('reverts on division by zero', async () => { 132 | const a = new BN('5678'); 133 | const b = new BN('0'); 134 | 135 | await expectRevert(safeMath.div(a, b), 'SafeMath: division by zero'); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /test/trade-block-time.ts: -------------------------------------------------------------------------------- 1 | import { v1 as uuidv1 } from 'uuid'; 2 | 3 | import { 4 | deployAndAssociateContracts, 5 | deployAndRegisterToken, 6 | ethAddress, 7 | } from './helpers'; 8 | import { deposit, executeTrade, generateOrdersAndFill } from './trade'; 9 | 10 | const tokenSymbol = 'TKN'; 11 | 12 | // These tests advance the block timestamp to test the nonce-timestamp filtering for the asset 13 | // registry. Changing the block timestamp causes side effects for other tests that don't specifically 14 | // handle it, so isolate these tests here 15 | contract('Exchange (trades)', (accounts) => { 16 | describe('executeTrade', () => { 17 | it('should revert when buy order base asset is mismatched with trade', async () => { 18 | const { exchange } = await deployAndAssociateContracts(); 19 | await deployAndRegisterToken(exchange, tokenSymbol); 20 | const oldTimestampMs = 21 | ((await web3.eth.getBlock('latest')).timestamp as number) * 1000; 22 | await increaseBlockTimestamp(); 23 | const token = await deployAndRegisterToken(exchange, tokenSymbol); 24 | const newTimestampMs = 25 | ((await web3.eth.getBlock('latest')).timestamp as number) * 1000; 26 | await exchange.setDispatcher(accounts[0]); 27 | const [sellWallet, buyWallet] = accounts; 28 | await deposit(exchange, token, buyWallet, sellWallet); 29 | 30 | const { buyOrder, sellOrder, fill } = await generateOrdersAndFill( 31 | token.address, 32 | ethAddress, 33 | buyWallet, 34 | sellWallet, 35 | ); 36 | buyOrder.nonce = uuidv1({ msecs: oldTimestampMs }); 37 | sellOrder.nonce = uuidv1({ msecs: newTimestampMs }); 38 | 39 | let error; 40 | try { 41 | await executeTrade( 42 | exchange, 43 | buyWallet, 44 | sellWallet, 45 | buyOrder, 46 | sellOrder, 47 | fill, 48 | ); 49 | } catch (e) { 50 | error = e; 51 | } 52 | expect(error).to.not.be.undefined; 53 | expect(error.message).to.match( 54 | /buy order market symbol address resolution mismatch/i, 55 | ); 56 | }); 57 | 58 | it('should revert when sell order base asset is mismatched with trade', async () => { 59 | const { exchange } = await deployAndAssociateContracts(); 60 | await deployAndRegisterToken(exchange, tokenSymbol); 61 | const oldTimestampMs = 62 | ((await web3.eth.getBlock('latest')).timestamp as number) * 1000; 63 | await increaseBlockTimestamp(); 64 | const token = await deployAndRegisterToken(exchange, tokenSymbol); 65 | const newTimestampMs = 66 | ((await web3.eth.getBlock('latest')).timestamp as number) * 1000; 67 | await exchange.setDispatcher(accounts[0]); 68 | const [sellWallet, buyWallet] = accounts; 69 | await deposit(exchange, token, buyWallet, sellWallet); 70 | 71 | const { buyOrder, sellOrder, fill } = await generateOrdersAndFill( 72 | token.address, 73 | ethAddress, 74 | buyWallet, 75 | sellWallet, 76 | ); 77 | buyOrder.nonce = uuidv1({ msecs: newTimestampMs }); 78 | sellOrder.nonce = uuidv1({ msecs: oldTimestampMs }); 79 | 80 | let error; 81 | try { 82 | await executeTrade( 83 | exchange, 84 | buyWallet, 85 | sellWallet, 86 | buyOrder, 87 | sellOrder, 88 | fill, 89 | ); 90 | } catch (e) { 91 | error = e; 92 | } 93 | expect(error).to.not.be.undefined; 94 | expect(error.message).to.match( 95 | /sell order market symbol address resolution mismatch/i, 96 | ); 97 | }); 98 | }); 99 | }); 100 | 101 | // https://docs.nethereum.com/en/latest/ethereum-and-clients/ganache-cli/#implemented-methods 102 | const increaseBlockTimestamp = async (): Promise => { 103 | await sendRpc('evm_increaseTime', [1]); // 1 second 104 | await sendRpc('evm_mine', []); 105 | }; 106 | 107 | const sendRpc = async (method: string, params: unknown[]): Promise => 108 | new Promise((resolve, reject) => { 109 | (web3 as any).currentProvider.send( 110 | { 111 | jsonrpc: '2.0', 112 | method, 113 | params, 114 | id: new Date().getTime(), 115 | }, 116 | (err: unknown, res: unknown) => { 117 | if (err) { 118 | reject(err); 119 | } else { 120 | resolve(res); 121 | } 122 | }, 123 | ); 124 | }); 125 | -------------------------------------------------------------------------------- /test/uuid.ts: -------------------------------------------------------------------------------- 1 | import { v1 as uuidv1, v4 as uuidv4 } from 'uuid'; 2 | 3 | import { uuidToHexString } from '../lib'; 4 | 5 | contract('UUID', () => { 6 | const UUIDMock = artifacts.require('UUIDMock'); 7 | 8 | describe('getTimestampInMsFromUuidV1', () => { 9 | it('should work for current timestamp', async () => { 10 | const uuidMock = await UUIDMock.new(); 11 | 12 | const inputTimestamp = new Date().getTime(); 13 | const outputTimestamp = ( 14 | await uuidMock.getTimestampInMsFromUuidV1( 15 | uuidToHexString(uuidv1({ msecs: inputTimestamp })), 16 | ) 17 | ).toNumber(); 18 | 19 | expect(outputTimestamp).to.equal(inputTimestamp); 20 | }); 21 | 22 | it('should work for 0', async () => { 23 | const uuidMock = await UUIDMock.new(); 24 | 25 | const inputTimestamp = 0; 26 | const outputTimestamp = ( 27 | await uuidMock.getTimestampInMsFromUuidV1( 28 | uuidToHexString(uuidv1({ msecs: inputTimestamp })), 29 | ) 30 | ).toNumber(); 31 | 32 | expect(outputTimestamp).to.equal(inputTimestamp); 33 | }); 34 | 35 | it('should revert for wrong UUID version', async () => { 36 | const uuidMock = await UUIDMock.new(); 37 | 38 | let error; 39 | try { 40 | await uuidMock.getTimestampInMsFromUuidV1(uuidToHexString(uuidv4())); 41 | } catch (e) { 42 | error = e; 43 | } 44 | 45 | expect(error).to.not.be.undefined; 46 | expect(error.message).to.match(/must be v1 uuid/i); 47 | }); 48 | 49 | it('should revert for timestamp before Unix epoch', async () => { 50 | const uuidMock = await UUIDMock.new(); 51 | 52 | const zeroTimeAndVersion1Mask = '0x0000000000001000'; 53 | const uuid = uuidToHexString(uuidv1()); 54 | const earliestUuid = `${zeroTimeAndVersion1Mask}${uuid.slice( 55 | zeroTimeAndVersion1Mask.length, 56 | )}`; 57 | 58 | let error; 59 | try { 60 | await uuidMock.getTimestampInMsFromUuidV1(earliestUuid); 61 | } catch (e) { 62 | error = e; 63 | } 64 | 65 | expect(error).to.not.be.undefined; 66 | expect(error.message).to.match(/subtraction overflow/i); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/withdraw.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { v1 as uuidv1 } from 'uuid'; 3 | 4 | import type { CustodianInstance } from '../types/truffle-contracts/Custodian'; 5 | import type { ExchangeInstance } from '../types/truffle-contracts/Exchange'; 6 | import type { GovernanceInstance } from '../types/truffle-contracts/Governance'; 7 | 8 | import { 9 | decimalToAssetUnits, 10 | decimalToPips, 11 | getWithdrawArguments, 12 | getWithdrawalHash, 13 | } from '../lib'; 14 | import { 15 | deployAndRegisterToken, 16 | ethAddress, 17 | ethSymbol, 18 | getSignature, 19 | minimumDecimalQuantity, 20 | minimumTokenQuantity, 21 | withdraw, 22 | } from './helpers'; 23 | 24 | // TODO Non-zero gas fees 25 | contract('Exchange (withdrawals)', (accounts) => { 26 | const Custodian = artifacts.require('Custodian'); 27 | const Exchange = artifacts.require('Exchange'); 28 | const Governance = artifacts.require('Governance'); 29 | const NonCompliantToken = artifacts.require('NonCompliantToken'); 30 | const SkimmingToken = artifacts.require('SkimmingTestToken'); 31 | const Token = artifacts.require('TestToken'); 32 | 33 | const tokenSymbol = 'TKN'; 34 | 35 | describe('withdraw', () => { 36 | it('should work by symbol for ETH', async () => { 37 | const { exchange } = await deployAndAssociateContracts(); 38 | await exchange.setDispatcher(accounts[0]); 39 | await exchange.depositEther({ 40 | value: minimumTokenQuantity, 41 | from: accounts[0], 42 | }); 43 | 44 | await withdraw( 45 | web3, 46 | exchange, 47 | { 48 | nonce: uuidv1(), 49 | wallet: accounts[0], 50 | quantity: minimumDecimalQuantity, 51 | autoDispatchEnabled: true, 52 | asset: ethSymbol, 53 | }, 54 | accounts[0], 55 | ); 56 | 57 | await assertWithdrawnEvent( 58 | exchange, 59 | accounts[0], 60 | ethAddress, 61 | ethSymbol, 62 | minimumDecimalQuantity, 63 | ); 64 | 65 | expect( 66 | ( 67 | await exchange.loadBalanceInAssetUnitsByAddress( 68 | accounts[0], 69 | ethAddress, 70 | ) 71 | ).toString(), 72 | ).to.equal('0'); 73 | expect( 74 | ( 75 | await exchange.loadBalanceInPipsByAddress(accounts[0], ethAddress) 76 | ).toString(), 77 | ).to.equal('0'); 78 | }); 79 | 80 | it('should work by address for ETH', async () => { 81 | const { exchange } = await deployAndAssociateContracts(); 82 | await exchange.setDispatcher(accounts[0]); 83 | await exchange.depositEther({ 84 | value: minimumTokenQuantity, 85 | from: accounts[0], 86 | }); 87 | 88 | await withdraw( 89 | web3, 90 | exchange, 91 | { 92 | nonce: uuidv1(), 93 | wallet: accounts[0], 94 | quantity: minimumDecimalQuantity, 95 | autoDispatchEnabled: true, 96 | assetContractAddress: ethAddress, 97 | }, 98 | accounts[0], 99 | ); 100 | 101 | await assertWithdrawnEvent( 102 | exchange, 103 | accounts[0], 104 | ethAddress, 105 | ethSymbol, 106 | minimumDecimalQuantity, 107 | ); 108 | 109 | expect( 110 | ( 111 | await exchange.loadBalanceInAssetUnitsByAddress( 112 | accounts[0], 113 | ethAddress, 114 | ) 115 | ).toString(), 116 | ).to.equal('0'); 117 | expect( 118 | ( 119 | await exchange.loadBalanceInPipsByAddress(accounts[0], ethAddress) 120 | ).toString(), 121 | ).to.equal('0'); 122 | }); 123 | 124 | it('should work by symbol for token', async () => { 125 | const { exchange } = await deployAndAssociateContracts(); 126 | const token = await deployAndRegisterToken(exchange, tokenSymbol); 127 | await exchange.setDispatcher(accounts[0]); 128 | await token.approve(exchange.address, minimumTokenQuantity); 129 | await exchange.depositTokenByAddress(token.address, minimumTokenQuantity); 130 | 131 | await withdraw( 132 | web3, 133 | exchange, 134 | { 135 | nonce: uuidv1(), 136 | wallet: accounts[0], 137 | quantity: minimumDecimalQuantity, 138 | autoDispatchEnabled: true, 139 | asset: tokenSymbol, 140 | }, 141 | accounts[0], 142 | ); 143 | 144 | await assertWithdrawnEvent( 145 | exchange, 146 | accounts[0], 147 | token.address, 148 | tokenSymbol, 149 | minimumDecimalQuantity, 150 | ); 151 | 152 | expect( 153 | ( 154 | await exchange.loadBalanceInAssetUnitsByAddress( 155 | accounts[0], 156 | ethAddress, 157 | ) 158 | ).toString(), 159 | ).to.equal('0'); 160 | expect( 161 | ( 162 | await exchange.loadBalanceInPipsByAddress(accounts[0], ethAddress) 163 | ).toString(), 164 | ).to.equal('0'); 165 | }); 166 | 167 | it('should work by symbol for non-compliant token', async () => { 168 | const { exchange } = await deployAndAssociateContracts(); 169 | const token = await NonCompliantToken.new(); 170 | await exchange.setDispatcher(accounts[0]); 171 | 172 | await exchange.registerToken(token.address, tokenSymbol, 18); 173 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 174 | await token.approve(exchange.address, minimumTokenQuantity); 175 | await exchange.depositTokenByAddress(token.address, minimumTokenQuantity); 176 | 177 | await withdraw( 178 | web3, 179 | exchange, 180 | { 181 | nonce: uuidv1(), 182 | wallet: accounts[0], 183 | quantity: minimumDecimalQuantity, 184 | autoDispatchEnabled: true, 185 | asset: tokenSymbol, 186 | }, 187 | accounts[0], 188 | ); 189 | 190 | await assertWithdrawnEvent( 191 | exchange, 192 | accounts[0], 193 | token.address, 194 | tokenSymbol, 195 | minimumDecimalQuantity, 196 | ); 197 | 198 | expect( 199 | ( 200 | await exchange.loadBalanceInAssetUnitsByAddress( 201 | accounts[0], 202 | ethAddress, 203 | ) 204 | ).toString(), 205 | ).to.equal('0'); 206 | expect( 207 | ( 208 | await exchange.loadBalanceInPipsByAddress(accounts[0], ethAddress) 209 | ).toString(), 210 | ).to.equal('0'); 211 | }); 212 | 213 | it('should deduct fee', async () => { 214 | const { exchange } = await deployAndAssociateContracts(); 215 | const token = await deployAndRegisterToken(exchange, tokenSymbol); 216 | await exchange.setDispatcher(accounts[0]); 217 | await exchange.setFeeWallet(accounts[1]); 218 | 219 | const tokenBalanceBefore = ( 220 | await token.balanceOf(accounts[0]) 221 | ).toString(); 222 | const withdrawalAmount = new BigNumber(minimumDecimalQuantity) 223 | .multipliedBy(100) 224 | .toFixed(8); 225 | await token.approve( 226 | exchange.address, 227 | decimalToAssetUnits(withdrawalAmount, 18), 228 | ); 229 | await exchange.depositTokenByAddress( 230 | token.address, 231 | decimalToAssetUnits(withdrawalAmount, 18), 232 | ); 233 | 234 | await withdraw( 235 | web3, 236 | exchange, 237 | { 238 | nonce: uuidv1(), 239 | wallet: accounts[0], 240 | quantity: withdrawalAmount, 241 | autoDispatchEnabled: true, 242 | asset: tokenSymbol, 243 | }, 244 | accounts[0], 245 | minimumDecimalQuantity, 246 | ); 247 | 248 | await assertWithdrawnEvent( 249 | exchange, 250 | accounts[0], 251 | token.address, 252 | tokenSymbol, 253 | withdrawalAmount, 254 | ); 255 | 256 | expect( 257 | ( 258 | await exchange.loadBalanceInAssetUnitsByAddress( 259 | accounts[0], 260 | token.address, 261 | ) 262 | ).toString(), 263 | ).to.equal('0'); 264 | expect( 265 | ( 266 | await exchange.loadBalanceInPipsByAddress(accounts[0], token.address) 267 | ).toString(), 268 | ).to.equal('0'); 269 | expect( 270 | ( 271 | await exchange.loadBalanceInAssetUnitsByAddress( 272 | accounts[1], 273 | token.address, 274 | ) 275 | ).toString(), 276 | ).to.equal(minimumTokenQuantity); 277 | expect((await token.balanceOf(accounts[0])).toString()).to.equal( 278 | new BigNumber(tokenBalanceBefore) 279 | .minus(new BigNumber(minimumTokenQuantity)) 280 | .toFixed(0), 281 | ); 282 | }); 283 | 284 | it('should revert for unknown token', async () => { 285 | const { exchange } = await deployAndAssociateContracts(); 286 | const token = await Token.new(); 287 | await exchange.setDispatcher(accounts[0]); 288 | 289 | let error; 290 | try { 291 | await withdraw( 292 | web3, 293 | exchange, 294 | { 295 | nonce: uuidv1(), 296 | wallet: accounts[0], 297 | quantity: minimumDecimalQuantity, 298 | autoDispatchEnabled: true, 299 | assetContractAddress: token.address, 300 | }, 301 | accounts[0], 302 | ); 303 | } catch (e) { 304 | error = e; 305 | } 306 | expect(error).to.not.be.undefined; 307 | expect(error.message).to.match(/no confirmed asset found/i); 308 | }); 309 | 310 | it('should revert when token skims from transfer', async () => { 311 | const { exchange } = await deployAndAssociateContracts(); 312 | await exchange.setDispatcher(accounts[0]); 313 | const token = await SkimmingToken.new(); 314 | await exchange.registerToken(token.address, tokenSymbol, 18); 315 | await exchange.confirmTokenRegistration(token.address, tokenSymbol, 18); 316 | await token.approve(exchange.address, minimumTokenQuantity); 317 | await exchange.depositTokenByAddress(token.address, minimumTokenQuantity); 318 | await token.setShouldSkim(true); 319 | 320 | let error; 321 | try { 322 | await withdraw( 323 | web3, 324 | exchange, 325 | { 326 | nonce: uuidv1(), 327 | wallet: accounts[0], 328 | quantity: minimumDecimalQuantity, 329 | autoDispatchEnabled: true, 330 | asset: tokenSymbol, 331 | }, 332 | accounts[0], 333 | ); 334 | } catch (e) { 335 | error = e; 336 | } 337 | expect(error).to.not.be.undefined; 338 | expect(error.message).to.match( 339 | / transfer success without expected balance change/i, 340 | ); 341 | 342 | const events = await exchange.getPastEvents('Withdrawn', { 343 | fromBlock: 0, 344 | }); 345 | expect(events).to.be.an('array'); 346 | expect(events.length).to.equal(0); 347 | }); 348 | 349 | it('should revert for invalid signature', async () => { 350 | const { exchange } = await deployAndAssociateContracts(); 351 | await exchange.setDispatcher(accounts[0]); 352 | await exchange.depositEther({ 353 | value: minimumTokenQuantity, 354 | from: accounts[0], 355 | }); 356 | 357 | const withdrawal = { 358 | nonce: uuidv1(), 359 | wallet: accounts[0], 360 | quantity: minimumDecimalQuantity, 361 | autoDispatchEnabled: true, 362 | asset: ethSymbol, 363 | }; 364 | const [withdrawalStruct] = await getWithdrawArguments( 365 | withdrawal, 366 | '0.00000000', 367 | // Sign with a different wallet 368 | await getSignature(web3, getWithdrawalHash(withdrawal), accounts[1]), 369 | ); 370 | 371 | let error; 372 | try { 373 | await exchange.withdraw(withdrawalStruct); 374 | } catch (e) { 375 | error = e; 376 | } 377 | expect(error).to.not.be.undefined; 378 | expect(error.message).to.match(/invalid wallet signature/i); 379 | }); 380 | 381 | it('should revert for exited wallet', async () => { 382 | const { exchange } = await deployAndAssociateContracts(); 383 | await exchange.setDispatcher(accounts[0]); 384 | await exchange.depositEther({ 385 | value: minimumTokenQuantity, 386 | from: accounts[0], 387 | }); 388 | await exchange.exitWallet({ from: accounts[0] }); 389 | 390 | let error; 391 | try { 392 | await withdraw( 393 | web3, 394 | exchange, 395 | { 396 | nonce: uuidv1(), 397 | wallet: accounts[0], 398 | quantity: minimumDecimalQuantity, 399 | autoDispatchEnabled: true, 400 | asset: ethSymbol, 401 | }, 402 | accounts[0], 403 | ); 404 | } catch (e) { 405 | error = e; 406 | } 407 | expect(error).to.not.be.undefined; 408 | expect(error.message).to.match(/wallet exited/i); 409 | }); 410 | 411 | it('should revert for excessive fee', async () => { 412 | const { exchange } = await deployAndAssociateContracts(); 413 | await exchange.setDispatcher(accounts[0]); 414 | await exchange.depositEther({ 415 | value: minimumTokenQuantity, 416 | from: accounts[0], 417 | }); 418 | 419 | let error; 420 | try { 421 | await withdraw( 422 | web3, 423 | exchange, 424 | { 425 | nonce: uuidv1(), 426 | wallet: accounts[0], 427 | quantity: minimumDecimalQuantity, 428 | autoDispatchEnabled: true, 429 | asset: ethSymbol, 430 | }, 431 | accounts[0], 432 | minimumDecimalQuantity, // 100% fee 433 | ); 434 | } catch (e) { 435 | error = e; 436 | } 437 | expect(error).to.not.be.undefined; 438 | expect(error.message).to.match(/excessive withdrawal fee/i); 439 | }); 440 | 441 | it('should revert for double withdrawal', async () => { 442 | const { exchange } = await deployAndAssociateContracts(); 443 | await exchange.setDispatcher(accounts[0]); 444 | await exchange.depositEther({ 445 | value: (BigInt(minimumTokenQuantity) * BigInt(2)).toString(), 446 | from: accounts[0], 447 | }); 448 | const withdrawal = { 449 | nonce: uuidv1(), 450 | wallet: accounts[0], 451 | quantity: minimumDecimalQuantity, 452 | autoDispatchEnabled: true, 453 | asset: ethSymbol, 454 | }; 455 | const [withdrawalStruct] = await getWithdrawArguments( 456 | withdrawal, 457 | '0', 458 | await getSignature(web3, getWithdrawalHash(withdrawal), accounts[0]), 459 | ); 460 | 461 | await exchange.withdraw(withdrawalStruct); 462 | 463 | let error; 464 | try { 465 | await exchange.withdraw(withdrawalStruct); 466 | } catch (e) { 467 | error = e; 468 | } 469 | expect(error).to.not.be.undefined; 470 | expect(error.message).to.match(/already withdrawn/i); 471 | }); 472 | }); 473 | 474 | const deployAndAssociateContracts = async ( 475 | blockDelay = 0, 476 | ): Promise<{ 477 | custodian: CustodianInstance; 478 | exchange: ExchangeInstance; 479 | governance: GovernanceInstance; 480 | }> => { 481 | const [exchange, governance] = await Promise.all([ 482 | Exchange.new(), 483 | Governance.new(blockDelay), 484 | ]); 485 | const custodian = await Custodian.new(exchange.address, governance.address); 486 | await exchange.setCustodian(custodian.address); 487 | 488 | return { custodian, exchange, governance }; 489 | }; 490 | 491 | const assertWithdrawnEvent = async ( 492 | exchange: ExchangeInstance, 493 | walletAddress: string, 494 | assetAddress: string, 495 | assetSymbol: string, 496 | decimalQuantity: string, 497 | ): Promise => { 498 | const events = await exchange.getPastEvents('Withdrawn', { 499 | fromBlock: 0, 500 | }); 501 | expect(events).to.be.an('array'); 502 | expect(events.length).to.equal(1); 503 | expect(events[0].returnValues.wallet).to.equal(walletAddress); 504 | expect(events[0].returnValues.assetAddress).to.equal(assetAddress); 505 | expect(events[0].returnValues.assetSymbol).to.equal(assetSymbol); 506 | expect(events[0].returnValues.quantityInPips).to.equal( 507 | decimalToPips(decimalQuantity), 508 | ); 509 | expect(events[0].returnValues.newExchangeBalanceInPips).to.equal( 510 | decimalToPips('0'), 511 | ); 512 | expect(events[0].returnValues.newExchangeBalanceInAssetUnits).to.equal('0'); 513 | }; 514 | }); 515 | -------------------------------------------------------------------------------- /truffle-config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Use this file to configure your truffle project. It's seeded with some 3 | * common settings for different networks and features like migrations, 4 | * compilation and testing. Uncomment the ones you need or modify 5 | * them to suit your project as necessary. 6 | * 7 | * More information about configuration can be found at: 8 | * 9 | * truffleframework.com/docs/advanced/configuration 10 | * 11 | * To deploy via Infura you'll need a wallet provider (like @truffle/hdwallet-provider) 12 | * to sign your transactions before they're sent to a remote public node. Infura accounts 13 | * are available for free at: infura.io/register. 14 | * 15 | * You'll also need a mnemonic - the twelve word phrase the wallet uses to generate 16 | * public/private key pairs. If you're publishing your code to GitHub make sure you load this 17 | * phrase from a file you've .gitignored so it doesn't accidentally become public. 18 | * 19 | */ 20 | 21 | // const HDWalletProvider = require('@truffle/hdwallet-provider'); 22 | // const infuraKey = "fj4jll3k....."; 23 | // 24 | // const fs = require('fs'); 25 | // const mnemonic = fs.readFileSync(".secret").toString().trim(); 26 | 27 | module.exports = { 28 | /** 29 | * Networks define how you connect to your ethereum client and let you set the 30 | * defaults web3 uses to send transactions. If you don't specify one truffle 31 | * will spin up a development blockchain for you on port 9545 when you 32 | * run `develop` or `test`. You can ask a truffle command to use a specific 33 | * network from the command line, e.g 34 | * 35 | * $ truffle test --network 36 | */ 37 | 38 | networks: { 39 | // Useful for testing. The `development` name is special - truffle uses it by default 40 | // if it's defined here and no other network is specified at the command line. 41 | // You should run a client (like ganache-cli, geth or parity) in a separate terminal 42 | // tab if you use this network and you must also set the `host`, `port` and `network_id` 43 | // options below to some value. 44 | // 45 | // development: { 46 | // host: "127.0.0.1", // Localhost (default: none) 47 | // port: 8545, // Standard Ethereum port (default: none) 48 | // network_id: "*", // Any network (default: none) 49 | // }, 50 | // Another network with more advanced options... 51 | // advanced: { 52 | // port: 8777, // Custom port 53 | // network_id: 1342, // Custom network 54 | // gas: 8500000, // Gas sent with each transaction (default: ~6700000) 55 | // gasPrice: 20000000000, // 20 gwei (in wei) (default: 100 gwei) 56 | // from:
, // Account to send txs from (default: accounts[0]) 57 | // websockets: true // Enable EventEmitter interface for web3 (default: false) 58 | // }, 59 | // Useful for deploying to a public network. 60 | // NB: It's important to wrap the provider as a function. 61 | // ropsten: { 62 | // provider: () => new HDWalletProvider(mnemonic, `https://ropsten.infura.io/v3/YOUR-PROJECT-ID`), 63 | // network_id: 3, // Ropsten's id 64 | // gas: 5500000, // Ropsten has a lower block limit than mainnet 65 | // confirmations: 2, // # of confs to wait between deployments. (default: 0) 66 | // timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50) 67 | // skipDryRun: true // Skip dry run before migrations? (default: false for public nets ) 68 | // }, 69 | // Useful for private networks 70 | // private: { 71 | // provider: () => new HDWalletProvider(mnemonic, `https://network.io`), 72 | // network_id: 2111, // This network is yours, in the cloud. 73 | // production: true // Treats this network as if it was a public net. (default: false) 74 | // } 75 | }, 76 | 77 | plugins: ['solidity-coverage', 'truffle-security'], 78 | 79 | // Set default mocha options here, use special reporters etc. 80 | mocha: { 81 | timeout: 100000, 82 | reporter: 'mocha-multi', 83 | }, 84 | 85 | // Configure your compilers 86 | compilers: { 87 | solc: { 88 | version: '0.6.8', // Fetch exact version from solc-bin (default: truffle's version) 89 | // docker: true, // Use "0.5.1" you've installed locally with docker (default: false) 90 | settings: { 91 | // See the solidity docs for advice about optimization and evmVersion 92 | optimizer: { 93 | enabled: true, 94 | runs: 49, // Max gas savings at expense of bytecode size 95 | }, 96 | evmVersion: 'constantinople', 97 | }, 98 | }, 99 | }, 100 | 101 | test_directory: 'build/test', 102 | }; 103 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["ES2018", "DOM"], 5 | "module": "CommonJS", 6 | "moduleResolution": "node", 7 | "outDir": "build", 8 | "strict": true, 9 | "target": "ES2018", 10 | "sourceMap": true, 11 | "typeRoots": ["./types", "./node_modules/@types"], 12 | "types": ["node", "truffle-contracts"] 13 | }, 14 | "include": ["**/*.ts"], 15 | "exclude": ["node_modules", "build"] 16 | } 17 | --------------------------------------------------------------------------------