├── .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 | #
Smart Contracts
3 |
4 | 
5 | 
6 | 
7 | 
8 | 
9 |
10 | 
11 | 
12 | 
13 |
14 | 
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 |
--------------------------------------------------------------------------------
/assets/coverage-functions.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/coverage-lines.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/coverage-statements.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kumabid/idex-contracts-whistler/fb1480499d6f457d239c006ab0fe0e87510dd75e/assets/logo.png
--------------------------------------------------------------------------------
/assets/tests.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------