├── .circleci └── config.yml ├── .gitignore ├── .nycrc.json ├── .prettierrc ├── LICENSE ├── README.md ├── ava.config.js ├── index.js ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── send-receive-eth.test.ts │ ├── send-receive-token.test.ts │ └── watcher.test.ts ├── abi │ ├── TokenUnidirectional.json │ ├── Unidirectional-mainnet.json │ └── Unidirectional-testnet.json ├── account.ts ├── index.ts ├── plugins │ ├── client.ts │ └── server.ts ├── types │ └── plugin.ts └── utils │ ├── channel.ts │ ├── queue.ts │ └── store.ts ├── tsconfig.json └── tslint.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | build: 5 | docker: 6 | - image: circleci/node:10 7 | working_directory: ~/repo 8 | steps: 9 | - checkout 10 | # Download and cache dependencies 11 | - restore_cache: 12 | keys: 13 | - v10-dependencies-{{ checksum "package.json" }} 14 | # fallback to using the latest cache if no exact match is found 15 | - v10-dependencies- 16 | - run: 17 | name: Install dependencies 18 | command: npm install 19 | - save_cache: 20 | paths: 21 | - node_modules 22 | key: v10-dependencies-{{ checksum "package.json" }} 23 | - run: 24 | name: Build and compile 25 | command: npm run build 26 | - run: 27 | name: Run tests 28 | command: npm test 29 | - run: 30 | name: Lint files 31 | command: npm run lint 32 | - run: 33 | name: Upload code coverage 34 | command: npx codecov 35 | - persist_to_workspace: 36 | root: ~/repo 37 | paths: . 38 | 39 | publish: 40 | docker: 41 | - image: circleci/node:10 42 | working_directory: ~/repo 43 | steps: 44 | - attach_workspace: 45 | at: ~/repo 46 | - run: 47 | name: Authenticate with registry 48 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc 49 | - run: 50 | name: Publish package 51 | command: npm publish 52 | 53 | workflows: 54 | version: 2 55 | build_and_publish: 56 | jobs: 57 | - build: 58 | filters: 59 | tags: 60 | only: /.*/ 61 | - publish: 62 | requires: 63 | - build 64 | filters: 65 | tags: 66 | only: /^v.*/ 67 | branches: 68 | ignore: /.*/ 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | .nyc_output 5 | .DS_Store 6 | .env 7 | *.tgz 8 | .vscode 9 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*.ts" 4 | ], 5 | "exclude": [ 6 | "**/__tests__/**/*.ts", 7 | "**/*.d.ts" 8 | ], 9 | "extension": [ 10 | ".ts" 11 | ], 12 | "require": [ 13 | "ts-node/register" 14 | ], 15 | "reporter": [ 16 | "text", 17 | "lcov" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Interledger Ethereum Payment Channel Plugin 2 | 3 | [![NPM Package](https://img.shields.io/npm/v/ilp-plugin-ethereum.svg?style=flat-square&logo=npm)](https://npmjs.org/package/ilp-plugin-ethereum) 4 | [![CircleCI](https://img.shields.io/circleci/project/github/interledgerjs/ilp-plugin-ethereum/master.svg?style=flat-square&logo=circleci)](https://circleci.com/gh/interledgerjs/ilp-plugin-ethereum/master) 5 | [![Codecov](https://img.shields.io/codecov/c/github/interledgerjs/ilp-plugin-ethereum/master.svg?style=flat-square&logo=codecov)](https://codecov.io/gh/interledgerjs/ilp-plugin-ethereum) 6 | [![Prettier](https://img.shields.io/badge/code_style-prettier-brightgreen.svg?style=flat-square)](https://prettier.io/) 7 | [![Apache 2.0 License](https://img.shields.io/github/license/interledgerjs/ilp-plugin-ethereum.svg?style=flat-square)](https://github.com/interledgerjs/ilp-plugin-ethereum/blob/master/LICENSE) 8 | 9 | 🚨 **Expect breaking changes while this plugin is in beta.** 10 | 11 | ## Overview 12 | 13 | Settle Interledger packets with streaming micropayments of ETH or an ERC-20 token. 14 | 15 | Two plugins peer with one another over WebSockets and exchange Interledger packets denominated in ETH or a particular ERC-20 token. One or both peers may collateralize a unidirectional, or one-way payment channel from themselves to the other peer. Then, they may send settlements as payment channel claims at configurable frequency. Two peers may extend nearly no credit to one another and require settlements before or after every ILP packet, or extend greater credit to one another and settle less frequently. 16 | 17 | [Machinomy smart contracts](https://github.com/machinomy/machinomy/tree/master/packages/contracts/contracts) are used for the unidirectional ETH and ERC-20 payment channels. 18 | 19 | ## Install 20 | 21 | ```bash 22 | npm install ilp-plugin-ethereum 23 | ``` 24 | 25 | Node.js 10+ is required. 26 | 27 | ## Roadmap 28 | 29 | - [x] ETH payment channel client 30 | - [x] Optimizations for streaming payments 31 | - [x] ERC-20 payment channel client 32 | - [ ] More robust testing 33 | - [ ] Refactor plugin architecture to use HTTP-based interface 34 | - [ ] Eliminate internal boilerplate code 35 | - [ ] Update Machinomy contract to support MetaMask and hardware wallets 36 | - [ ] Smart liquidity management and negotiation for incoming capacity 37 | - [ ] Audit codebase 38 | 39 | ## API 40 | 41 | Here are the available options to pass to the plugin. Additional configuration options are also inherited from [ilp-plugin-btp](https://github.com/interledgerjs/ilp-plugin-btp) if the plugin is a client, and [ilp-plugin-mini-accounts](https://github.com/interledgerjs/ilp-plugin-mini-accounts) if the plugin is a server. 42 | 43 | All ILP packet amounts and accounting must use units of _gwei_, or units to 9 decimal places (wei was such a small unit that it introduced issues calculating an exchange rate). For payment channel claims, the plugin converts the amount into wei, or if using an ERC-20 token, converts to the number of decimal places of the token. 44 | 45 | #### `ethereumPrivateKey` 46 | 47 | - **Required** 48 | - Type: `string` 49 | - Private key of the Ethereum account used to sign transactions, corresponding to the address which peers must open channels to 50 | 51 | #### `ethereumProvider` 52 | 53 | - Type: 54 | - `"homestead"` to use Infura & Etherscan on mainnet 55 | - `"ropsten"` to use Infura & Etherscan on the Ropsten proof of work testnet 56 | - `"kovan"` to use Infura & Etherscan on the Kovan proof of authority testnet (Parity) 57 | - `"rinkeby"` to use Infura & Etherscan on the Rinkeby proof of authority testnet (Geth) 58 | - [`ethers.providers.Provider`](https://docs.ethers.io/ethers.js/html/api-providers.html) to supply a custom provider 59 | - Default: `"homestead"` 60 | - Provider used to connect to an Ethereum node for a particular chain/testnet 61 | 62 | #### `ethereumWallet` 63 | 64 | - Type: [`ethers.Wallet`](https://docs.ethers.io/ethers.js/html/api-wallet.html) 65 | - [Ethers wallet](https://docs.ethers.io/ethers.js/html/api-wallet.html) used to sign transactions 66 | - Must be connected to a provider to query the network 67 | - Supercedes and may be provided in lieu of `ethereumPrivateKey` and `ethereumProvider` 68 | 69 | #### `role` 70 | 71 | - Type: 72 | - `"client"` to connect to a single peer or server that is explicity specified 73 | - `"server"` to enable multiple clients to openly connect to the plugin 74 | - Default: `"client"` 75 | 76 | > ### Settlement 77 | > 78 | > Clients do not automatically open channels, nor settle automatically. Channels must be funded or closed through the internal API of the plugin. Sending payment channel claims can be triggered by invoking `sendMoney` on the plugin, and the money handler is called upon receipt of incoming payment channel claims (set using `registerMoneyHandler`). 79 | > 80 | > Servers _do_ automatically open channels. If a client has opened a channel with a value above the configurable `minIncomingChannelAmount`, the server will automatically open a channel back to the client with a value of `outgoingChannelAmount`. When the channel is half empty, the server will also automatically top up the value of the channel by the `outgoingChannelAmount`. 81 | > 82 | > The balance configuration has been simplified for servers. Clients must prefund before sending any packets through a server, and if a client fulfills packets sent to them through a server, the server will automatically settle such that they owe 0 to the client. This configuration was chosen because it assumes the server would extend no credit to an anonymous client, and clients extend little credit to servers, necessitating frequent settlements. 83 | > 84 | > ### Closing Channels 85 | > 86 | > Both clients and servers operate a channel watcher to automatically close a disputed channel if it's profitable to do so, and both will automatically claim channels if the other peer requests one to be closed. 87 | > 88 | > ### Transaction Fees 89 | > 90 | > In the current version of this plugin, there is no accounting for transaction fees on servers. In previous versions, transaction fees where added to the client's balance, which forced them to prefund the fee, but this made accounting nearly impossible from the client's perspective: it was opaque, and there was no negotiation. The balances of the two peers would quickly get out of sync. 91 | > 92 | > Since clients must manually open & close channels, they do have the ability to authorize transaction fees before sending them to the chain. 93 | > 94 | > ### Future Work 95 | > 96 | > The current model introduces problems with locking up excessive liquidity for servers, and doesn't provide sufficient denial of service protections against transaction fees. Ultimately, clients will likely have to purchase incoming capacity (possibly by amount and/or time) through prefunding the server, and pay for the server's transaction fees to open and close a channel back to them. However, this may require a more complex negotiation and fee logic that is nontrivial to implement. 97 | 98 | #### `tokenAddress` 99 | 100 | - Type: `string` 101 | - Ethereum address of the ERC-20 token contract 102 | - Used to send and receive a particular ERC-20 token asset, instead of ether 103 | - Each payment channel can be used with only ETH, or only a predefined ERC-20 token 104 | 105 | #### `contractAddress` 106 | 107 | - Type: `string` 108 | - Custom Ethereum address for the Machinomy payment channel contact 109 | - Useful for private blockchains or testing with Ganache 110 | 111 | #### `maxPacketAmount` 112 | 113 | - Type: [`BigNumber`](http://mikemcl.github.io/bignumber.js/), `number`, or `string` 114 | - Default: `Infinity` 115 | - Maximum amount in _gwei_ above which an incoming packet should be rejected 116 | 117 | #### `outgoingChannelAmount` 118 | 119 | - Type: [`BigNumber`](http://mikemcl.github.io/bignumber.js/), `number`, or `string` 120 | - Default: `50000000` gwei, or 0.05 ether 121 | - Amount in _gwei_ to use as a default to fund an outgoing channel up to 122 | - Note: this is primarily relevant to servers, since clients that don't automatically open channels may manually specify the amount 123 | 124 | #### `minIncomingChannelAmount` 125 | 126 | - Type: [`BigNumber`](http://mikemcl.github.io/bignumber.js/), `number`, or `string` 127 | - Default: `Infinity` gwei (channels will never automatically be opened) 128 | - Value in _gwei_ that a peer's incoming channel must exceed if an outgoing channel to the peer should be automatically opened 129 | 130 | #### `channelWatcherInterval` 131 | 132 | - Type: [`BigNumber`](http://mikemcl.github.io/bignumber.js/), `number`, or `string` 133 | - Default `60000` ms, or 1 minute 134 | - Number of milliseconds between each run of the channel watcher, which checks if the peer started a dispute and if so, claims the channel if it's profitable 135 | 136 | #### `outgoingDisputePeriod` 137 | 138 | - Type: [`BigNumber`](http://mikemcl.github.io/bignumber.js/), `number`, or `string` 139 | - Default: `34560` blocks, or approximately 6 days, assuming 15 second blocks 140 | - Number of blocks for dispute period when opening new outgoing channels 141 | 142 | While the channel is open, the sender may begin the dispute period. If the receiver does not claim the channel before the specified number of blocks elapses and the dispute period ends, all the funds can go back to the sender. Disputing a channel can be useful if the receiver is unresponsive or excessive collateral is locked up. 143 | 144 | #### `minIncomingDisputePeriod` 145 | 146 | - Type: [`BigNumber`](http://mikemcl.github.io/bignumber.js/), `number`, or `string` 147 | - Default: `17280` blocks, or approximately 3 days, assuming 15 second blocks 148 | - Minimum number of blocks for the dispute period in order to accept an incoming channel 149 | 150 | In case the sender starts a dispute period, the receiver may want to allot themselves enough time to claim the channel. Incoming claims from channels with dispute periods below this floor will be rejected outright. 151 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | files: ['src/__tests__/**/*.ts'], 3 | failFast: true, 4 | verbose: true, 5 | serial: true, 6 | timeout: '3m', 7 | compileEnhancements: false, 8 | extensions: ['ts'], 9 | require: ['ts-node/register'] 10 | } 11 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // This is a hack to allow default and named CommonJS exports 4 | const bundle = require('./build') 5 | module.exports = bundle.default 6 | for (let p in bundle) { 7 | if (!module.exports.hasOwnProperty(p)) { 8 | module.exports[p] = bundle[p] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ilp-plugin-ethereum", 3 | "version": "3.0.0-beta.20", 4 | "description": "Settle Interledger payments with ether and ERC-20 tokens", 5 | "main": "index.js", 6 | "types": "build/index.d.ts", 7 | "files": [ 8 | "build", 9 | "!build/__tests__" 10 | ], 11 | "scripts": { 12 | "build": "tsc", 13 | "lint": "tslint --project .", 14 | "test": "nyc ava", 15 | "test-inspect": "node --inspect-brk node_modules/ava/profile.js", 16 | "format": "prettier --write 'src/**/*.ts'" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/interledgerjs/ilp-plugin-ethereum.git" 21 | }, 22 | "keywords": [ 23 | "ilp", 24 | "interledger", 25 | "ledger", 26 | "plugin", 27 | "ethereum", 28 | "machinomy", 29 | "erc20" 30 | ], 31 | "contributors": [ 32 | "Kincaid O'Neil (https://kincaidoneil.com/)", 33 | "Kevin Davis " 34 | ], 35 | "license": "Apache-2.0", 36 | "bugs": { 37 | "url": "https://github.com/interledgerjs/ilp-plugin-ethereum/issues" 38 | }, 39 | "homepage": "https://github.com/interledgerjs/ilp-plugin-ethereum#readme", 40 | "dependencies": { 41 | "@kava-labs/crypto-rate-utils": "^2.0.2", 42 | "@types/node": "^11.13.5", 43 | "@types/webassembly-js-api": "0.0.2", 44 | "bignumber.js": "^7.2.1", 45 | "bitcoin-ts": "^1.4.0", 46 | "btp-packet": "^2.2.0", 47 | "ethers": "^4.0.27", 48 | "eventemitter2": "^5.0.1", 49 | "ilp-logger": "^1.1.3", 50 | "ilp-packet": "^3.0.8", 51 | "ilp-plugin-btp": "^1.3.10", 52 | "ilp-plugin-mini-accounts": "^4.0.2", 53 | "openzeppelin-solidity": "^2.2.0" 54 | }, 55 | "devDependencies": { 56 | "@types/ganache-core": "^2.1.2", 57 | "@types/get-port": "^4.2.0", 58 | "ava": "^1.4.1", 59 | "codecov": "^3.3.0", 60 | "ganache-core": "^2.5.5", 61 | "get-port": "^5.0.0", 62 | "nyc": "^14.0.0", 63 | "prettier": "^1.17.0", 64 | "standard": "^12.0.1", 65 | "ts-node": "^8.1.0", 66 | "tslint": "^5.16.0", 67 | "tslint-config-prettier": "^1.18.0", 68 | "tslint-config-standard": "^8.0.1", 69 | "tslint-eslint-rules": "^5.4.0", 70 | "typescript": "^3.4.4" 71 | }, 72 | "engines": { 73 | "node": ">=10.0.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/__tests__/send-receive-eth.test.ts: -------------------------------------------------------------------------------- 1 | import getPort from 'get-port' 2 | import EthereumPlugin from '..' 3 | import BigNumber from 'bignumber.js' 4 | import test from 'ava' 5 | import createLogger from 'ilp-logger' 6 | import { convert, eth, gwei, wei } from '@kava-labs/crypto-rate-utils' 7 | 8 | test('ether can be sent between two peers', async t => { 9 | const port = await getPort() 10 | 11 | const clientPlugin = new EthereumPlugin( 12 | { 13 | role: 'client', 14 | server: `btp+ws://:secret@localhost:${port}`, 15 | ethereumPrivateKey: process.env.PRIVATE_KEY_A!, 16 | ethereumProvider: process.env.ETHEREUM_PROVIDER as any 17 | }, 18 | { 19 | log: createLogger('ilp-plugin-ethereum:client') 20 | } 21 | ) 22 | 23 | const serverPlugin = new EthereumPlugin( 24 | { 25 | role: 'client', 26 | listener: { 27 | port, 28 | secret: 'secret' 29 | }, 30 | ethereumPrivateKey: process.env.PRIVATE_KEY_B!, 31 | ethereumProvider: process.env.ETHEREUM_PROVIDER as any 32 | }, 33 | { 34 | log: createLogger('ilp-plugin-ethereum:server') 35 | } 36 | ) 37 | 38 | await Promise.all([serverPlugin.connect(), clientPlugin.connect()]) 39 | 40 | const AMOUNT_TO_FUND = convert(eth('0.002'), wei()) 41 | const AMOUNT_TO_DEPOSIT = convert(eth('0.001'), wei()) 42 | 43 | const SEND_AMOUNT_1 = convert(eth('0.0023'), gwei()) 44 | const SEND_AMOUNT_2 = convert(eth('0.0005'), gwei()) 45 | 46 | const pluginAccount = await clientPlugin._loadAccount('peer') 47 | 48 | // Open a channel 49 | await t.notThrowsAsync( 50 | pluginAccount.fundOutgoingChannel(AMOUNT_TO_FUND, () => Promise.resolve()), 51 | 'successfully opens an outgoing chanenl' 52 | ) 53 | 54 | // Deposit to the channel 55 | await t.notThrowsAsync( 56 | pluginAccount.fundOutgoingChannel(AMOUNT_TO_DEPOSIT, () => 57 | Promise.resolve() 58 | ), 59 | 'successfully deposits to the outgoing channel' 60 | ) 61 | 62 | // Ensure the initial claim can be accepted 63 | serverPlugin.deregisterMoneyHandler() 64 | await new Promise(async resolve => { 65 | serverPlugin.registerMoneyHandler(async amount => { 66 | t.true( 67 | new BigNumber(amount).isEqualTo(SEND_AMOUNT_1), 68 | 'initial claim is sent and validated successfully between two peers' 69 | ) 70 | resolve() 71 | }) 72 | 73 | await t.notThrowsAsync(clientPlugin.sendMoney(SEND_AMOUNT_1.toString())) 74 | }) 75 | 76 | // Ensure a greater claim can be accepted 77 | serverPlugin.deregisterMoneyHandler() 78 | await new Promise(async resolve => { 79 | serverPlugin.registerMoneyHandler(async amount => { 80 | t.true( 81 | new BigNumber(amount).isEqualTo(SEND_AMOUNT_2), 82 | 'better claim is sent and validated successfully between two peers' 83 | ) 84 | resolve() 85 | }) 86 | 87 | await t.notThrowsAsync(clientPlugin.sendMoney(SEND_AMOUNT_2.toString())) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /src/__tests__/send-receive-token.test.ts: -------------------------------------------------------------------------------- 1 | import { convert, eth, gwei, wei } from '@kava-labs/crypto-rate-utils' 2 | import test from 'ava' 3 | import BigNumber from 'bignumber.js' 4 | import debug from 'debug' 5 | import { ethers } from 'ethers' 6 | import ganache from 'ganache-core' 7 | import getPort from 'get-port' 8 | import createLogger from 'ilp-logger' 9 | import EthereumPlugin from '..' 10 | import TOKEN_UNIDIRECTIONAL from '../abi/TokenUnidirectional.json' 11 | import MINTABLE_TOKEN from 'openzeppelin-solidity/build/contracts/ERC20Mintable.json' 12 | 13 | test('tokens can be sent between two peers', async t => { 14 | const port = await getPort() 15 | const mnemonic = 16 | 'donate post silk true upset company tourist salt puppy rough base jealous salad caution female' 17 | 18 | const provider = new ethers.providers.Web3Provider( 19 | ganache.provider({ 20 | mnemonic, 21 | logger: { 22 | log: debug('ganache-core') 23 | } 24 | }) 25 | ) 26 | 27 | const generateWallet = (index = 0) => 28 | ethers.Wallet.fromMnemonic(mnemonic, `m/44'/60'/0'/0/${index}`).connect( 29 | provider 30 | ) 31 | 32 | const clientWallet = generateWallet() 33 | const serverWallet = generateWallet(1) 34 | 35 | // Deploy TokenUnidirectional 36 | const machinomyFactory = new ethers.ContractFactory( 37 | TOKEN_UNIDIRECTIONAL.abi, 38 | TOKEN_UNIDIRECTIONAL.bytecode, 39 | clientWallet 40 | ) 41 | const contractAddress = (await machinomyFactory.deploy()).address 42 | 43 | // Deploy test ERC-20 token contract 44 | const tokenFactory = new ethers.ContractFactory( 45 | MINTABLE_TOKEN.abi, 46 | MINTABLE_TOKEN.bytecode, 47 | clientWallet 48 | ) 49 | const tokenContract = await tokenFactory.deploy() 50 | 51 | // Mint test tokens for both accounts (100 units of the token, assuming -18 base) 52 | await tokenContract.functions.mint( 53 | await clientWallet.getAddress(), 54 | new BigNumber(10).exponentiatedBy(20).toString() 55 | ) 56 | await tokenContract.functions.mint( 57 | await serverWallet.getAddress(), 58 | new BigNumber(10).exponentiatedBy(20).toString() 59 | ) 60 | 61 | const clientPlugin = new EthereumPlugin( 62 | { 63 | role: 'client', 64 | server: `btp+ws://:secret@localhost:${port}`, 65 | ethereumWallet: clientWallet, 66 | contractAddress, 67 | tokenAddress: tokenContract.address 68 | }, 69 | { 70 | log: createLogger('ilp-plugin-ethereum:client') 71 | } 72 | ) 73 | 74 | const serverPlugin = new EthereumPlugin( 75 | { 76 | role: 'client', 77 | listener: { 78 | port, 79 | secret: 'secret' 80 | }, 81 | ethereumWallet: serverWallet, 82 | contractAddress, 83 | tokenAddress: tokenContract.address 84 | }, 85 | { 86 | log: createLogger('ilp-plugin-ethereum:server') 87 | } 88 | ) 89 | 90 | await Promise.all([serverPlugin.connect(), clientPlugin.connect()]) 91 | 92 | const AMOUNT_TO_FUND = convert(eth('0.002'), wei()) 93 | const AMOUNT_TO_DEPOSIT = convert(eth('0.001'), wei()) 94 | 95 | const SEND_AMOUNT_1 = convert(eth('0.0023'), gwei()) 96 | const SEND_AMOUNT_2 = convert(eth('0.0005'), gwei()) 97 | 98 | const pluginAccount = await clientPlugin._loadAccount('peer') 99 | 100 | // Open a channel 101 | await t.notThrowsAsync( 102 | pluginAccount.fundOutgoingChannel(AMOUNT_TO_FUND, () => Promise.resolve()), 103 | 'successfully opens an outgoing chanenl' 104 | ) 105 | 106 | // Deposit to the channel 107 | await t.notThrowsAsync( 108 | pluginAccount.fundOutgoingChannel(AMOUNT_TO_DEPOSIT, () => 109 | Promise.resolve() 110 | ), 111 | 'successfully deposits to the outgoing channel' 112 | ) 113 | 114 | // Ensure the initial claim can be accepted 115 | serverPlugin.deregisterMoneyHandler() 116 | await new Promise(async resolve => { 117 | serverPlugin.registerMoneyHandler(async amount => { 118 | t.true( 119 | new BigNumber(amount).isEqualTo(SEND_AMOUNT_1), 120 | 'initial claim is sent and validated successfully between two peers' 121 | ) 122 | resolve() 123 | }) 124 | 125 | await t.notThrowsAsync(clientPlugin.sendMoney(SEND_AMOUNT_1.toString())) 126 | }) 127 | 128 | // Ensure a greater claim can be accepted 129 | serverPlugin.deregisterMoneyHandler() 130 | await new Promise(async resolve => { 131 | serverPlugin.registerMoneyHandler(async amount => { 132 | t.true( 133 | new BigNumber(amount).isEqualTo(SEND_AMOUNT_2), 134 | 'better claim is sent and validated successfully between two peers' 135 | ) 136 | resolve() 137 | }) 138 | 139 | await t.notThrowsAsync(clientPlugin.sendMoney(SEND_AMOUNT_2.toString())) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /src/__tests__/watcher.test.ts: -------------------------------------------------------------------------------- 1 | import getPort from 'get-port' 2 | import EthereumPlugin from '..' 3 | import { prepareTransaction } from '../utils/channel' 4 | import { MemoryStore } from '../utils/store' 5 | import test from 'ava' 6 | import { convert, eth, gwei, wei } from '@kava-labs/crypto-rate-utils' 7 | 8 | test(`channel watcher claims settling channel if it's profitable`, async t => { 9 | t.plan(1) 10 | 11 | const port = await getPort() 12 | 13 | const clientStore = new MemoryStore() 14 | const clientPlugin = new EthereumPlugin( 15 | { 16 | role: 'client', 17 | ethereumPrivateKey: process.env.PRIVATE_KEY_A!, 18 | ethereumProvider: process.env.ETHEREUM_PROVIDER as any, 19 | server: `btp+ws://userA:secretA@localhost:${port}` 20 | }, 21 | { 22 | store: clientStore 23 | } 24 | ) 25 | 26 | const serverStore = new MemoryStore() 27 | const createServer = async (): Promise => { 28 | const serverPlugin = new EthereumPlugin( 29 | { 30 | role: 'server', 31 | ethereumPrivateKey: process.env.PRIVATE_KEY_B!, 32 | ethereumProvider: process.env.ETHEREUM_PROVIDER as any, 33 | channelWatcherInterval: 5000, // Every 5 sec 34 | debugHostIldcpInfo: { 35 | assetCode: 'ETH', 36 | assetScale: 9, 37 | clientAddress: 'private.ethereum' 38 | }, 39 | port 40 | }, 41 | { 42 | store: serverStore 43 | } 44 | ) 45 | 46 | serverPlugin.registerMoneyHandler(() => Promise.resolve()) 47 | await serverPlugin.connect() 48 | 49 | return serverPlugin 50 | } 51 | 52 | const serverPlugin = await createServer() 53 | await clientPlugin.connect() 54 | 55 | // Create channel & send claim to server 56 | const pluginAccount = await clientPlugin._loadAccount('peer') 57 | await pluginAccount.fundOutgoingChannel(convert(eth('0.0015'), wei())) 58 | await clientPlugin.sendMoney(convert(eth('0.0015'), gwei()).toString()) 59 | 60 | // Wait for claims to finish processing 61 | await new Promise(r => setTimeout(r, 2000)) 62 | 63 | // Disconnect the client & server, then start settling the channel 64 | await clientPlugin.disconnect() 65 | await serverPlugin.disconnect() 66 | 67 | const channelId = JSON.parse((await clientStore.get( 68 | 'peer:account' 69 | )) as string).outgoing.channelId as string 70 | 71 | const { sendTransaction } = await prepareTransaction({ 72 | methodName: 'startSettling', 73 | params: [channelId], 74 | contract: await clientPlugin._contract, 75 | gasPrice: await clientPlugin._getGasPrice() 76 | }) 77 | 78 | await sendTransaction() 79 | 80 | // Start the server back up to make sure the channel watcher claims the channel 81 | await createServer() 82 | 83 | await new Promise(resolve => { 84 | const interval = setInterval(async () => { 85 | const contract = await serverPlugin._contract 86 | const wasClaimed = await contract.functions.isAbsent(channelId) 87 | 88 | if (wasClaimed) { 89 | clearInterval(interval) 90 | t.pass() 91 | resolve() 92 | } 93 | }, 5000) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /src/abi/TokenUnidirectional.json: -------------------------------------------------------------------------------- 1 | { 2 | "bytecode": "0x608060405234801561001057600080fd5b5061139c806100206000396000f3006080604052600436106100d95763ffffffff7c0100000000000000000000000000000000000000000000000000000000600035041662f31e7681146100de57806303e8c9fa1461010a5780631de26e161461013d5780632f8f0c92146101585780634722361c14610158578063605466021461017c5780636683f9ae146101945780637964ea87146101ac5780637a7ebd7b1461020c5780637c35be7a14610265578063987757dd1461027d578063ad37908914610295578063dee6c895146102ad578063e62eea47146102e6578063ec8be5b9146102fe575b600080fd5b3480156100ea57600080fd5b506100f660043561036a565b604080519115158252519081900360200190f35b34801561011657600080fd5b5061013b600435600160a060020a03602435811690604435906064351660843561037c565b005b34801561014957600080fd5b5061013b60043560243561069b565b34801561016457600080fd5b506100f6600435600160a060020a0360243516610880565b34801561018857600080fd5b506100f66004356108b9565b3480156101a057600080fd5b506100f66004356108d5565b3480156101b857600080fd5b50604080516020600460443581810135601f810184900484028501840190955284845261013b9482359460248035953695946064949201919081908401838280828437509497506108ec9650505050505050565b34801561021857600080fd5b50610224600435610cf6565b60408051600160a060020a0397881681529587166020870152858101949094526060850192909252608084015290921660a082015290519081900360c00190f35b34801561027157600080fd5b506100f6600435610d38565b34801561028957600080fd5b5061013b600435610d59565b3480156102a157600080fd5b506100f6600435610f44565b3480156102b957600080fd5b506102d4600435602435600160a060020a0360443516610f74565b60408051918252519081900360200190f35b3480156102f257600080fd5b5061013b600435611028565b34801561030a57600080fd5b50604080516020601f6064356004818101359283018490048402850184019095528184526100f6948035946024803595600160a060020a036044351695369560849493019181908401838280828437509497506110e39650505050505050565b6000610375826108b9565b1592915050565b6000610387866108b9565b1515610403576040805160e560020a62461bcd02815260206004820152602360248201527f4368616e6e656c2077697468207468652073616d65206964206973207072657360448201527f656e740000000000000000000000000000000000000000000000000000000000606482015290519081900360840190fd5b50604080517f23b872dd0000000000000000000000000000000000000000000000000000000081523360048201523060248201526044810183905290518391600160a060020a038316916323b872dd916064808201926020929091908290030181600087803b15801561047557600080fd5b505af1158015610489573d6000803e3d6000fd5b505050506040513d602081101561049f57600080fd5b5051151561051d576040805160e560020a62461bcd02815260206004820152602860248201527f556e61626c6520746f207472616e7366657220746f6b656e20746f207468652060448201527f636f6e7472616374000000000000000000000000000000000000000000000000606482015290519081900360840190fd5b60c06040519081016040528033600160a060020a0316815260200186600160a060020a031681526020018381526020018581526020016000815260200184600160a060020a0316815250600080886000191660001916815260200190815260200160002060008201518160000160006101000a815481600160a060020a030219169083600160a060020a0316021790555060208201518160010160006101000a815481600160a060020a030219169083600160a060020a0316021790555060408201518160020155606082015181600301556080820151816004015560a08201518160050160006101000a815481600160a060020a030219169083600160a060020a0316021790555090505084600160a060020a031633600160a060020a031687600019167f8f7d1ea1dfb1f5cc2f05d16f7712e0b73c0b172edb09bad6e6fb5a8015cadc7885876040518083815260200182600160a060020a0316600160a060020a031681526020019250505060405180910390a4505050505050565b6000806106a88433610880565b15156106fe576040805160e560020a62461bcd02815260206004820152601960248201527f63616e4465706f7369742072657475726e65642066616c736500000000000000604482015290519081900360640190fd5b5050600082815260208181526040808320600581015482517f23b872dd0000000000000000000000000000000000000000000000000000000081523360048201523060248201526044810187905292519194600160a060020a039091169384936323b872dd9360648083019491928390030190829087803b15801561078257600080fd5b505af1158015610796573d6000803e3d6000fd5b505050506040513d60208110156107ac57600080fd5b5051151561082a576040805160e560020a62461bcd02815260206004820152602860248201527f556e61626c6520746f207472616e7366657220746f6b656e20746f207468652060448201527f636f6e7472616374000000000000000000000000000000000000000000000000606482015290519081900360840190fd5b600282015461083f908463ffffffff61115216565b600283015560408051848152905185917f6f850cda6d6b2f5cca622bc2d4739e4ed917c12d29f9a92b9e6c127abe398424919081900360200190a250505050565b60008281526020819052604081208054600160a060020a038481169116146108a785610d38565b80156108b05750805b95945050505050565b600090815260208190526040902054600160a060020a03161590565b600090815260208190526040902060040154151590565b60008060006108fd868633876110e3565b1515610953576040805160e560020a62461bcd02815260206004820152601760248201527f63616e436c61696d2072657475726e65642066616c7365000000000000000000604482015290519081900360640190fd5b600086815260208190526040902060058101546002820154919450600160a060020a031692508510610a7a57600183015460028401546040805160e060020a63a9059cbb028152600160a060020a0393841660048201526024810192909252519184169163a9059cbb916044808201926020929091908290030181600087803b1580156109df57600080fd5b505af11580156109f3573d6000803e3d6000fd5b505050506040513d6020811015610a0957600080fd5b50511515610a75576040805160e560020a62461bcd02815260206004820152602c602482015260008051602061135183398151915260448201527f6e656c2072656365697665720000000000000000000000000000000000000000606482015290519081900360840190fd5b610c71565b60018301546040805160e060020a63a9059cbb028152600160a060020a0392831660048201526024810188905290519184169163a9059cbb916044808201926020929091908290030181600087803b158015610ad557600080fd5b505af1158015610ae9573d6000803e3d6000fd5b505050506040513d6020811015610aff57600080fd5b50511515610b6b576040805160e560020a62461bcd02815260206004820152602c602482015260008051602061135183398151915260448201527f6e656c2072656365697665720000000000000000000000000000000000000000606482015290519081900360840190fd5b6002830154610b80908663ffffffff61115f16565b83546040805160e060020a63a9059cbb028152600160a060020a0392831660048201526024810184905290519293509084169163a9059cbb916044808201926020929091908290030181600087803b158015610bdb57600080fd5b505af1158015610bef573d6000803e3d6000fd5b505050506040513d6020811015610c0557600080fd5b50511515610c71576040805160e560020a62461bcd02815260206004820152602a602482015260008051602061135183398151915260448201527f6e656c2073656e64657200000000000000000000000000000000000000000000606482015290519081900360840190fd5b600086815260208190526040808220805473ffffffffffffffffffffffffffffffffffffffff199081168255600182018054821690556002820184905560038201849055600482018490556005909101805490911690555187917f3de43c9e481138453c3cfea2781e18a609abb6448556669b257edc7de710fd6491a2505050505050565b600060208190529081526040902080546001820154600283015460038401546004850154600590950154600160a060020a039485169593851694929391921686565b6000610d438261036a565b8015610d535750610375826108d5565b92915050565b600080610d6583610f44565b1515610dbb576040805160e560020a62461bcd02815260206004820152601860248201527f63616e536574746c652072657475726e65642066616c73650000000000000000604482015290519081900360640190fd5b5050600081815260208181526040808320600581015481546002830154845160e060020a63a9059cbb028152600160a060020a039283166004820152602481019190915293519295911693849363a9059cbb9360448083019491928390030190829087803b158015610e2c57600080fd5b505af1158015610e40573d6000803e3d6000fd5b505050506040513d6020811015610e5657600080fd5b50511515610ec2576040805160e560020a62461bcd02815260206004820152602a602482015260008051602061135183398151915260448201527f6e656c2073656e64657200000000000000000000000000000000000000000000606482015290519081900360840190fd5b600083815260208190526040808220805473ffffffffffffffffffffffffffffffffffffffff199081168255600182018054821690556002820184905560038201849055600482018490556005909101805490911690555184917f74fb75c3de2cff5e8a78cf9b1f49a5bea60126b42ed45bb4b2b25b7da03e4d1b91a2505050565b60008181526020819052604081206004810154431015610f63846108d5565b8015610f6c5750805b949350505050565b604080516c010000000000000000000000003081026020808401919091526034830187905260548301869052600160a060020a0385169091026074830152825160688184030181526088909201928390528151600093918291908401908083835b60208310610ff45780518252601f199092019160209182019101610fd5565b5181516020939093036101000a60001901801990911692169190911790526040519201829003909120979650505050505050565b60006110348233610880565b151561108a576040805160e560020a62461bcd02815260206004820152601f60248201527f63616e5374617274536574746c696e672072657475726e65642066616c736500604482015290519081900360640190fd5b50600081815260208190526040902060038101546110af90439063ffffffff61115216565b600482015560405182907fd6461a3a92fd600fe23f236b2e25c2fd0c197a66b2f990989f0b210d578f461790600090a25050565b600084815260208190526040812060018101546005820154600160a060020a0391821686831614918491829161111d918b918b9116611171565b9150611129828761127b565b8454600160a060020a0390811691161490508280156111455750805b9998505050505050505050565b81810182811015610d5357fe5b60008282111561116b57fe5b50900390565b60408051808201909152601c81527f19457468657265756d205369676e6564204d6573736167653a0a3332000000006020820152600090806111b4868686610f74565b6040516020018083805190602001908083835b602083106111e65780518252601f1990920191602091820191016111c7565b51815160209384036101000a600019018019909216911617905292019384525060408051808503815293820190819052835193945092839250908401908083835b602083106112465780518252601f199092019160209182019101611227565b5181516020939093036101000a6000190180199091169216919091179052604051920182900390912098975050505050505050565b600080600080845160411415156112955760009350611347565b50505060208201516040830151606084015160001a601b60ff821610156112ba57601b015b8060ff16601b141580156112d257508060ff16601c14155b156112e05760009350611347565b60408051600080825260208083018085528a905260ff8516838501526060830187905260808301869052925160019360a0808501949193601f19840193928390039091019190865af115801561133a573d6000803e3d6000fd5b5050506020604051035193505b505050929150505600556e61626c6520746f207472616e7366657220746f6b656e20746f206368616ea165627a7a723058208a9cf56a6fc05f6e682dec87b314ecd14fef4e303e146116048063887283cbff0029", 3 | "abi": [ 4 | { 5 | "constant": true, 6 | "inputs": [ 7 | { 8 | "name": "", 9 | "type": "bytes32" 10 | } 11 | ], 12 | "name": "channels", 13 | "outputs": [ 14 | { 15 | "name": "sender", 16 | "type": "address" 17 | }, 18 | { 19 | "name": "receiver", 20 | "type": "address" 21 | }, 22 | { 23 | "name": "value", 24 | "type": "uint256" 25 | }, 26 | { 27 | "name": "settlingPeriod", 28 | "type": "uint256" 29 | }, 30 | { 31 | "name": "settlingUntil", 32 | "type": "uint256" 33 | }, 34 | { 35 | "name": "tokenContract", 36 | "type": "address" 37 | } 38 | ], 39 | "payable": false, 40 | "stateMutability": "view", 41 | "type": "function" 42 | }, 43 | { 44 | "anonymous": false, 45 | "inputs": [ 46 | { 47 | "indexed": true, 48 | "name": "channelId", 49 | "type": "bytes32" 50 | }, 51 | { 52 | "indexed": true, 53 | "name": "sender", 54 | "type": "address" 55 | }, 56 | { 57 | "indexed": true, 58 | "name": "receiver", 59 | "type": "address" 60 | }, 61 | { 62 | "indexed": false, 63 | "name": "value", 64 | "type": "uint256" 65 | }, 66 | { 67 | "indexed": false, 68 | "name": "tokenContract", 69 | "type": "address" 70 | } 71 | ], 72 | "name": "DidOpen", 73 | "type": "event" 74 | }, 75 | { 76 | "anonymous": false, 77 | "inputs": [ 78 | { 79 | "indexed": true, 80 | "name": "channelId", 81 | "type": "bytes32" 82 | }, 83 | { 84 | "indexed": false, 85 | "name": "deposit", 86 | "type": "uint256" 87 | } 88 | ], 89 | "name": "DidDeposit", 90 | "type": "event" 91 | }, 92 | { 93 | "anonymous": false, 94 | "inputs": [ 95 | { 96 | "indexed": true, 97 | "name": "channelId", 98 | "type": "bytes32" 99 | } 100 | ], 101 | "name": "DidClaim", 102 | "type": "event" 103 | }, 104 | { 105 | "anonymous": false, 106 | "inputs": [ 107 | { 108 | "indexed": true, 109 | "name": "channelId", 110 | "type": "bytes32" 111 | } 112 | ], 113 | "name": "DidStartSettling", 114 | "type": "event" 115 | }, 116 | { 117 | "anonymous": false, 118 | "inputs": [ 119 | { 120 | "indexed": true, 121 | "name": "channelId", 122 | "type": "bytes32" 123 | } 124 | ], 125 | "name": "DidSettle", 126 | "type": "event" 127 | }, 128 | { 129 | "constant": false, 130 | "inputs": [ 131 | { 132 | "name": "channelId", 133 | "type": "bytes32" 134 | }, 135 | { 136 | "name": "receiver", 137 | "type": "address" 138 | }, 139 | { 140 | "name": "settlingPeriod", 141 | "type": "uint256" 142 | }, 143 | { 144 | "name": "tokenContract", 145 | "type": "address" 146 | }, 147 | { 148 | "name": "value", 149 | "type": "uint256" 150 | } 151 | ], 152 | "name": "open", 153 | "outputs": [], 154 | "payable": false, 155 | "stateMutability": "nonpayable", 156 | "type": "function" 157 | }, 158 | { 159 | "constant": true, 160 | "inputs": [ 161 | { 162 | "name": "channelId", 163 | "type": "bytes32" 164 | }, 165 | { 166 | "name": "origin", 167 | "type": "address" 168 | } 169 | ], 170 | "name": "canDeposit", 171 | "outputs": [ 172 | { 173 | "name": "", 174 | "type": "bool" 175 | } 176 | ], 177 | "payable": false, 178 | "stateMutability": "view", 179 | "type": "function" 180 | }, 181 | { 182 | "constant": false, 183 | "inputs": [ 184 | { 185 | "name": "channelId", 186 | "type": "bytes32" 187 | }, 188 | { 189 | "name": "value", 190 | "type": "uint256" 191 | } 192 | ], 193 | "name": "deposit", 194 | "outputs": [], 195 | "payable": false, 196 | "stateMutability": "nonpayable", 197 | "type": "function" 198 | }, 199 | { 200 | "constant": true, 201 | "inputs": [ 202 | { 203 | "name": "channelId", 204 | "type": "bytes32" 205 | }, 206 | { 207 | "name": "origin", 208 | "type": "address" 209 | } 210 | ], 211 | "name": "canStartSettling", 212 | "outputs": [ 213 | { 214 | "name": "", 215 | "type": "bool" 216 | } 217 | ], 218 | "payable": false, 219 | "stateMutability": "view", 220 | "type": "function" 221 | }, 222 | { 223 | "constant": false, 224 | "inputs": [ 225 | { 226 | "name": "channelId", 227 | "type": "bytes32" 228 | } 229 | ], 230 | "name": "startSettling", 231 | "outputs": [], 232 | "payable": false, 233 | "stateMutability": "nonpayable", 234 | "type": "function" 235 | }, 236 | { 237 | "constant": true, 238 | "inputs": [ 239 | { 240 | "name": "channelId", 241 | "type": "bytes32" 242 | } 243 | ], 244 | "name": "canSettle", 245 | "outputs": [ 246 | { 247 | "name": "", 248 | "type": "bool" 249 | } 250 | ], 251 | "payable": false, 252 | "stateMutability": "view", 253 | "type": "function" 254 | }, 255 | { 256 | "constant": false, 257 | "inputs": [ 258 | { 259 | "name": "channelId", 260 | "type": "bytes32" 261 | } 262 | ], 263 | "name": "settle", 264 | "outputs": [], 265 | "payable": false, 266 | "stateMutability": "nonpayable", 267 | "type": "function" 268 | }, 269 | { 270 | "constant": true, 271 | "inputs": [ 272 | { 273 | "name": "channelId", 274 | "type": "bytes32" 275 | }, 276 | { 277 | "name": "payment", 278 | "type": "uint256" 279 | }, 280 | { 281 | "name": "origin", 282 | "type": "address" 283 | }, 284 | { 285 | "name": "signature", 286 | "type": "bytes" 287 | } 288 | ], 289 | "name": "canClaim", 290 | "outputs": [ 291 | { 292 | "name": "", 293 | "type": "bool" 294 | } 295 | ], 296 | "payable": false, 297 | "stateMutability": "view", 298 | "type": "function" 299 | }, 300 | { 301 | "constant": false, 302 | "inputs": [ 303 | { 304 | "name": "channelId", 305 | "type": "bytes32" 306 | }, 307 | { 308 | "name": "payment", 309 | "type": "uint256" 310 | }, 311 | { 312 | "name": "signature", 313 | "type": "bytes" 314 | } 315 | ], 316 | "name": "claim", 317 | "outputs": [], 318 | "payable": false, 319 | "stateMutability": "nonpayable", 320 | "type": "function" 321 | }, 322 | { 323 | "constant": true, 324 | "inputs": [ 325 | { 326 | "name": "channelId", 327 | "type": "bytes32" 328 | } 329 | ], 330 | "name": "isAbsent", 331 | "outputs": [ 332 | { 333 | "name": "", 334 | "type": "bool" 335 | } 336 | ], 337 | "payable": false, 338 | "stateMutability": "view", 339 | "type": "function" 340 | }, 341 | { 342 | "constant": true, 343 | "inputs": [ 344 | { 345 | "name": "channelId", 346 | "type": "bytes32" 347 | } 348 | ], 349 | "name": "isPresent", 350 | "outputs": [ 351 | { 352 | "name": "", 353 | "type": "bool" 354 | } 355 | ], 356 | "payable": false, 357 | "stateMutability": "view", 358 | "type": "function" 359 | }, 360 | { 361 | "constant": true, 362 | "inputs": [ 363 | { 364 | "name": "channelId", 365 | "type": "bytes32" 366 | } 367 | ], 368 | "name": "isSettling", 369 | "outputs": [ 370 | { 371 | "name": "", 372 | "type": "bool" 373 | } 374 | ], 375 | "payable": false, 376 | "stateMutability": "view", 377 | "type": "function" 378 | }, 379 | { 380 | "constant": true, 381 | "inputs": [ 382 | { 383 | "name": "channelId", 384 | "type": "bytes32" 385 | } 386 | ], 387 | "name": "isOpen", 388 | "outputs": [ 389 | { 390 | "name": "", 391 | "type": "bool" 392 | } 393 | ], 394 | "payable": false, 395 | "stateMutability": "view", 396 | "type": "function" 397 | }, 398 | { 399 | "constant": true, 400 | "inputs": [ 401 | { 402 | "name": "channelId", 403 | "type": "bytes32" 404 | }, 405 | { 406 | "name": "payment", 407 | "type": "uint256" 408 | }, 409 | { 410 | "name": "tokenContract", 411 | "type": "address" 412 | } 413 | ], 414 | "name": "paymentDigest", 415 | "outputs": [ 416 | { 417 | "name": "", 418 | "type": "bytes32" 419 | } 420 | ], 421 | "payable": false, 422 | "stateMutability": "view", 423 | "type": "function" 424 | } 425 | ] 426 | } 427 | -------------------------------------------------------------------------------- /src/abi/Unidirectional-mainnet.json: -------------------------------------------------------------------------------- 1 | { 2 | "bytecode": "0x6060604052341561000f57600080fd5b610d9e8061001e6000396000f3006060604052600436106100d95763ffffffff7c0100000000000000000000000000000000000000000000000000000000600035041662f31e7681146100de5780632f8f0c921461010857806341b6fcf71461012a5780634722361c1461010857806360546602146101555780636683f9ae1461016b5780637964ea87146101815780637a7ebd7b146101df5780637c35be7a14610235578063987757dd1461024b578063ad37908914610261578063b214faa514610277578063ba6cc6c314610282578063e62eea47146102a2578063ec8be5b9146102b8575b600080fd5b34156100e957600080fd5b6100f4600435610320565b604051901515815260200160405180910390f35b341561011357600080fd5b6100f4600435600160a060020a0360243516610332565b341561013557600080fd5b6101436004356024356103c7565b60405190815260200160405180910390f35b341561016057600080fd5b6100f460043561040c565b341561017657600080fd5b6100f4600435610486565b341561018c57600080fd5b6101dd600480359060248035919060649060443590810190830135806020601f820181900481020160405190810160405281815292919060208401838380828437509496506104fa95505050505050565b005b34156101ea57600080fd5b6101f56004356106c3565b604051600160a060020a03958616815293909416602084015260408084019290925263ffffffff166060830152608082019290925260a001905180910390f35b341561024057600080fd5b6100f4600435610705565b341561025657600080fd5b6101dd600435610726565b341561026c57600080fd5b6100f460043561080c565b6101dd6004356108a7565b6101dd600435600160a060020a036024351663ffffffff6044351661090f565b34156102ad57600080fd5b6101dd600435610a46565b34156102c357600080fd5b6100f460048035906024803591600160a060020a03604435169160849060643590810190830135806020601f82018190048102016040519081016040528181529291906020840183838082843750949650610ab295505050505050565b600061032b8261040c565b1592915050565b600061033c610d32565b6000848152602081905260408082209060a0905190810160409081528254600160a060020a039081168352600184015481166020840152600284015491830191909152600383015463ffffffff1660608301526004909201546080820152925084168251600160a060020a03161490506103b585610705565b80156103be5750805b95945050505050565b6000308383604051600160a060020a03939093166c01000000000000000000000000028352601483019190915260348201526054016040518091039020905092915050565b6000610416610d32565b600083815260208190526040908190209060a0905190810160409081528254600160a060020a0390811683526001840154166020830152600283015490820152600382015463ffffffff166060820152600490910154608082015290508051600160a060020a0316159392505050565b6000610490610d32565b600083815260208190526040908190209060a0905190810160409081528254600160a060020a0390811683526001840154166020830152600283015490820152600382015463ffffffff166060820152600490910154608082019081529091505115159392505050565b610502610d32565b61050e84843385610ab2565b151561051957600080fd5b600084815260208190526040908190209060a0905190810160409081528254600160a060020a03908116835260018401541660208301526002830154908201908152600383015463ffffffff166060830152600490920154608082015291505183106105bf578060200151600160a060020a03166108fc82604001519081150290604051600060405180830381858888f1935050505015156105ba57600080fd5b61063c565b8060200151600160a060020a031683156108fc0284604051600060405180830381858888f1935050505015156105f457600080fd5b8051600160a060020a03166108fc6106178584604001519063ffffffff610c6c16565b9081150290604051600060405180830381858888f19350505050151561063c57600080fd5b600084815260208190526040808220805473ffffffffffffffffffffffffffffffffffffffff19908116825560018201805490911690556002810183905560038101805463ffffffff191690556004019190915584907f3de43c9e481138453c3cfea2781e18a609abb6448556669b257edc7de710fd64905160405180910390a250505050565b60006020819052908152604090208054600182015460028301546003840154600490940154600160a060020a03938416949290931692909163ffffffff169085565b600061071082610320565b8015610720575061032b82610486565b92915050565b60006107318261080c565b151561073c57600080fd5b5060008181526020819052604090819020805460028201549192600160a060020a039091169180156108fc029151600060405180830381858888f19350505050151561078757600080fd5b600082815260208190526040808220805473ffffffffffffffffffffffffffffffffffffffff19908116825560018201805490911690556002810183905560038101805463ffffffff191690556004019190915582907f74fb75c3de2cff5e8a78cf9b1f49a5bea60126b42ed45bb4b2b25b7da03e4d1b905160405180910390a25050565b6000610816610d32565b6000838152602081905260408082209060a0905190810160409081528254600160a060020a0390811683526001840154166020830152600283015490820152600382015463ffffffff1660608201526004909101546080820152915061087b84610486565b801561088b575081608001514310155b905061089684610486565b801561089f5750805b949350505050565b6108b18133610332565b15156108bc57600080fd5b6000818152602081905260409081902060020180543490810190915582917f6f850cda6d6b2f5cca622bc2d4739e4ed917c12d29f9a92b9e6c127abe39842491905190815260200160405180910390a250565b6109188361040c565b151561092357600080fd5b60a06040519081016040908152600160a060020a0333811683528416602080840191909152348284015263ffffffff8416606084015260006080840181905286815290819052208151815473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a0391909116178155602082015160018201805473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a039290921691909117905560408201518160020155606082015160038201805463ffffffff191663ffffffff92909216919091179055608082015160049091015550600160a060020a03808316903316847f2f7cfc632227c054da7caaf75268353dba6206f53e9f7a547a193e66ab8c94dc3460405190815260200160405180910390a4505050565b6000610a528233610332565b1515610a5d57600080fd5b5060008181526020819052604090819020600381015463ffffffff16430160048201559082907fd6461a3a92fd600fe23f236b2e25c2fd0c197a66b2f990989f0b210d578f4617905160405180910390a25050565b6000610abc610d32565b600086815260208190526040808220829182919060a0905190810160409081528254600160a060020a03908116835260018401541660208301908152600284015491830191909152600383015463ffffffff1660608301526004909201546080820152945051600160a060020a031687600160a060020a0316149250610b428989610c7e565b915073__ECRecovery____________________________6319045a2583886000604051602001526040517c010000000000000000000000000000000000000000000000000000000063ffffffff851602815260048101838152604060248301908152909160440183818151815260200191508051906020019080838360005b83811015610bd9578082015183820152602001610bc1565b50505050905090810190601f168015610c065780820380516001836020036101000a031916815260200191505b50935050505060206040518083038186803b1515610c2357600080fd5b6102c65a03f41515610c3457600080fd5b5050506040518051600160a060020a031690508451600160a060020a0316149050828015610c5f5750805b9998505050505050505050565b600082821115610c7857fe5b50900390565b6000610c88610d60565b60408051908101604052601c81527f19457468657265756d205369676e6564204d6573736167653a0a3332000000006020820152905080610cc985856103c7565b6040518083805190602001908083835b60208310610cf85780518252601f199092019160209182019101610cd9565b6001836020036101000a03801982511681845116179092525050509190910192835250506020019050604051809103902091505092915050565b60a0604051908101604090815260008083526020830181905290820181905260608201819052608082015290565b602060405190810160405260008152905600a165627a7a7230582054107390e3abdedc00b3ae8b66039c130a5995a3ce03c1875e71bb0084435c460029", 3 | "abi": [ 4 | { 5 | "constant": true, 6 | "inputs": [ 7 | { 8 | "name": "", 9 | "type": "bytes32" 10 | } 11 | ], 12 | "name": "channels", 13 | "outputs": [ 14 | { 15 | "name": "sender", 16 | "type": "address" 17 | }, 18 | { 19 | "name": "receiver", 20 | "type": "address" 21 | }, 22 | { 23 | "name": "value", 24 | "type": "uint256" 25 | }, 26 | { 27 | "name": "settlingPeriod", 28 | "type": "uint32" 29 | }, 30 | { 31 | "name": "settlingUntil", 32 | "type": "uint256" 33 | } 34 | ], 35 | "payable": false, 36 | "stateMutability": "view", 37 | "type": "function" 38 | }, 39 | { 40 | "anonymous": false, 41 | "inputs": [ 42 | { 43 | "indexed": true, 44 | "name": "channelId", 45 | "type": "bytes32" 46 | }, 47 | { 48 | "indexed": true, 49 | "name": "sender", 50 | "type": "address" 51 | }, 52 | { 53 | "indexed": true, 54 | "name": "receiver", 55 | "type": "address" 56 | }, 57 | { 58 | "indexed": false, 59 | "name": "value", 60 | "type": "uint256" 61 | } 62 | ], 63 | "name": "DidOpen", 64 | "type": "event" 65 | }, 66 | { 67 | "anonymous": false, 68 | "inputs": [ 69 | { 70 | "indexed": true, 71 | "name": "channelId", 72 | "type": "bytes32" 73 | }, 74 | { 75 | "indexed": false, 76 | "name": "deposit", 77 | "type": "uint256" 78 | } 79 | ], 80 | "name": "DidDeposit", 81 | "type": "event" 82 | }, 83 | { 84 | "anonymous": false, 85 | "inputs": [ 86 | { 87 | "indexed": true, 88 | "name": "channelId", 89 | "type": "bytes32" 90 | } 91 | ], 92 | "name": "DidClaim", 93 | "type": "event" 94 | }, 95 | { 96 | "anonymous": false, 97 | "inputs": [ 98 | { 99 | "indexed": true, 100 | "name": "channelId", 101 | "type": "bytes32" 102 | } 103 | ], 104 | "name": "DidStartSettling", 105 | "type": "event" 106 | }, 107 | { 108 | "anonymous": false, 109 | "inputs": [ 110 | { 111 | "indexed": true, 112 | "name": "channelId", 113 | "type": "bytes32" 114 | } 115 | ], 116 | "name": "DidSettle", 117 | "type": "event" 118 | }, 119 | { 120 | "constant": false, 121 | "inputs": [ 122 | { 123 | "name": "channelId", 124 | "type": "bytes32" 125 | }, 126 | { 127 | "name": "receiver", 128 | "type": "address" 129 | }, 130 | { 131 | "name": "settlingPeriod", 132 | "type": "uint32" 133 | } 134 | ], 135 | "name": "open", 136 | "outputs": [], 137 | "payable": true, 138 | "stateMutability": "payable", 139 | "type": "function" 140 | }, 141 | { 142 | "constant": true, 143 | "inputs": [ 144 | { 145 | "name": "channelId", 146 | "type": "bytes32" 147 | }, 148 | { 149 | "name": "origin", 150 | "type": "address" 151 | } 152 | ], 153 | "name": "canDeposit", 154 | "outputs": [ 155 | { 156 | "name": "", 157 | "type": "bool" 158 | } 159 | ], 160 | "payable": false, 161 | "stateMutability": "view", 162 | "type": "function" 163 | }, 164 | { 165 | "constant": false, 166 | "inputs": [ 167 | { 168 | "name": "channelId", 169 | "type": "bytes32" 170 | } 171 | ], 172 | "name": "deposit", 173 | "outputs": [], 174 | "payable": true, 175 | "stateMutability": "payable", 176 | "type": "function" 177 | }, 178 | { 179 | "constant": true, 180 | "inputs": [ 181 | { 182 | "name": "channelId", 183 | "type": "bytes32" 184 | }, 185 | { 186 | "name": "origin", 187 | "type": "address" 188 | } 189 | ], 190 | "name": "canStartSettling", 191 | "outputs": [ 192 | { 193 | "name": "", 194 | "type": "bool" 195 | } 196 | ], 197 | "payable": false, 198 | "stateMutability": "view", 199 | "type": "function" 200 | }, 201 | { 202 | "constant": false, 203 | "inputs": [ 204 | { 205 | "name": "channelId", 206 | "type": "bytes32" 207 | } 208 | ], 209 | "name": "startSettling", 210 | "outputs": [], 211 | "payable": false, 212 | "stateMutability": "nonpayable", 213 | "type": "function" 214 | }, 215 | { 216 | "constant": true, 217 | "inputs": [ 218 | { 219 | "name": "channelId", 220 | "type": "bytes32" 221 | } 222 | ], 223 | "name": "canSettle", 224 | "outputs": [ 225 | { 226 | "name": "", 227 | "type": "bool" 228 | } 229 | ], 230 | "payable": false, 231 | "stateMutability": "view", 232 | "type": "function" 233 | }, 234 | { 235 | "constant": false, 236 | "inputs": [ 237 | { 238 | "name": "channelId", 239 | "type": "bytes32" 240 | } 241 | ], 242 | "name": "settle", 243 | "outputs": [], 244 | "payable": false, 245 | "stateMutability": "nonpayable", 246 | "type": "function" 247 | }, 248 | { 249 | "constant": true, 250 | "inputs": [ 251 | { 252 | "name": "channelId", 253 | "type": "bytes32" 254 | }, 255 | { 256 | "name": "payment", 257 | "type": "uint256" 258 | }, 259 | { 260 | "name": "origin", 261 | "type": "address" 262 | }, 263 | { 264 | "name": "signature", 265 | "type": "bytes" 266 | } 267 | ], 268 | "name": "canClaim", 269 | "outputs": [ 270 | { 271 | "name": "", 272 | "type": "bool" 273 | } 274 | ], 275 | "payable": false, 276 | "stateMutability": "view", 277 | "type": "function" 278 | }, 279 | { 280 | "constant": false, 281 | "inputs": [ 282 | { 283 | "name": "channelId", 284 | "type": "bytes32" 285 | }, 286 | { 287 | "name": "payment", 288 | "type": "uint256" 289 | }, 290 | { 291 | "name": "signature", 292 | "type": "bytes" 293 | } 294 | ], 295 | "name": "claim", 296 | "outputs": [], 297 | "payable": false, 298 | "stateMutability": "nonpayable", 299 | "type": "function" 300 | }, 301 | { 302 | "constant": true, 303 | "inputs": [ 304 | { 305 | "name": "channelId", 306 | "type": "bytes32" 307 | } 308 | ], 309 | "name": "isPresent", 310 | "outputs": [ 311 | { 312 | "name": "", 313 | "type": "bool" 314 | } 315 | ], 316 | "payable": false, 317 | "stateMutability": "view", 318 | "type": "function" 319 | }, 320 | { 321 | "constant": true, 322 | "inputs": [ 323 | { 324 | "name": "channelId", 325 | "type": "bytes32" 326 | } 327 | ], 328 | "name": "isAbsent", 329 | "outputs": [ 330 | { 331 | "name": "", 332 | "type": "bool" 333 | } 334 | ], 335 | "payable": false, 336 | "stateMutability": "view", 337 | "type": "function" 338 | }, 339 | { 340 | "constant": true, 341 | "inputs": [ 342 | { 343 | "name": "channelId", 344 | "type": "bytes32" 345 | } 346 | ], 347 | "name": "isSettling", 348 | "outputs": [ 349 | { 350 | "name": "", 351 | "type": "bool" 352 | } 353 | ], 354 | "payable": false, 355 | "stateMutability": "view", 356 | "type": "function" 357 | }, 358 | { 359 | "constant": true, 360 | "inputs": [ 361 | { 362 | "name": "channelId", 363 | "type": "bytes32" 364 | } 365 | ], 366 | "name": "isOpen", 367 | "outputs": [ 368 | { 369 | "name": "", 370 | "type": "bool" 371 | } 372 | ], 373 | "payable": false, 374 | "stateMutability": "view", 375 | "type": "function" 376 | }, 377 | { 378 | "constant": true, 379 | "inputs": [ 380 | { 381 | "name": "channelId", 382 | "type": "bytes32" 383 | }, 384 | { 385 | "name": "payment", 386 | "type": "uint256" 387 | } 388 | ], 389 | "name": "paymentDigest", 390 | "outputs": [ 391 | { 392 | "name": "", 393 | "type": "bytes32" 394 | } 395 | ], 396 | "payable": false, 397 | "stateMutability": "view", 398 | "type": "function" 399 | } 400 | ] 401 | } 402 | -------------------------------------------------------------------------------- /src/abi/Unidirectional-testnet.json: -------------------------------------------------------------------------------- 1 | { 2 | "bytecode": "0x608060405234801561001057600080fd5b50610d75806100206000396000f3006080604052600436106100d95763ffffffff7c0100000000000000000000000000000000000000000000000000000000600035041662f31e7681146100de5780632f8f0c921461010a57806341b6fcf71461012e5780634722361c1461010a578063605466021461015b5780636683f9ae146101735780637964ea871461018b5780637a7ebd7b146101ed5780637c35be7a1461023c578063987757dd14610254578063ad3790891461026c578063b214faa514610284578063e62eea471461028f578063ec8be5b9146102a7578063fd745bce14610313575b600080fd5b3480156100ea57600080fd5b506100f660043561032d565b604080519115158252519081900360200190f35b34801561011657600080fd5b506100f6600435600160a060020a036024351661033f565b34801561013a57600080fd5b506101496004356024356103c0565b60408051918252519081900360200190f35b34801561016757600080fd5b506100f6600435610461565b34801561017f57600080fd5b506100f66004356104cb565b34801561019757600080fd5b50604080516020600460443581810135601f81018490048402850184019095528484526101eb9482359460248035953695946064949201919081908401838280828437509497506105349650505050505050565b005b3480156101f957600080fd5b506102056004356106fb565b60408051600160a060020a039687168152949095166020850152838501929092526060830152608082015290519081900360a00190f35b34801561024857600080fd5b506100f6600435610736565b34801561026057600080fd5b506101eb600435610757565b34801561027857600080fd5b506100f6600435610830565b6101eb6004356108c3565b34801561029b57600080fd5b506101eb600435610929565b3480156102b357600080fd5b50604080516020601f6064356004818101359283018490048402850184019095528184526100f6948035946024803595600160a060020a0360443516953695608494930191819084018382808284375094975061098a9650505050505050565b6101eb600435600160a060020a0360243516604435610a39565b600061033882610461565b1592915050565b6000610349610d07565b5060008381526020818152604091829020825160a0810184528154600160a060020a039081168083526001840154821694830194909452600283015494820194909452600382015460608201526004909101546080820152918416146103ae85610736565b80156103b75750805b95945050505050565b604080516c010000000000000000000000003002602080830191909152603482018590526054808301859052835180840390910181526074909201928390528151600093918291908401908083835b6020831061042e5780518252601f19909201916020918201910161040f565b5181516020939093036101000a600019018019909116921691909117905260405192018290039091209695505050505050565b600061046b610d07565b505060008181526020818152604091829020825160a0810184528154600160a060020a0390811680835260018401549091169382019390935260028201549381019390935260038101546060840152600401546080830152159050919050565b60006104d5610d07565b505060009081526020818152604091829020825160a0810184528154600160a060020a03908116825260018301541692810192909252600281015492820192909252600382015460608201526004909101546080909101819052151590565b61053c610d07565b6105488484338561098a565b151561055357600080fd5b5060008381526020818152604091829020825160a0810184528154600160a060020a039081168252600183015416928101929092526002810154928201839052600381015460608301526004015460808201529083106105f4578060200151600160a060020a03166108fc82604001519081150290604051600060405180830381858888f193505050501580156105ee573d6000803e3d6000fd5b50610684565b8060200151600160a060020a03166108fc849081150290604051600060405180830381858888f19350505050158015610631573d6000803e3d6000fd5b508060000151600160a060020a03166108fc61065a858460400151610b1890919063ffffffff16565b6040518115909202916000818181858888f19350505050158015610682573d6000803e3d6000fd5b505b600084815260208190526040808220805473ffffffffffffffffffffffffffffffffffffffff199081168255600182018054909116905560028101839055600381018390556004018290555185917f3de43c9e481138453c3cfea2781e18a609abb6448556669b257edc7de710fd6491a250505050565b60006020819052908152604090208054600182015460028301546003840154600490940154600160a060020a03938416949390921692909185565b60006107418261032d565b80156107515750610338826104cb565b92915050565b600061076282610830565b151561076d57600080fd5b506000818152602081905260408082208054600282015492519193600160a060020a039091169280156108fc02929091818181858888f193505050501580156107ba573d6000803e3d6000fd5b50600082815260208190526040808220805473ffffffffffffffffffffffffffffffffffffffff199081168255600182018054909116905560028101839055600381018390556004018290555183917f74fb75c3de2cff5e8a78cf9b1f49a5bea60126b42ed45bb4b2b25b7da03e4d1b91a25050565b600061083a610d07565b50600082815260208181526040808320815160a0810183528154600160a060020a039081168252600183015416938101939093526002810154918301919091526003810154606083015260040154608082015290610897846104cb565b80156108a7575081608001514310155b90506108b2846104cb565b80156108bb5750805b949350505050565b6108cd813361033f565b15156108d857600080fd5b600081815260208181526040918290206002018054349081019091558251908152915183927f6f850cda6d6b2f5cca622bc2d4739e4ed917c12d29f9a92b9e6c127abe39842492908290030190a250565b6000610935823361033f565b151561094057600080fd5b506000818152602081905260408082206003810154430160048201559051909183917fd6461a3a92fd600fe23f236b2e25c2fd0c197a66b2f990989f0b210d578f46179190a25050565b6000610994610d07565b50600085815260208181526040808320815160a0810183528154600160a060020a039081168252600183015481169482018590526002830154938201939093526003820154606082015260049091015460808201529290861690911490806109fc8989610b2a565b9150610a088287610c32565b600160a060020a03168460000151600160a060020a0316149050828015610a2c5750805b9998505050505050505050565b610a4283610461565b1515610a4d57600080fd5b6040805160a08101825233808252600160a060020a03858116602080850182815234868801818152606088018a8152600060808a018181528e8252818752908b902099518a5490891673ffffffffffffffffffffffffffffffffffffffff19918216178b55945160018b01805491909916951694909417909655516002880155935160038701555160049095019490945584519182529351919287927f2f7cfc632227c054da7caaf75268353dba6206f53e9f7a547a193e66ab8c94dc9281900390910190a4505050565b600082821115610b2457fe5b50900390565b60408051808201909152601c81527f19457468657265756d205369676e6564204d6573736167653a0a333200000000602082015260009080610b6c85856103c0565b6040516020018083805190602001908083835b60208310610b9e5780518252601f199092019160209182019101610b7f565b51815160209384036101000a600019018019909216911617905292019384525060408051808503815293820190819052835193945092839250908401908083835b60208310610bfe5780518252601f199092019160209182019101610bdf565b5181516020939093036101000a60001901801990911692169190911790526040519201829003909120979650505050505050565b60008060008084516041141515610c4c5760009350610cfe565b50505060208201516040830151606084015160001a601b60ff82161015610c7157601b015b8060ff16601b14158015610c8957508060ff16601c14155b15610c975760009350610cfe565b60408051600080825260208083018085528a905260ff8516838501526060830187905260808301869052925160019360a0808501949193601f19840193928390039091019190865af1158015610cf1573d6000803e3d6000fd5b5050506020604051035193505b50505092915050565b60a0604051908101604052806000600160a060020a031681526020016000600160a060020a0316815260200160008152602001600081526020016000815250905600a165627a7a72305820ed228e45a3b78856197decd7d98d6c8cee748b84b3c25cccaf2189380766290e0029", 3 | "abi": [ 4 | { 5 | "constant": true, 6 | "inputs": [ 7 | { 8 | "name": "", 9 | "type": "bytes32" 10 | } 11 | ], 12 | "name": "channels", 13 | "outputs": [ 14 | { 15 | "name": "sender", 16 | "type": "address" 17 | }, 18 | { 19 | "name": "receiver", 20 | "type": "address" 21 | }, 22 | { 23 | "name": "value", 24 | "type": "uint256" 25 | }, 26 | { 27 | "name": "settlingPeriod", 28 | "type": "uint256" 29 | }, 30 | { 31 | "name": "settlingUntil", 32 | "type": "uint256" 33 | } 34 | ], 35 | "payable": false, 36 | "stateMutability": "view", 37 | "type": "function" 38 | }, 39 | { 40 | "anonymous": false, 41 | "inputs": [ 42 | { 43 | "indexed": true, 44 | "name": "channelId", 45 | "type": "bytes32" 46 | }, 47 | { 48 | "indexed": true, 49 | "name": "sender", 50 | "type": "address" 51 | }, 52 | { 53 | "indexed": true, 54 | "name": "receiver", 55 | "type": "address" 56 | }, 57 | { 58 | "indexed": false, 59 | "name": "value", 60 | "type": "uint256" 61 | } 62 | ], 63 | "name": "DidOpen", 64 | "type": "event" 65 | }, 66 | { 67 | "anonymous": false, 68 | "inputs": [ 69 | { 70 | "indexed": true, 71 | "name": "channelId", 72 | "type": "bytes32" 73 | }, 74 | { 75 | "indexed": false, 76 | "name": "deposit", 77 | "type": "uint256" 78 | } 79 | ], 80 | "name": "DidDeposit", 81 | "type": "event" 82 | }, 83 | { 84 | "anonymous": false, 85 | "inputs": [ 86 | { 87 | "indexed": true, 88 | "name": "channelId", 89 | "type": "bytes32" 90 | } 91 | ], 92 | "name": "DidClaim", 93 | "type": "event" 94 | }, 95 | { 96 | "anonymous": false, 97 | "inputs": [ 98 | { 99 | "indexed": true, 100 | "name": "channelId", 101 | "type": "bytes32" 102 | } 103 | ], 104 | "name": "DidStartSettling", 105 | "type": "event" 106 | }, 107 | { 108 | "anonymous": false, 109 | "inputs": [ 110 | { 111 | "indexed": true, 112 | "name": "channelId", 113 | "type": "bytes32" 114 | } 115 | ], 116 | "name": "DidSettle", 117 | "type": "event" 118 | }, 119 | { 120 | "constant": false, 121 | "inputs": [ 122 | { 123 | "name": "channelId", 124 | "type": "bytes32" 125 | }, 126 | { 127 | "name": "receiver", 128 | "type": "address" 129 | }, 130 | { 131 | "name": "settlingPeriod", 132 | "type": "uint256" 133 | } 134 | ], 135 | "name": "open", 136 | "outputs": [], 137 | "payable": true, 138 | "stateMutability": "payable", 139 | "type": "function" 140 | }, 141 | { 142 | "constant": true, 143 | "inputs": [ 144 | { 145 | "name": "channelId", 146 | "type": "bytes32" 147 | }, 148 | { 149 | "name": "origin", 150 | "type": "address" 151 | } 152 | ], 153 | "name": "canDeposit", 154 | "outputs": [ 155 | { 156 | "name": "", 157 | "type": "bool" 158 | } 159 | ], 160 | "payable": false, 161 | "stateMutability": "view", 162 | "type": "function" 163 | }, 164 | { 165 | "constant": false, 166 | "inputs": [ 167 | { 168 | "name": "channelId", 169 | "type": "bytes32" 170 | } 171 | ], 172 | "name": "deposit", 173 | "outputs": [], 174 | "payable": true, 175 | "stateMutability": "payable", 176 | "type": "function" 177 | }, 178 | { 179 | "constant": true, 180 | "inputs": [ 181 | { 182 | "name": "channelId", 183 | "type": "bytes32" 184 | }, 185 | { 186 | "name": "origin", 187 | "type": "address" 188 | } 189 | ], 190 | "name": "canStartSettling", 191 | "outputs": [ 192 | { 193 | "name": "", 194 | "type": "bool" 195 | } 196 | ], 197 | "payable": false, 198 | "stateMutability": "view", 199 | "type": "function" 200 | }, 201 | { 202 | "constant": false, 203 | "inputs": [ 204 | { 205 | "name": "channelId", 206 | "type": "bytes32" 207 | } 208 | ], 209 | "name": "startSettling", 210 | "outputs": [], 211 | "payable": false, 212 | "stateMutability": "nonpayable", 213 | "type": "function" 214 | }, 215 | { 216 | "constant": true, 217 | "inputs": [ 218 | { 219 | "name": "channelId", 220 | "type": "bytes32" 221 | } 222 | ], 223 | "name": "canSettle", 224 | "outputs": [ 225 | { 226 | "name": "", 227 | "type": "bool" 228 | } 229 | ], 230 | "payable": false, 231 | "stateMutability": "view", 232 | "type": "function" 233 | }, 234 | { 235 | "constant": false, 236 | "inputs": [ 237 | { 238 | "name": "channelId", 239 | "type": "bytes32" 240 | } 241 | ], 242 | "name": "settle", 243 | "outputs": [], 244 | "payable": false, 245 | "stateMutability": "nonpayable", 246 | "type": "function" 247 | }, 248 | { 249 | "constant": true, 250 | "inputs": [ 251 | { 252 | "name": "channelId", 253 | "type": "bytes32" 254 | }, 255 | { 256 | "name": "payment", 257 | "type": "uint256" 258 | }, 259 | { 260 | "name": "origin", 261 | "type": "address" 262 | }, 263 | { 264 | "name": "signature", 265 | "type": "bytes" 266 | } 267 | ], 268 | "name": "canClaim", 269 | "outputs": [ 270 | { 271 | "name": "", 272 | "type": "bool" 273 | } 274 | ], 275 | "payable": false, 276 | "stateMutability": "view", 277 | "type": "function" 278 | }, 279 | { 280 | "constant": false, 281 | "inputs": [ 282 | { 283 | "name": "channelId", 284 | "type": "bytes32" 285 | }, 286 | { 287 | "name": "payment", 288 | "type": "uint256" 289 | }, 290 | { 291 | "name": "signature", 292 | "type": "bytes" 293 | } 294 | ], 295 | "name": "claim", 296 | "outputs": [], 297 | "payable": false, 298 | "stateMutability": "nonpayable", 299 | "type": "function" 300 | }, 301 | { 302 | "constant": true, 303 | "inputs": [ 304 | { 305 | "name": "channelId", 306 | "type": "bytes32" 307 | } 308 | ], 309 | "name": "isPresent", 310 | "outputs": [ 311 | { 312 | "name": "", 313 | "type": "bool" 314 | } 315 | ], 316 | "payable": false, 317 | "stateMutability": "view", 318 | "type": "function" 319 | }, 320 | { 321 | "constant": true, 322 | "inputs": [ 323 | { 324 | "name": "channelId", 325 | "type": "bytes32" 326 | } 327 | ], 328 | "name": "isAbsent", 329 | "outputs": [ 330 | { 331 | "name": "", 332 | "type": "bool" 333 | } 334 | ], 335 | "payable": false, 336 | "stateMutability": "view", 337 | "type": "function" 338 | }, 339 | { 340 | "constant": true, 341 | "inputs": [ 342 | { 343 | "name": "channelId", 344 | "type": "bytes32" 345 | } 346 | ], 347 | "name": "isSettling", 348 | "outputs": [ 349 | { 350 | "name": "", 351 | "type": "bool" 352 | } 353 | ], 354 | "payable": false, 355 | "stateMutability": "view", 356 | "type": "function" 357 | }, 358 | { 359 | "constant": true, 360 | "inputs": [ 361 | { 362 | "name": "channelId", 363 | "type": "bytes32" 364 | } 365 | ], 366 | "name": "isOpen", 367 | "outputs": [ 368 | { 369 | "name": "", 370 | "type": "bool" 371 | } 372 | ], 373 | "payable": false, 374 | "stateMutability": "view", 375 | "type": "function" 376 | }, 377 | { 378 | "constant": true, 379 | "inputs": [ 380 | { 381 | "name": "channelId", 382 | "type": "bytes32" 383 | }, 384 | { 385 | "name": "payment", 386 | "type": "uint256" 387 | } 388 | ], 389 | "name": "paymentDigest", 390 | "outputs": [ 391 | { 392 | "name": "", 393 | "type": "bytes32" 394 | } 395 | ], 396 | "payable": false, 397 | "stateMutability": "view", 398 | "type": "function" 399 | } 400 | ] 401 | } 402 | -------------------------------------------------------------------------------- /src/account.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js' 2 | import { 3 | MIME_APPLICATION_JSON, 4 | MIME_APPLICATION_OCTET_STREAM, 5 | MIME_TEXT_PLAIN_UTF8, 6 | TYPE_MESSAGE 7 | } from 'btp-packet' 8 | import { randomBytes } from 'crypto' 9 | import { ethers } from 'ethers' 10 | import { TransactionReceipt } from 'ethers/providers' 11 | import { 12 | deserializeIlpPrepare, 13 | deserializeIlpReply, 14 | Errors, 15 | errorToReject, 16 | IlpPrepare, 17 | IlpReply, 18 | isFulfill, 19 | isReject 20 | } from 'ilp-packet' 21 | import { BtpPacket, BtpPacketData, BtpSubProtocol } from 'ilp-plugin-btp' 22 | import { promisify } from 'util' 23 | import EthereumPlugin from '.' 24 | import { DataHandler, MoneyHandler } from './types/plugin' 25 | import { 26 | ClaimablePaymentChannel, 27 | createPaymentDigest, 28 | fetchChannelById, 29 | generateChannelId, 30 | hasClaim, 31 | hasEvent, 32 | hexToBuffer, 33 | isDisputed, 34 | isValidClaimSignature, 35 | PaymentChannel, 36 | prepareTransaction, 37 | remainingInChannel, 38 | SerializedClaim, 39 | SerializedClaimablePaymentChannel, 40 | SerializedPaymentChannel, 41 | spentFromChannel, 42 | updateChannel, 43 | didChannelClose 44 | } from './utils/channel' 45 | import ReducerQueue from './utils/queue' 46 | 47 | // Almost never use exponential notation 48 | BigNumber.config({ EXPONENTIAL_AT: 1e9 }) 49 | 50 | /** 51 | * - approve() using OpenZeppelin's MintableToken => 47146 52 | * - open() using TokenUnidirectional => 173825 53 | * Total: 220971 54 | * 55 | * - Allow wiggle room in case the ERC-20 implementation needs additional gas 56 | */ 57 | const APPROVE_AND_OPEN_GAS_LIMIT = new BigNumber(300000) 58 | 59 | /** 60 | * - approve() using OpenZeppelin's MintableToken => 47358 61 | * - deposit() using TokenUnidirectional => 55192 62 | * Total: 102550 63 | * 64 | * - Allow wiggle room in case the ERC-20 implementation needs additional gas 65 | */ 66 | const APPROVE_AND_DEPOSIT_GAS_LIMIT = new BigNumber(150000) 67 | 68 | const delay = (timeout: number) => new Promise(r => setTimeout(r, timeout)) 69 | 70 | const getBtpSubprotocol = (message: BtpPacket, name: string) => 71 | message.data.protocolData.find((p: BtpSubProtocol) => p.protocolName === name) 72 | 73 | export const generateBtpRequestId = async () => 74 | (await promisify(randomBytes)(4)).readUInt32BE(0) 75 | 76 | export interface SerializedAccountData { 77 | accountName: string 78 | receivableBalance: string 79 | payableBalance: string 80 | payoutAmount: string 81 | ethereumAddress?: string 82 | incoming?: SerializedClaimablePaymentChannel 83 | outgoing?: SerializedPaymentChannel 84 | } 85 | 86 | export interface AccountData { 87 | /** Hash/account identifier in ILP address */ 88 | accountName: string 89 | 90 | /** Incoming amount owed to us by our peer for their packets we've forwarded */ 91 | receivableBalance: BigNumber 92 | 93 | /** Outgoing amount owed by us to our peer for packets we've sent to them */ 94 | payableBalance: BigNumber 95 | 96 | /** 97 | * Amount of failed outgoing settlements that is owed to the peer, but not reflected 98 | * in the payableBalance (e.g. due to sendMoney calls on client) 99 | */ 100 | payoutAmount: BigNumber 101 | 102 | /** 103 | * Ethereum address counterparty should be paid at 104 | * - Does not pertain to address counterparty sends from 105 | * - Must be linked for the lifetime of the account 106 | */ 107 | ethereumAddress?: string 108 | 109 | /** 110 | * Priority FIFO queue for incoming channel state updates: 111 | * - Validating claims 112 | * - Watching channels 113 | * - Claiming chanenls 114 | */ 115 | incoming: ReducerQueue 116 | 117 | /** 118 | * Priority FIFO queue for outgoing channel state updates: 119 | * - Signing claims 120 | * - Refreshing state after funding transactions 121 | */ 122 | outgoing: ReducerQueue 123 | } 124 | 125 | enum IncomingTaskPriority { 126 | ClaimChannel = 1, 127 | ValidateClaim = 0 128 | } 129 | 130 | export default class EthereumAccount { 131 | /** Metadata specific to this account to persist (claims, channels, balances) */ 132 | account: AccountData 133 | 134 | /** Expose access to common configuration across accounts */ 135 | private master: EthereumPlugin 136 | 137 | /** 138 | * Queue for channel state/signing outgoing claims ONLY while a deposit is occuring, 139 | * enabling them to happen in parallel 140 | */ 141 | private depositQueue?: ReducerQueue 142 | 143 | /** 144 | * Send the given BTP packet message to the counterparty for this account 145 | * (wraps _call on internal plugin) 146 | */ 147 | private sendMessage: (message: BtpPacket) => Promise 148 | 149 | /** Data handler from plugin for incoming ILP packets */ 150 | private dataHandler: DataHandler 151 | 152 | /** Money handler from plugin for incoming money */ 153 | private moneyHandler: MoneyHandler 154 | 155 | /** Timer/interval for channel watcher to claim incoming, disputed channels */ 156 | private watcher: NodeJS.Timer | null 157 | 158 | constructor({ 159 | accountData, 160 | master, 161 | sendMessage, 162 | dataHandler, 163 | moneyHandler 164 | }: { 165 | accountName: string 166 | accountData: AccountData 167 | master: EthereumPlugin 168 | sendMessage: (message: BtpPacket) => Promise 169 | dataHandler: DataHandler 170 | moneyHandler: MoneyHandler 171 | }) { 172 | this.master = master 173 | this.sendMessage = sendMessage 174 | this.dataHandler = dataHandler 175 | this.moneyHandler = moneyHandler 176 | 177 | this.account = new Proxy(accountData, { 178 | set: (account, key, val) => { 179 | this.persistAccountData() 180 | return Reflect.set(account, key, val) 181 | } 182 | }) 183 | 184 | // Automatically persist cached channels/claims to the store 185 | this.account.incoming.on('data', () => this.persistAccountData()) 186 | this.account.outgoing.on('data', () => this.persistAccountData()) 187 | 188 | this.watcher = this.startChannelWatcher() 189 | 190 | /** 191 | * Channel should be "auto funded" only if the client is online. For example: 192 | * - The account just got created and no Ethereum address is linked (which requires it to be fetched from an online client) 193 | * - An incoming paychan claim was received, and may put it above the threshold 194 | * - An outgoing paychan claim was sent, so a top-up may be required, which likely only happened 195 | * if the client was online and e.g. just returned a FULFILL packet 196 | */ 197 | if (!this.account.ethereumAddress) { 198 | this.autoFundOutgoingChannel().catch(err => { 199 | this.master._log.error( 200 | 'Error attempting to auto fund outgoing channel: ', 201 | err 202 | ) 203 | }) 204 | } 205 | } 206 | 207 | private persistAccountData(): void { 208 | this.master._store.set(`${this.account.accountName}:account`, this.account) 209 | } 210 | 211 | /** 212 | * Inform the peer what address this instance should be paid at and 213 | * request the Ethereum address the peer wants to be paid at 214 | * - No-op if we already know the peer's address 215 | */ 216 | private async fetchEthereumAddress(): Promise { 217 | if (typeof this.account.ethereumAddress === 'string') return 218 | try { 219 | const response = await this.sendMessage({ 220 | type: TYPE_MESSAGE, 221 | requestId: await generateBtpRequestId(), 222 | data: { 223 | protocolData: [ 224 | { 225 | protocolName: 'info', 226 | contentType: MIME_APPLICATION_JSON, 227 | data: Buffer.from( 228 | JSON.stringify({ 229 | ethereumAddress: this.master._wallet.address 230 | }) 231 | ) 232 | } 233 | ] 234 | } 235 | }) 236 | 237 | const info = response.protocolData.find( 238 | (p: BtpSubProtocol) => p.protocolName === 'info' 239 | ) 240 | 241 | if (info) { 242 | this.linkEthereumAddress(info) 243 | } else { 244 | this.master._log.debug( 245 | `Failed to link Ethereum address: BTP response did not include any 'info' subprotocol data` 246 | ) 247 | } 248 | } catch (err) { 249 | this.master._log.debug( 250 | `Failed to exchange Ethereum addresses: ${err.message}` 251 | ) 252 | } 253 | } 254 | 255 | /** 256 | * Validate the response to an `info` request and link 257 | * the provided Ethereum address to the account, if it's valid 258 | */ 259 | private linkEthereumAddress(info: BtpSubProtocol): void { 260 | try { 261 | const { ethereumAddress } = JSON.parse(info.data.toString()) 262 | 263 | if (typeof ethereumAddress !== 'string') { 264 | return this.master._log.debug( 265 | `Failed to link Ethereum address: invalid response, no address provided` 266 | ) 267 | } 268 | 269 | if (!ethers.utils.getAddress(ethereumAddress)) { 270 | return this.master._log.debug( 271 | `Failed to link Ethereum address: not a valid address` 272 | ) 273 | } 274 | 275 | const currentAddress = this.account.ethereumAddress 276 | if (currentAddress) { 277 | // Don't log if it's the same address that's already linked...we don't care 278 | if (currentAddress.toLowerCase() === ethereumAddress.toLowerCase()) { 279 | return 280 | } 281 | 282 | return this.master._log.debug( 283 | `Cannot link Ethereum address ${ethereumAddress} to ${ 284 | this.account.accountName 285 | }: ${currentAddress} is already linked for the lifetime of the account` 286 | ) 287 | } 288 | 289 | this.account.ethereumAddress = ethereumAddress 290 | this.master._log.debug( 291 | `Successfully linked Ethereum address ${ethereumAddress} to ${ 292 | this.account.accountName 293 | }` 294 | ) 295 | } catch (err) { 296 | this.master._log.debug(`Failed to link Ethereum address: ${err.message}`) 297 | } 298 | } 299 | 300 | /** 301 | * Create a channel with the given amount or deposit the given amount to an existing outgoing channel, 302 | * invoking the authorize callback to confirm the transaction fee 303 | * - Fund amount is in base units (wei for ETH) 304 | */ 305 | async fundOutgoingChannel( 306 | value: BigNumber, 307 | authorize: (fee: BigNumber) => Promise = () => Promise.resolve() 308 | ) { 309 | return this.account.outgoing.add(cachedChannel => 310 | cachedChannel 311 | ? this.depositToChannel(cachedChannel, value, authorize) 312 | : this.openChannel(value, authorize) 313 | ) 314 | } 315 | 316 | /** 317 | * Automatically fund a new outgoing channel or topup an existing channel 318 | * - When over 50% of the capacity has been spent/sent to the receiver, 319 | * add the outgoing channel amount to the channel 320 | */ 321 | private async autoFundOutgoingChannel() { 322 | await this.account.outgoing.add(async cachedChannel => { 323 | const requiresTopUp = 324 | !cachedChannel || 325 | remainingInChannel(cachedChannel).isLessThan( 326 | this.master._outgoingChannelAmount.dividedBy(2) 327 | ) 328 | 329 | const incomingChannel = this.account.incoming.state 330 | const sufficientIncoming = (incomingChannel 331 | ? incomingChannel.value 332 | : new BigNumber(0) 333 | ).isGreaterThanOrEqualTo(this.master._minIncomingChannelAmount) 334 | 335 | if (requiresTopUp && sufficientIncoming) { 336 | return cachedChannel 337 | ? this.depositToChannel( 338 | cachedChannel, 339 | this.master._outgoingChannelAmount 340 | ) 341 | : this.openChannel(this.master._outgoingChannelAmount) 342 | } 343 | 344 | return cachedChannel 345 | }) 346 | } 347 | 348 | /** 349 | * Open a channel for the given amount in base units (wei for ETH) 350 | * - Must always be called from a task in the outgoing queue 351 | */ 352 | private async openChannel( 353 | value: BigNumber, 354 | authorize: (fee: BigNumber) => Promise = () => Promise.resolve() 355 | ): Promise { 356 | await this.fetchEthereumAddress() 357 | if (!this.account.ethereumAddress) { 358 | this.master._log.debug( 359 | 'Failed to open channel: no Ethereum address is linked' 360 | ) 361 | return 362 | } 363 | 364 | const channelId = await generateChannelId() 365 | const sender = this.master._wallet.address 366 | const receiver = this.account.ethereumAddress 367 | const disputePeriod = this.master._outgoingDisputePeriod 368 | const tokenContract = 369 | this.master._tokenContract && this.master._tokenContract.address 370 | 371 | const contract = await this.master._contract 372 | const gasPrice = await this.master._getGasPrice() 373 | 374 | const txObj = !tokenContract 375 | ? { 376 | methodName: 'open', 377 | params: [channelId, receiver, disputePeriod.toString()], 378 | contract, 379 | gasPrice, 380 | value 381 | } 382 | : { 383 | methodName: 'open', 384 | params: [ 385 | channelId, 386 | receiver, 387 | disputePeriod.toString(), 388 | tokenContract, 389 | value.toString() 390 | ], 391 | contract, 392 | gasPrice 393 | } 394 | 395 | const { 396 | sendTransaction, 397 | txFee 398 | }: { 399 | sendTransaction: () => Promise 400 | txFee: BigNumber 401 | } = await this.checkIfApproved(value).then(async requiresApproval => { 402 | /** For ETH or unlocked ERC-20s, accurately estimate the gas */ 403 | if (!requiresApproval) { 404 | const { txFee, sendTransaction } = await prepareTransaction(txObj) 405 | 406 | const hasSufficientBalance = await this.checkIfSufficientBalance( 407 | value, 408 | txFee 409 | ) 410 | if (!hasSufficientBalance) { 411 | throw new Error('ETH balance is insufficient to open channel') 412 | } 413 | 414 | await authorize(txFee) 415 | return { txFee, sendTransaction } 416 | } else { 417 | /** 418 | * If approving ERC-20 token transfers is required, overestimate the gas, 419 | * using a combined upperbound for both transactions 420 | */ 421 | const gasLimit = APPROVE_AND_OPEN_GAS_LIMIT 422 | 423 | const gasPrice = await this.master._getGasPrice() 424 | const txFee = gasLimit.times(gasPrice) 425 | 426 | const hasSufficientBalance = await this.checkIfSufficientBalance( 427 | value, 428 | txFee 429 | ) 430 | if (!hasSufficientBalance) { 431 | throw new Error( 432 | 'ERC-20 balance or ETH balance is insufficient to open channel' 433 | ) 434 | } 435 | 436 | await authorize(txFee) 437 | 438 | const remainingGasLimit = await this.secureAllowance(gasLimit) 439 | 440 | return prepareTransaction({ 441 | ...txObj, 442 | gasLimit: remainingGasLimit 443 | }) 444 | } 445 | }) 446 | 447 | this.master._log.debug( 448 | `Opening channel for ${this.master._format( 449 | value, 450 | 'base' 451 | )} and fee of ${this.master._format(txFee, 'base')}` 452 | ) 453 | 454 | const receipt = await this.master 455 | ._queueTransaction(sendTransaction) 456 | .catch(err => { 457 | this.master._log.error(`Failed to open channel:`, err) 458 | throw err 459 | }) 460 | 461 | const didOpen = hasEvent(receipt, 'DidOpen') 462 | if (!didOpen) { 463 | throw new Error('Failed to open new channel') 464 | } 465 | 466 | // Construct the known initial channel state 467 | const signedChannel = this.signClaim({ 468 | lastUpdated: Date.now(), 469 | contractAddress: contract.address, 470 | channelId, 471 | receiver, 472 | sender, 473 | disputePeriod, 474 | value, 475 | spent: new BigNumber(0), 476 | tokenContract 477 | }) 478 | 479 | // Send a zero amount claim to the peer so they'll link the channel 480 | this.sendClaim(signedChannel).catch(err => 481 | this.master._log.error('Error sending proof-of-channel to peer: ', err) 482 | ) 483 | 484 | this.master._log.debug( 485 | `Successfully opened channel ${channelId} for ${this.master._format( 486 | value, 487 | 'base' 488 | )}` 489 | ) 490 | 491 | return signedChannel 492 | } 493 | 494 | /** 495 | * Deposit the given amount in base units (wei for ETH) to the given channel 496 | * - Must always be called from a task in the outgoing queue 497 | */ 498 | private async depositToChannel( 499 | channel: PaymentChannel, 500 | value: BigNumber, 501 | authorize: (fee: BigNumber) => Promise = () => Promise.resolve() 502 | ): Promise { 503 | // To simultaneously send payment channel claims, create a "side queue" only for the duration of the deposit 504 | this.depositQueue = new ReducerQueue(channel) 505 | 506 | // In case there were pending tasks to send claims in the main queue, try to send a claim 507 | this.depositQueue 508 | .add(this.createClaim.bind(this)) 509 | .catch(err => 510 | this.master._log.error('Error queuing task to create new claim:', err) 511 | ) 512 | 513 | const totalNewValue = channel.value.plus(value) 514 | const channelId = channel.channelId 515 | 516 | try { 517 | const tokenContract = this.master._tokenContract 518 | const txObj = !tokenContract 519 | ? { 520 | methodName: 'deposit', 521 | params: [channelId], 522 | contract: await this.master._contract, 523 | gasPrice: await this.master._getGasPrice(), 524 | value 525 | } 526 | : { 527 | methodName: 'deposit', 528 | params: [channelId, value.toString()], 529 | contract: await this.master._contract, 530 | gasPrice: await this.master._getGasPrice() 531 | } 532 | 533 | const { 534 | sendTransaction, 535 | txFee 536 | }: { 537 | sendTransaction: () => Promise 538 | txFee: BigNumber 539 | } = await this.checkIfApproved(value).then(async requiresApproval => { 540 | /** For ETH or unlocked ERC-20s, accurately estimate the gas */ 541 | if (!requiresApproval) { 542 | const { txFee, sendTransaction } = await prepareTransaction(txObj) 543 | 544 | const hasSufficientBalance = await this.checkIfSufficientBalance( 545 | value, 546 | txFee 547 | ) 548 | if (!hasSufficientBalance) { 549 | throw new Error('ETH balance is insufficient for deposit') 550 | } 551 | 552 | await authorize(txFee) 553 | return { txFee, sendTransaction } 554 | } else { 555 | /** 556 | * If approving ERC-20 token transfers is required, overestimate the gas, 557 | * using a combined upperbound for both transactions 558 | */ 559 | const gasLimit = APPROVE_AND_DEPOSIT_GAS_LIMIT 560 | 561 | const gasPrice = await this.master._getGasPrice() 562 | const txFee = gasLimit.times(gasPrice) 563 | 564 | const hasSufficientBalance = await this.checkIfSufficientBalance( 565 | value, 566 | txFee 567 | ) 568 | if (!hasSufficientBalance) { 569 | throw new Error( 570 | 'ERC-20 balance or ETH balance is insufficient for deposit' 571 | ) 572 | } 573 | 574 | await authorize(txFee) 575 | 576 | const remainingGasLimit = await this.secureAllowance(gasLimit) 577 | 578 | return prepareTransaction({ 579 | ...txObj, 580 | gasLimit: remainingGasLimit 581 | }) 582 | } 583 | }) 584 | 585 | this.master._log.debug( 586 | `Depositing ${this.master._format( 587 | value, 588 | 'base' 589 | )} to channel for fee of ${this.master._format(txFee, 'base')}` 590 | ) 591 | 592 | const receipt = await this.master._queueTransaction(sendTransaction) 593 | 594 | const didDeposit = hasEvent(receipt, 'DidDeposit') 595 | if (!didDeposit) { 596 | throw new Error(`Failed to deposit to channel ${channelId}`) 597 | } 598 | 599 | const updatedChannel = { 600 | ...channel, 601 | value: totalNewValue 602 | } 603 | 604 | this.master._log.debug('Informing peer of channel top-up') 605 | this.sendMessage({ 606 | type: TYPE_MESSAGE, 607 | requestId: await generateBtpRequestId(), 608 | data: { 609 | protocolData: [ 610 | { 611 | protocolName: 'channelDeposit', 612 | contentType: MIME_APPLICATION_OCTET_STREAM, 613 | data: Buffer.alloc(0) 614 | } 615 | ] 616 | } 617 | }).catch(err => { 618 | this.master._log.error('Error informing peer of channel deposit:', err) 619 | }) 620 | 621 | this.master._log.debug( 622 | `Successfully deposited ${this.master._format( 623 | value, 624 | 'base' 625 | )} to channel ${channelId} for total value of ${this.master._format( 626 | totalNewValue, 627 | 'base' 628 | )}` 629 | ) 630 | 631 | const bestClaim = this.depositQueue.clear() 632 | delete this.depositQueue // Don't await the promise so no new tasks are added to the queue 633 | 634 | // Merge the updated channel state with any claims sent in the side queue 635 | const forkedState = await bestClaim 636 | return forkedState 637 | ? { 638 | ...updatedChannel, 639 | signature: forkedState.signature, 640 | spent: forkedState.spent 641 | } 642 | : updatedChannel 643 | } catch (err) { 644 | this.master._log.error(`Failed to deposit to channel:`, err) 645 | 646 | // Since there's no updated state from the deposit, just use the state from the side queue 647 | const bestClaim = this.depositQueue!.clear() 648 | delete this.depositQueue // Don't await the promise so no new tasks are added to the queue 649 | 650 | return bestClaim 651 | } 652 | } 653 | 654 | /** 655 | * Check that the Ethereum account has sufficient ether and token balances to complete the transaction 656 | * @param value Amount to be sent to contract, in either ETH (units of wei) or the configured ERC-20 (denominated in its base unit) 657 | * @param fee Transaction fee in ETH, always denominated in units of wei 658 | */ 659 | async checkIfSufficientBalance( 660 | value: BigNumber, 661 | fee: BigNumber 662 | ): Promise { 663 | const etherBalance = new BigNumber( 664 | (await this.master._wallet.getBalance()).toString() 665 | ) 666 | 667 | if (!this.master._tokenContract) { 668 | return etherBalance.isGreaterThanOrEqualTo(value.plus(fee)) 669 | } else { 670 | const tokenBalance = new BigNumber( 671 | (await this.master._tokenContract.functions.balanceOf( 672 | this.master._wallet.address 673 | )).toString() 674 | ) 675 | 676 | return ( 677 | tokenBalance.isGreaterThanOrEqualTo(value) && 678 | etherBalance.isGreaterThanOrEqualTo(fee) 679 | ) 680 | } 681 | } 682 | 683 | /** 684 | * Check if the Machinomy contract is approved to send the configured ERC-20 token 685 | * from the configured Ethereum account 686 | * @param minimumAllowance Minimum amount the contract must be approved to transfer into it 687 | */ 688 | async checkIfApproved(minimumAllowance: BigNumber): Promise { 689 | if (!this.master._tokenContract) { 690 | return false 691 | } 692 | 693 | const ownerAddress = this.master._wallet.address 694 | const spenderAddress = (await this.master._contract).address 695 | 696 | const allowance = await this.master._tokenContract.functions.allowance( 697 | ownerAddress, 698 | spenderAddress 699 | ) 700 | 701 | return allowance.lt(minimumAllowance.toString()) 702 | } 703 | 704 | /** 705 | * Authorize the Machinomy token contract to send the configured ERC-20 token from the configured Ethereum account 706 | * - Only needs to happen once, since we're authorizing for the largest possible value 707 | * - Secure since all transfers to the Machinomy contract still require transactions from user 708 | * @param gasLimit Total gas limit for the approve transaction and subsequent operations 709 | * @returns Remaining gas available to spend from the given gas limit, after the approve transaction 710 | */ 711 | async secureAllowance(gasLimit: BigNumber): Promise { 712 | if (!this.master._tokenContract) { 713 | return gasLimit 714 | } 715 | 716 | const spenderAddress = (await this.master._contract).address 717 | 718 | const { sendTransaction } = await prepareTransaction({ 719 | methodName: 'approve', 720 | params: [spenderAddress, ethers.constants.MaxUint256], 721 | contract: this.master._tokenContract, 722 | gasPrice: await this.master._getGasPrice(), 723 | gasLimit 724 | }) 725 | 726 | this.master._log.debug( 727 | `Unlocking transfers for ${this.master._assetCode} for account ${ 728 | this.account.ethereumAddress 729 | }` 730 | ) 731 | 732 | const receipt = await this.master._queueTransaction(sendTransaction) 733 | 734 | const didApprove = hasEvent(receipt, 'Approval') 735 | if (!didApprove) { 736 | throw new Error( 737 | `Failed to unlock transfers for ${this.master._assetCode}` 738 | ) 739 | } 740 | 741 | return receipt.gasUsed 742 | ? gasLimit.minus(receipt.gasUsed.toString()) 743 | : new BigNumber(0) 744 | } 745 | 746 | /** 747 | * Send a settlement/payment channel claim to the peer 748 | * 749 | * If an amount is specified (e.g. role=client), try to send that amount, plus the amount of 750 | * settlements that have previously failed. 751 | * 752 | * If no amount is specified (e.g. role=server), settle such that 0 is owed to the peer. 753 | */ 754 | async sendMoney(amount?: string) { 755 | const amountToSend = amount || BigNumber.max(0, this.account.payableBalance) 756 | this.account.payoutAmount = this.account.payoutAmount.plus(amountToSend) 757 | 758 | this.depositQueue 759 | ? await this.depositQueue.add(this.createClaim.bind(this)) 760 | : await this.account.outgoing.add(this.createClaim.bind(this)) 761 | } 762 | 763 | async createClaim( 764 | cachedChannel: PaymentChannel | undefined 765 | ): Promise { 766 | this.autoFundOutgoingChannel().catch(err => 767 | this.master._log.error( 768 | 'Error attempting to auto fund outgoing channel: ', 769 | err 770 | ) 771 | ) 772 | 773 | const settlementBudget = this.master._convertToBaseUnit( 774 | this.account.payoutAmount 775 | ) 776 | if (settlementBudget.isLessThanOrEqualTo(0)) { 777 | return cachedChannel 778 | } 779 | 780 | if (!cachedChannel) { 781 | this.master._log.debug(`Cannot send claim: no channel is open`) 782 | return cachedChannel 783 | } 784 | 785 | // Used to ensure the claim increment is always > 0 786 | if (!remainingInChannel(cachedChannel).isGreaterThan(0)) { 787 | this.master._log.debug( 788 | `Cannot send claim to: no remaining funds in outgoing channel` 789 | ) 790 | return cachedChannel 791 | } 792 | 793 | // Ensures that the increment is greater than the previous claim 794 | // Since budget and remaining in channel must be positive, claim increment should always be positive 795 | const claimIncrement = BigNumber.min( 796 | remainingInChannel(cachedChannel), 797 | settlementBudget 798 | ) 799 | 800 | this.master._log.info( 801 | `Settlement attempt triggered with ${this.account.accountName}` 802 | ) 803 | 804 | // Total value of new claim: value of old best claim + increment of new claim 805 | const value = spentFromChannel(cachedChannel).plus(claimIncrement) 806 | 807 | const updatedChannel = this.signClaim({ 808 | ...cachedChannel, 809 | spent: value 810 | }) 811 | 812 | this.master._log.debug( 813 | `Sending claim for total of ${this.master._format( 814 | value, 815 | 'base' 816 | )}, incremented by ${this.master._format(claimIncrement, 'base')}` 817 | ) 818 | 819 | // Send paychan claim to client, don't await a response 820 | this.sendClaim(updatedChannel).catch(err => 821 | // If they reject the claim, it's not particularly actionable 822 | this.master._log.debug( 823 | `Error while sending claim to peer: ${err.message}` 824 | ) 825 | ) 826 | 827 | const claimIncrementGwei = this.master._convertFromBaseUnit(claimIncrement) 828 | 829 | this.account.payableBalance = this.account.payableBalance.minus( 830 | claimIncrementGwei 831 | ) 832 | 833 | this.account.payoutAmount = BigNumber.min( 834 | 0, 835 | this.account.payoutAmount.minus(claimIncrementGwei) 836 | ) 837 | 838 | return updatedChannel 839 | } 840 | 841 | signClaim(channel: PaymentChannel): ClaimablePaymentChannel { 842 | const secp256k1 = this.master._secp256k1! 843 | 844 | const { 845 | signature, 846 | recoveryId 847 | } = secp256k1.signMessageHashRecoverableCompact( 848 | hexToBuffer(this.master._wallet.privateKey), 849 | createPaymentDigest( 850 | channel.contractAddress, 851 | channel.channelId, 852 | channel.spent.toString(), 853 | channel.tokenContract 854 | ) 855 | ) 856 | 857 | /** 858 | * Ethereum requires RLP encoding, not supported by bitcoin-ts: 859 | * - https://ethereum.stackexchange.com/questions/64380/understanding-ethereum-signatures 860 | * - https://docs.ethers.io/ethers.js/html/api-utils.html#signatures 861 | */ 862 | const v = recoveryId === 1 ? '1c' : '1b' 863 | const flatSignature = '0x' + Buffer.from(signature).toString('hex') + v 864 | 865 | return { 866 | ...channel, 867 | signature: flatSignature 868 | } 869 | } 870 | 871 | async sendClaim({ 872 | channelId, 873 | signature, 874 | spent, 875 | contractAddress, 876 | tokenContract 877 | }: PaymentChannel) { 878 | const claim = { 879 | channelId, 880 | signature, 881 | value: spent.toString(), 882 | contractAddress, 883 | tokenContract 884 | } 885 | 886 | return this.sendMessage({ 887 | type: TYPE_MESSAGE, 888 | requestId: await generateBtpRequestId(), 889 | data: { 890 | protocolData: [ 891 | { 892 | protocolName: 'machinomy', 893 | contentType: MIME_APPLICATION_JSON, 894 | data: Buffer.from(JSON.stringify(claim)) 895 | } 896 | ] 897 | } 898 | }) 899 | } 900 | 901 | async handleData(message: BtpPacket): Promise { 902 | // Link the given Ethereum address & inform counterparty what address this wants to be paid at 903 | const info = getBtpSubprotocol(message, 'info') 904 | if (info) { 905 | this.linkEthereumAddress(info) 906 | 907 | return [ 908 | { 909 | protocolName: 'info', 910 | contentType: MIME_APPLICATION_JSON, 911 | data: Buffer.from( 912 | JSON.stringify({ 913 | ethereumAddress: this.master._wallet.address 914 | }) 915 | ) 916 | } 917 | ] 918 | } 919 | 920 | // If the peer says they may have deposited, check for a deposit 921 | const channelDeposit = getBtpSubprotocol(message, 'channelDeposit') 922 | if (channelDeposit) { 923 | const cachedChannel = this.account.incoming.state 924 | if (!cachedChannel) { 925 | return [] 926 | } 927 | 928 | // Don't block the queue while fetching channel state, since that slows down claim processing 929 | this.master._log.debug('Checking if peer has deposited to channel') 930 | const checkForDeposit = async (attempts = 0): Promise => { 931 | if (attempts > 20) { 932 | return this.master._log.debug( 933 | `Failed to confirm incoming deposit after several attempts` 934 | ) 935 | } 936 | 937 | const updatedChannel = await updateChannel( 938 | await this.master._contract, 939 | cachedChannel 940 | ) 941 | 942 | if (!updatedChannel) { 943 | return 944 | } 945 | 946 | const wasDeposit = updatedChannel.value.isGreaterThan( 947 | cachedChannel.value 948 | ) 949 | if (!wasDeposit) { 950 | await delay(250) 951 | return checkForDeposit(attempts + 1) 952 | } 953 | 954 | /** 955 | * Rectify the two forked states 956 | * - It's *possible* that between the fetching the channel state and when the task below is queued, 957 | * we've already closed the old channel and the peer has linked a new channel with nearly identical 958 | * properties (same channelId, sender, receiver, settlingPeriod, etc) 959 | * - The peer could try to profit off this by opening the 2nd channel with a lesser value than the first 960 | * (so we think the value is higher than it really is), but `didChannelClose` will catch that, because 961 | * it knows the channel value can never decrease 962 | */ 963 | await this.account.incoming.add(async newCachedChannel => { 964 | if ( 965 | !newCachedChannel || 966 | didChannelClose(cachedChannel, newCachedChannel) 967 | ) { 968 | this.master._log.debug( 969 | `Incoming channel was closed while confirming deposit: reverting to old state` 970 | ) 971 | 972 | // Revert to old state 973 | return newCachedChannel 974 | } 975 | 976 | // Only update the state with the new value of the channel 977 | this.master._log.debug('Confirmed deposit to incoming channel') 978 | return { 979 | ...newCachedChannel, 980 | /** 981 | * Value of newCachedChannel should *always* be >= the value of the fetched channel, 982 | * since `didChannelClose` ensures that the channel value didn't decrease between 983 | * the two cached states 984 | */ 985 | value: BigNumber.max(updatedChannel.value, newCachedChannel.value) 986 | } 987 | }) 988 | } 989 | 990 | await checkForDeposit().catch(err => { 991 | this.master._log.error('Error confirming incoming deposit:', err) 992 | }) 993 | 994 | return [] 995 | } 996 | 997 | // If the peer requests to close a channel, try to close it, if it's profitable 998 | const requestClose = getBtpSubprotocol(message, 'requestClose') 999 | if (requestClose) { 1000 | this.master._log.info( 1001 | `Channel close requested for account ${this.account.accountName}` 1002 | ) 1003 | 1004 | await this.claimIfProfitable(false, () => Promise.resolve()).catch(err => 1005 | this.master._log.error( 1006 | `Error attempting to claim channel: ${err.message}` 1007 | ) 1008 | ) 1009 | 1010 | return [] 1011 | } 1012 | 1013 | const machinomy = getBtpSubprotocol(message, 'machinomy') 1014 | if (machinomy) { 1015 | this.master._log.debug( 1016 | `Handling Machinomy claim for account ${this.account.accountName}` 1017 | ) 1018 | 1019 | // If JSON is semantically invalid, this will throw 1020 | const claim = JSON.parse(machinomy.data.toString()) 1021 | 1022 | const hasValidSchema = (o: any): o is SerializedClaim => 1023 | typeof o.value === 'string' && 1024 | typeof o.channelId === 'string' && 1025 | typeof o.signature === 'string' && 1026 | typeof o.contractAddress === 'string' && 1027 | ['string', 'undefined'].includes(typeof o.tokenContract) 1028 | if (!hasValidSchema(claim)) { 1029 | this.master._log.debug('Invalid claim: schema is malformed') 1030 | return [] 1031 | } 1032 | 1033 | await this.account.incoming.add(this.validateClaim(claim)).catch(err => 1034 | // Don't expose internal errors, since it may not have been intentionally thrown 1035 | this.master._log.error('Failed to validate claim: ', err) 1036 | ) 1037 | 1038 | /** 1039 | * Attempt to fund an outgoing channel, if the incoming claim is accepted, 1040 | * the incoming channel has sufficient value, and no existing outgoing 1041 | * channel already exists 1042 | */ 1043 | this.autoFundOutgoingChannel().catch(err => 1044 | this.master._log.error( 1045 | 'Error attempting to auto fund outgoing channel: ', 1046 | err 1047 | ) 1048 | ) 1049 | 1050 | return [] 1051 | } 1052 | 1053 | // Handle incoming ILP PREPARE packets from peer 1054 | // plugin-btp handles correlating the response packets for the dataHandler 1055 | const ilp = getBtpSubprotocol(message, 'ilp') 1056 | if (ilp) { 1057 | try { 1058 | const { amount } = deserializeIlpPrepare(ilp.data) 1059 | const amountBN = new BigNumber(amount) 1060 | 1061 | if (amountBN.gt(this.master._maxPacketAmount)) { 1062 | throw new Errors.AmountTooLargeError('Packet size is too large.', { 1063 | receivedAmount: amount, 1064 | maximumAmount: this.master._maxPacketAmount.toString() 1065 | }) 1066 | } 1067 | 1068 | const newBalance = this.account.receivableBalance.plus(amount) 1069 | if (newBalance.isGreaterThan(this.master._maxBalance)) { 1070 | this.master._log.debug( 1071 | `Cannot forward PREPARE: cannot debit ${this.master._format( 1072 | amount, 1073 | 'account' 1074 | )}: proposed balance of ${this.master._format( 1075 | newBalance, 1076 | 'account' 1077 | )} exceeds maximum of ${this.master._format( 1078 | this.master._maxBalance, 1079 | 'account' 1080 | )}` 1081 | ) 1082 | throw new Errors.InsufficientLiquidityError( 1083 | 'Exceeded maximum balance' 1084 | ) 1085 | } 1086 | 1087 | this.master._log.debug( 1088 | `Forwarding PREPARE: Debited ${this.master._format( 1089 | amount, 1090 | 'account' 1091 | )}, new balance is ${this.master._format(newBalance, 'account')}` 1092 | ) 1093 | this.account.receivableBalance = newBalance 1094 | 1095 | const response = await this.dataHandler(ilp.data) 1096 | const reply = deserializeIlpReply(response) 1097 | 1098 | if (isReject(reply)) { 1099 | this.master._log.debug( 1100 | `Credited ${this.master._format( 1101 | amount, 1102 | 'account' 1103 | )} in response to REJECT` 1104 | ) 1105 | this.account.receivableBalance = this.account.receivableBalance.minus( 1106 | amount 1107 | ) 1108 | } else if (isFulfill(reply)) { 1109 | this.master._log.debug( 1110 | `Received FULFILL in response to forwarded PREPARE` 1111 | ) 1112 | } 1113 | 1114 | return [ 1115 | { 1116 | protocolName: 'ilp', 1117 | contentType: MIME_APPLICATION_OCTET_STREAM, 1118 | data: response 1119 | } 1120 | ] 1121 | } catch (err) { 1122 | return [ 1123 | { 1124 | protocolName: 'ilp', 1125 | contentType: MIME_APPLICATION_OCTET_STREAM, 1126 | data: errorToReject('', err) 1127 | } 1128 | ] 1129 | } 1130 | } 1131 | 1132 | return [] 1133 | } 1134 | 1135 | /** 1136 | * Given an unvalidated claim and the current channel state, return either: 1137 | * (1) the previous state, or 1138 | * (2) new state updated with the valid claim 1139 | * 1140 | * TODO: Add test cases for sending claims with varying lengths of hex strings, with and without 0x prefix 1141 | * (current approach of checking string equality likely circumvents those issues) 1142 | */ 1143 | validateClaim = (claim: SerializedClaim) => async ( 1144 | cachedChannel: ClaimablePaymentChannel | undefined, 1145 | attempts = 0 1146 | ): Promise => { 1147 | // To reduce latency, only fetch channel state if no channel was linked, or there was a possible on-chain deposit 1148 | const shouldFetchChannel = 1149 | !cachedChannel || 1150 | new BigNumber(claim.value).isGreaterThan(cachedChannel.value) 1151 | const updatedChannel = shouldFetchChannel 1152 | ? await fetchChannelById(await this.master._contract, claim.channelId) 1153 | : cachedChannel 1154 | 1155 | // Perform checks to link a new channel 1156 | if (!cachedChannel) { 1157 | if (!updatedChannel) { 1158 | if (attempts > 20) { 1159 | this.master._log.debug( 1160 | `Invalid claim: channel ${ 1161 | claim.channelId 1162 | } doesn't exist, despite several attempts to refresh channel state` 1163 | ) 1164 | return cachedChannel 1165 | } 1166 | 1167 | await delay(250) 1168 | return this.validateClaim(claim)(cachedChannel, attempts + 1) 1169 | } 1170 | 1171 | // Ensure the channel is to this address 1172 | // (only check for new channels, not per claim, in case the server restarts and changes config) 1173 | const amReceiver = 1174 | updatedChannel.receiver.toLowerCase() === 1175 | this.master._wallet.address.toLowerCase() 1176 | if (!amReceiver) { 1177 | this.master._log.debug( 1178 | `Invalid claim: the recipient for new channel ${ 1179 | claim.channelId 1180 | } is not ${this.master._wallet.address}` 1181 | ) 1182 | return cachedChannel 1183 | } 1184 | 1185 | // Confirm the settling period for the channel is above the minimum 1186 | const isAboveMinDisputePeriod = updatedChannel.disputePeriod.isGreaterThanOrEqualTo( 1187 | this.master._minIncomingDisputePeriod 1188 | ) 1189 | if (!isAboveMinDisputePeriod) { 1190 | this.master._log.debug( 1191 | `Invalid claim: new channel ${ 1192 | claim.channelId 1193 | } has dispute period of ${ 1194 | updatedChannel.disputePeriod 1195 | } blocks, below floor of ${ 1196 | this.master._minIncomingDisputePeriod 1197 | } blocks` 1198 | ) 1199 | return cachedChannel 1200 | } 1201 | } 1202 | // An existing claim is linked, so validate this against the previous claim 1203 | else { 1204 | if (!updatedChannel) { 1205 | this.master._log.error(`Invalid claim: channel is unexpectedly closed`) 1206 | return cachedChannel 1207 | } 1208 | 1209 | // `updatedChannel` is fetched using the ID in the claim, so compare against previously linked channelId 1210 | const wrongChannel = 1211 | claim.channelId.toLowerCase() !== cachedChannel.channelId.toLowerCase() 1212 | if (wrongChannel) { 1213 | this.master._log.debug( 1214 | 'Invalid claim: channel is not the previously linked channel' 1215 | ) 1216 | return cachedChannel 1217 | } 1218 | } 1219 | 1220 | /** 1221 | * Ensure the claim is positive or zero 1222 | * - Allow claims of 0 (essentially a proof of channel ownership without sending any money) 1223 | */ 1224 | const hasNegativeValue = new BigNumber(claim.value).isNegative() 1225 | if (hasNegativeValue) { 1226 | this.master._log.error(`Invalid claim: value is negative`) 1227 | return cachedChannel 1228 | } 1229 | 1230 | const wrongContract = 1231 | claim.contractAddress.toLowerCase() !== 1232 | (await this.master._contract).address.toLowerCase() 1233 | if (wrongContract) { 1234 | this.master._log.debug( 1235 | 'Invalid claim: sender is using a different contract or network (e.g. testnet instead of mainnet)' 1236 | ) 1237 | return cachedChannel 1238 | } 1239 | 1240 | // If using ERC-20 tokens, ensure the claim and channel are for the correct token 1241 | if (this.master._tokenContract) { 1242 | // Since channels are fetched from TokenUnidirectional, tokenContract is defined 1243 | const channelUsesWrongToken = 1244 | updatedChannel.tokenContract!.toLowerCase() !== 1245 | this.master._tokenContract.address.toLowerCase() 1246 | if (channelUsesWrongToken) { 1247 | this.master._log.debug( 1248 | 'Invalid claim: channel is for the wrong ERC-20 token' 1249 | ) 1250 | return cachedChannel 1251 | } 1252 | 1253 | const claimUsesWrongToken = 1254 | !claim.tokenContract || 1255 | claim.tokenContract.toLowerCase() !== 1256 | this.master._tokenContract.address.toLowerCase() 1257 | if (claimUsesWrongToken) { 1258 | this.master._log.debug('Invalid claim: claim is for wrong ERC-20 token') 1259 | return cachedChannel 1260 | } 1261 | } 1262 | // If using ETH, ensure the claim is not for a token 1263 | else { 1264 | if (claim.tokenContract) { 1265 | this.master._log.debug( 1266 | 'Invalid claim: claim is for ERC-20 token, not ETH' 1267 | ) 1268 | return cachedChannel 1269 | } 1270 | } 1271 | 1272 | const isSigned = isValidClaimSignature( 1273 | this.master._secp256k1!, 1274 | claim, 1275 | updatedChannel.sender 1276 | ) 1277 | if (!isSigned) { 1278 | this.master._log.debug('Invalid claim: signature is invalid') 1279 | return cachedChannel 1280 | } 1281 | 1282 | const sufficientChannelValue = updatedChannel.value.isGreaterThanOrEqualTo( 1283 | claim.value 1284 | ) 1285 | if (!sufficientChannelValue) { 1286 | if (attempts > 20) { 1287 | this.master._log.debug( 1288 | `Invalid claim: value of ${this.master._format( 1289 | claim.value, 1290 | 'account' 1291 | )} is above value of channel, despite several attempts to refresh channel state` 1292 | ) 1293 | return cachedChannel 1294 | } 1295 | 1296 | await delay(250) 1297 | return this.validateClaim(claim)(cachedChannel, attempts + 1) 1298 | } 1299 | 1300 | // Finally, if the claim is new, ensure it isn't already linked to another account 1301 | if (!cachedChannel) { 1302 | /** 1303 | * Ensure no channel can be linked to multiple accounts 1304 | * - Each channel key is a mapping of channelId -> accountName 1305 | * - Since the store is cached in JS, no race condition since get & set are in the same closure 1306 | * with no async operations in between 1307 | * - When fetching the initial channel state, Ethers throws if the channelId isn't the precise length, 1308 | * prefixed with 0x, and lowercased, which *should* check against linking two channelIds that fail 1309 | * string equality but map to the same channel on different accounts 1310 | * (TODO Add tests for this!) 1311 | */ 1312 | const channelKey = `${claim.channelId}:incoming-channel` 1313 | await this.master._store.load(channelKey) 1314 | const linkedAccount = this.master._store.get(channelKey) 1315 | if (typeof linkedAccount === 'string') { 1316 | this.master._log.debug( 1317 | `Invalid claim: channel ${ 1318 | claim.channelId 1319 | } is already linked to a different account` 1320 | ) 1321 | return cachedChannel 1322 | } 1323 | 1324 | this.master._store.set(channelKey, this.account.accountName) 1325 | this.master._log.debug( 1326 | `Incoming channel ${claim.channelId} is now linked to account ${ 1327 | this.account.accountName 1328 | }` 1329 | ) 1330 | } 1331 | 1332 | // Cap the value of the credited claim by the total value of the channel 1333 | const claimIncrement = BigNumber.min( 1334 | claim.value, 1335 | updatedChannel.value 1336 | ).minus(cachedChannel ? cachedChannel.spent : 0) 1337 | 1338 | // Claims for zero are okay, so long as it's new channel (essentially a "proof of channel") 1339 | const isBestClaim = claimIncrement.gt(0) 1340 | if (!isBestClaim && cachedChannel) { 1341 | this.master._log.debug( 1342 | `Invalid claim: value of ${this.master._format( 1343 | claim.value, 1344 | 'base' 1345 | )} is less than previous claim for ${this.master._format( 1346 | updatedChannel.spent, 1347 | 'base' 1348 | )}` 1349 | ) 1350 | return cachedChannel 1351 | } 1352 | 1353 | // Only perform balance operations if the claim increment is positive 1354 | if (isBestClaim) { 1355 | const amount = this.master._convertFromBaseUnit(claimIncrement) 1356 | 1357 | this.account.receivableBalance = this.account.receivableBalance.minus( 1358 | amount 1359 | ) 1360 | 1361 | await this.moneyHandler(amount.toString()) 1362 | } 1363 | 1364 | this.master._log.debug( 1365 | `Accepted incoming claim from account ${ 1366 | this.account.accountName 1367 | } for ${this.master._format(claimIncrement, 'base')}` 1368 | ) 1369 | 1370 | // Start the channel watcher if it wasn't already running 1371 | if (!this.watcher) { 1372 | this.watcher = this.startChannelWatcher() 1373 | } 1374 | 1375 | return { 1376 | ...updatedChannel, 1377 | tokenContract: claim.tokenContract, 1378 | channelId: claim.channelId, 1379 | contractAddress: claim.contractAddress, 1380 | signature: claim.signature, 1381 | spent: new BigNumber(claim.value) 1382 | } 1383 | } 1384 | 1385 | // Handle the response from a forwarded ILP PREPARE 1386 | handlePrepareResponse(prepare: IlpPrepare, reply: IlpReply) { 1387 | if (isFulfill(reply)) { 1388 | // Update balance to reflect that we owe them the amount of the FULFILL 1389 | const amount = new BigNumber(prepare.amount) 1390 | 1391 | this.master._log.debug( 1392 | `Received a FULFILL in response to forwarded PREPARE: credited ${this.master._format( 1393 | amount, 1394 | 'account' 1395 | )}` 1396 | ) 1397 | this.account.payableBalance = this.account.payableBalance.plus(amount) 1398 | 1399 | this.sendMoney().catch((err: Error) => 1400 | this.master._log.debug('Error queueing outgoing settlement: ', err) 1401 | ) 1402 | } else if (isReject(reply)) { 1403 | this.master._log.debug( 1404 | `Received a ${reply.code} REJECT in response to the forwarded PREPARE` 1405 | ) 1406 | 1407 | // On T04s, send the most recent claim to the peer in case they didn't get it 1408 | const outgoingChannel = this.account.outgoing.state 1409 | if (reply.code === 'T04' && hasClaim(outgoingChannel)) { 1410 | this.sendClaim(outgoingChannel).catch((err: Error) => 1411 | this.master._log.debug( 1412 | 'Failed to send latest claim to peer on T04 error:', 1413 | err 1414 | ) 1415 | ) 1416 | } 1417 | } 1418 | } 1419 | 1420 | private startChannelWatcher() { 1421 | const timer: NodeJS.Timeout = setInterval(async () => { 1422 | const cachedChannel = this.account.incoming.state 1423 | // No channel & claim are linked: stop the channel watcher 1424 | if (!cachedChannel) { 1425 | this.watcher = null 1426 | clearInterval(timer) 1427 | return 1428 | } 1429 | 1430 | const updatedChannel = await updateChannel( 1431 | await this.master._contract, 1432 | cachedChannel 1433 | ) 1434 | 1435 | // If the channel is closed or closing, then add a task to the queue 1436 | // that will update the channel state (for real) and claim if it's closing 1437 | if (!updatedChannel || isDisputed(updatedChannel)) { 1438 | this.claimIfProfitable(true).catch((err: Error) => { 1439 | this.master._log.debug( 1440 | `Error attempting to claim channel or confirm channel was closed: ${ 1441 | err.message 1442 | }` 1443 | ) 1444 | }) 1445 | } 1446 | }, this.master._channelWatcherInterval.toNumber()) 1447 | 1448 | return timer 1449 | } 1450 | 1451 | claimIfProfitable( 1452 | requireDisputed = false, 1453 | authorize?: (channel: PaymentChannel, fee: BigNumber) => Promise 1454 | ) { 1455 | return this.account.incoming.add(async cachedChannel => { 1456 | if (!cachedChannel) { 1457 | return cachedChannel 1458 | } 1459 | 1460 | const updatedChannel = await updateChannel( 1461 | await this.master._contract, 1462 | cachedChannel 1463 | ) 1464 | if (!updatedChannel) { 1465 | this.master._log.error( 1466 | `Cannot claim channel ${cachedChannel.channelId} with ${ 1467 | this.account.accountName 1468 | }: linked channel is unexpectedly closed` 1469 | ) 1470 | return updatedChannel 1471 | } 1472 | 1473 | const { channelId, spent, signature } = updatedChannel 1474 | 1475 | if (requireDisputed && !isDisputed(updatedChannel)) { 1476 | this.master._log.debug( 1477 | `Won't claim channel ${updatedChannel.channelId} with ${ 1478 | this.account.accountName 1479 | }: channel is not disputed` 1480 | ) 1481 | return updatedChannel 1482 | } 1483 | 1484 | this.master._log.debug( 1485 | `Attempting to claim channel ${channelId} for ${this.master._format( 1486 | updatedChannel.spent, 1487 | 'base' 1488 | )}` 1489 | ) 1490 | 1491 | const { sendTransaction, txFee } = await prepareTransaction({ 1492 | methodName: 'claim', 1493 | params: [channelId, spent.toString(), signature], 1494 | contract: await this.master._contract, 1495 | gasPrice: await this.master._getGasPrice() 1496 | }) 1497 | 1498 | // Check to verify it's profitable first 1499 | if (authorize) { 1500 | const isAuthorized = await authorize(updatedChannel, txFee) 1501 | .then(() => true) 1502 | .catch(() => false) 1503 | 1504 | if (!isAuthorized) { 1505 | return updatedChannel 1506 | } 1507 | } else if (txFee.isGreaterThanOrEqualTo(spent)) { 1508 | this.master._log.debug( 1509 | `Not profitable to claim channel ${channelId} with ${ 1510 | this.account.accountName 1511 | }: fee of ${this.master._format( 1512 | txFee, 1513 | 'base' 1514 | )} is greater than value of ${this.master._format(spent, 'base')}` 1515 | ) 1516 | 1517 | return updatedChannel 1518 | } 1519 | 1520 | const receipt = await this.master 1521 | ._queueTransaction(sendTransaction) 1522 | .catch(err => { 1523 | this.master._log.error(`Failed to claim channel:`, err) 1524 | throw err 1525 | }) 1526 | 1527 | if (!hasEvent(receipt, 'DidClaim')) { 1528 | throw new Error(`Failed to claim channel ${channelId}`) 1529 | } 1530 | 1531 | this.master._log.debug( 1532 | `Successfully claimed incoming channel ${channelId} for ${this.master._format( 1533 | spent, 1534 | 'base' 1535 | )}` 1536 | ) 1537 | }, IncomingTaskPriority.ClaimChannel) 1538 | } 1539 | 1540 | // Request the peer to claim the outgoing channel 1541 | async requestClose() { 1542 | return this.account.outgoing.add(async cachedChannel => { 1543 | if (!cachedChannel) { 1544 | return 1545 | } 1546 | 1547 | try { 1548 | await this.sendMessage({ 1549 | requestId: await generateBtpRequestId(), 1550 | type: TYPE_MESSAGE, 1551 | data: { 1552 | protocolData: [ 1553 | { 1554 | protocolName: 'requestClose', 1555 | contentType: MIME_TEXT_PLAIN_UTF8, 1556 | data: Buffer.alloc(0) 1557 | } 1558 | ] 1559 | } 1560 | }) 1561 | 1562 | const checkForChannelClose = async () => 1563 | fetchChannelById(await this.master._contract, cachedChannel.channelId) 1564 | .then(channel => !channel) 1565 | .catch(() => false) 1566 | 1567 | const confirmChannelDidClose = (attempts = 0): Promise => 1568 | checkForChannelClose().then(async isClosed => { 1569 | if (isClosed) { 1570 | return true 1571 | } else if (attempts > 20) { 1572 | return false 1573 | } else { 1574 | await delay(250) 1575 | return confirmChannelDidClose(attempts + 1) 1576 | } 1577 | }) 1578 | 1579 | if (!(await confirmChannelDidClose())) { 1580 | this.master._log.error( 1581 | 'Unable to confirm if the peer closed our outgoing channel' 1582 | ) 1583 | return cachedChannel 1584 | } 1585 | 1586 | this.master._log.debug( 1587 | `Peer successfully closed our outgoing channel ${ 1588 | cachedChannel.channelId 1589 | }, returning at least ${this.master._format( 1590 | remainingInChannel(cachedChannel), 1591 | 'base' 1592 | )} of collateral` 1593 | ) 1594 | } catch (err) { 1595 | this.master._log.debug( 1596 | 'Error while requesting peer to claim channel:', 1597 | err 1598 | ) 1599 | 1600 | return cachedChannel 1601 | } 1602 | }) 1603 | } 1604 | 1605 | // From mini-accounts: invoked on a websocket close or error event 1606 | // From plugin-btp: invoked *only* when `disconnect` is called on plugin 1607 | async disconnect(): Promise { 1608 | // Only stop the channel watcher if the channels were attempted to be closed 1609 | if (this.watcher) { 1610 | clearInterval(this.watcher) 1611 | } 1612 | } 1613 | 1614 | unload(): void { 1615 | // Stop the channel watcher 1616 | if (this.watcher) { 1617 | clearInterval(this.watcher) 1618 | } 1619 | 1620 | // Remove event listeners that persisted updated channels/claims 1621 | this.account.outgoing.removeAllListeners() 1622 | this.account.incoming.removeAllListeners() 1623 | 1624 | // Remove account from store cache 1625 | this.master._store.unload(`${this.account.accountName}:account`) 1626 | 1627 | // Garbage collect the account at the top-level 1628 | this.master._accounts.delete(this.account.accountName) 1629 | } 1630 | } 1631 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { convert, eth, gwei, wei } from '@kava-labs/crypto-rate-utils' 2 | import BigNumber from 'bignumber.js' 3 | import { instantiateSecp256k1, Secp256k1 } from 'bitcoin-ts' 4 | import { registerProtocolNames } from 'btp-packet' 5 | import { ethers } from 'ethers' 6 | import { EventEmitter2 } from 'eventemitter2' 7 | import createLogger from 'ilp-logger' 8 | import { BtpPacket, IlpPluginBtpConstructorOptions } from 'ilp-plugin-btp' 9 | import EthereumAccount, { SerializedAccountData } from './account' 10 | import { EthereumClientPlugin } from './plugins/client' 11 | import { EthereumServerPlugin, MiniAccountsOpts } from './plugins/server' 12 | import { 13 | DataHandler, 14 | Logger, 15 | MoneyHandler, 16 | PluginInstance, 17 | PluginServices 18 | } from './types/plugin' 19 | import { 20 | ClaimablePaymentChannel, 21 | deserializePaymentChannel, 22 | getContract, 23 | PaymentChannel, 24 | remainingInChannel, 25 | spentFromChannel, 26 | updateChannel 27 | } from './utils/channel' 28 | import ReducerQueue from './utils/queue' 29 | import { MemoryStore, StoreWrapper } from './utils/store' 30 | import ERC20_ARTIFACT from 'openzeppelin-solidity/build/contracts/ERC20Detailed.json' 31 | 32 | registerProtocolNames(['machinomy', 'requestClose', 'channelDeposit']) 33 | 34 | // Almost never use exponential notation 35 | BigNumber.config({ EXPONENTIAL_AT: 1e9 }) 36 | 37 | const defaultDataHandler: DataHandler = () => { 38 | throw new Error('no request handler registered') 39 | } 40 | 41 | const defaultMoneyHandler: MoneyHandler = () => { 42 | throw new Error('no money handler registered') 43 | } 44 | 45 | export { 46 | EthereumAccount, 47 | remainingInChannel, 48 | spentFromChannel, 49 | PaymentChannel, 50 | ClaimablePaymentChannel 51 | } 52 | 53 | export interface EthereumPluginOpts 54 | extends MiniAccountsOpts, 55 | IlpPluginBtpConstructorOptions { 56 | /** 57 | * "client" to connect to a single peer or parent server that is explicity specified 58 | * "server" to enable multiple clients to openly connect to the plugin 59 | */ 60 | role: 'client' | 'server' 61 | 62 | /** 63 | * Private key of the Ethereum account used to send and receive 64 | * - Corresponds to the Ethereum address shared with peers 65 | */ 66 | ethereumPrivateKey?: string 67 | 68 | /** 69 | * Name of an Ethereum chain to create an Infura provider with Etherscan fallback, 70 | * or a custom Ethers Ethereum provider to query the network 71 | */ 72 | ethereumProvider?: 73 | | 'homestead' 74 | | 'kovan' 75 | | 'ropsten' 76 | | 'rinkeby' 77 | | ethers.providers.Provider 78 | 79 | /** 80 | * Ethers wallet used to sign transactions and provider to query the network 81 | * - Supercedes any private key or provider name config 82 | */ 83 | ethereumWallet?: ethers.Wallet 84 | 85 | /** Default amount to fund when opening a new channel or depositing to a depleted channel (gwei) */ 86 | outgoingChannelAmount?: BigNumber.Value 87 | 88 | /** 89 | * Minimum value of incoming channel in order to _automatically_ fund an outgoing channel to peer (gwei) 90 | * - Defaults to infinity, which never automatically opens a channel 91 | * - Will also automatically top-up outgoing channels to the outgoing amount when they 92 | * get depleted more than halfway 93 | */ 94 | minIncomingChannelAmount?: BigNumber.Value 95 | 96 | /** Minimum number of blocks for settlement period to accept a new incoming channel */ 97 | minIncomingDisputePeriod?: BigNumber.Value 98 | 99 | /** Number of blocks for dispute period used to create outgoing channels */ 100 | outgoingDisputePeriod?: BigNumber.Value 101 | 102 | /** Maximum allowed amount in gwei for incoming packets (gwei) */ 103 | maxPacketAmount?: BigNumber.Value 104 | 105 | /** Number of ms between runs of the channel watcher to check if a dispute was started */ 106 | channelWatcherInterval?: BigNumber.Value 107 | 108 | /** 109 | * Callback for fetching the currenct gas price 110 | * - Defaults to using the eth_estimateGas RPC with the connected Ethereum node 111 | */ 112 | getGasPrice?: () => Promise 113 | 114 | /** Custom address for the Machinomy contract used */ 115 | contractAddress?: string 116 | 117 | /** Address of the ERC-20 token contract to use for the token in the payment channel */ 118 | tokenAddress?: string 119 | } 120 | 121 | const OUTGOING_CHANNEL_AMOUNT_GWEI = convert(eth('0.05'), gwei()) 122 | 123 | const BLOCKS_PER_DAY = (24 * 60 * 60) / 15 // Assuming 1 block / 15 seconds 124 | const OUTGOING_DISPUTE_PERIOD_BLOCKS = 6 * BLOCKS_PER_DAY // 6 days 125 | const MIN_INCOMING_DISPUTE_PERIOD_BLOCKS = 3 * BLOCKS_PER_DAY // 3 days 126 | 127 | const CHANNEL_WATCHER_INTERVAL_MS = new BigNumber(60 * 1000) 128 | 129 | export default class EthereumPlugin extends EventEmitter2 130 | implements PluginInstance { 131 | static readonly version = 2 132 | readonly _plugin: EthereumClientPlugin | EthereumServerPlugin 133 | readonly _accounts = new Map() // accountName -> account 134 | readonly _wallet: ethers.Wallet 135 | readonly _getGasPrice: () => Promise // wei 136 | readonly _outgoingChannelAmount: BigNumber // wei 137 | readonly _minIncomingChannelAmount: BigNumber // wei 138 | readonly _outgoingDisputePeriod: BigNumber // # of blocks 139 | readonly _minIncomingDisputePeriod: BigNumber // # of blocks 140 | readonly _maxPacketAmount: BigNumber // gwei 141 | readonly _maxBalance: BigNumber // gwei 142 | readonly _channelWatcherInterval: BigNumber // ms 143 | readonly _store: StoreWrapper 144 | readonly _log: Logger 145 | _txPipeline: Promise = Promise.resolve() 146 | _secp256k1?: Secp256k1 147 | _contract: Promise 148 | _dataHandler: DataHandler = defaultDataHandler 149 | _moneyHandler: MoneyHandler = defaultMoneyHandler 150 | _tokenContract?: ethers.Contract 151 | 152 | /** 153 | * Orders of magnitude between the base unit, or smallest on-ledger denomination (e.g. wei) 154 | * and the unit used for accounting and in ILP packets (e.g. gwei) 155 | */ 156 | _accountingScale = 9 157 | 158 | /** 159 | * Orders of magnitude between the base unit, or smallest on-ledger denomination (e.g. wei), 160 | * and the unit of exchange (e.g. ether) 161 | */ 162 | _assetScale = 18 163 | 164 | /** Symbol of the asset exchanged */ 165 | _assetCode = 'ETH' 166 | 167 | constructor( 168 | { 169 | role = 'client', 170 | ethereumPrivateKey, 171 | ethereumProvider = 'homestead', 172 | ethereumWallet, 173 | getGasPrice, 174 | outgoingChannelAmount = OUTGOING_CHANNEL_AMOUNT_GWEI, 175 | minIncomingChannelAmount = Infinity, 176 | outgoingDisputePeriod = OUTGOING_DISPUTE_PERIOD_BLOCKS, 177 | minIncomingDisputePeriod = MIN_INCOMING_DISPUTE_PERIOD_BLOCKS, 178 | maxPacketAmount = Infinity, 179 | channelWatcherInterval = CHANNEL_WATCHER_INTERVAL_MS, 180 | contractAddress, 181 | tokenAddress, 182 | // All remaining params are passed to mini-accounts/plugin-btp 183 | ...opts 184 | }: EthereumPluginOpts, 185 | { log, store = new MemoryStore() }: PluginServices = {} 186 | ) { 187 | super() 188 | 189 | if (ethereumWallet) { 190 | this._wallet = ethereumWallet 191 | } else if (ethereumPrivateKey) { 192 | const provider = 193 | typeof ethereumProvider === 'string' 194 | ? ethers.getDefaultProvider(ethereumProvider) 195 | : ethereumProvider 196 | 197 | this._wallet = new ethers.Wallet(ethereumPrivateKey, provider) 198 | } else { 199 | throw new Error('Private key or Ethers wallet must be configured') 200 | } 201 | 202 | this._store = new StoreWrapper(store) 203 | this._log = log || createLogger(`ilp-plugin-ethereum-${role}`) 204 | 205 | this._getGasPrice = async () => 206 | new BigNumber( 207 | getGasPrice 208 | ? await getGasPrice() 209 | : await this._wallet.provider 210 | .getGasPrice() 211 | .then(gasPrice => gasPrice.toString()) 212 | ) 213 | 214 | // Cache the ABI/address of the contract corresponding to the chain we're connected to 215 | // If this promise rejects, connect() will also reject since loading accounts await this 216 | this._contract = getContract( 217 | this._wallet, 218 | !!tokenAddress, 219 | contractAddress 220 | ).catch(err => { 221 | this._log.error('Failed to load contract ABI and address:', err) 222 | throw err 223 | }) 224 | 225 | if (tokenAddress) { 226 | this._tokenContract = new ethers.Contract( 227 | tokenAddress, 228 | ERC20_ARTIFACT.abi, 229 | this._wallet 230 | ) 231 | } 232 | 233 | this._outgoingChannelAmount = convert(gwei(outgoingChannelAmount), wei()) 234 | .abs() 235 | .dp(0, BigNumber.ROUND_DOWN) 236 | 237 | this._minIncomingChannelAmount = convert( 238 | gwei(minIncomingChannelAmount), 239 | wei() 240 | ) 241 | .abs() 242 | .dp(0, BigNumber.ROUND_DOWN) 243 | 244 | // Sender can start a dispute period at anytime (e.g., if receiver is unresponsive) 245 | // If the receiver doesn't claim funds within that period, sender gets entire channel value 246 | 247 | this._minIncomingDisputePeriod = new BigNumber(minIncomingDisputePeriod) 248 | .abs() 249 | .dp(0, BigNumber.ROUND_CEIL) 250 | 251 | this._outgoingDisputePeriod = new BigNumber(outgoingDisputePeriod) 252 | .abs() 253 | .dp(0, BigNumber.ROUND_DOWN) 254 | 255 | this._maxPacketAmount = new BigNumber(maxPacketAmount) 256 | .abs() 257 | .dp(0, BigNumber.ROUND_DOWN) 258 | 259 | this._maxBalance = new BigNumber(role === 'client' ? Infinity : 0).dp( 260 | 0, 261 | BigNumber.ROUND_FLOOR 262 | ) 263 | 264 | this._channelWatcherInterval = new BigNumber(channelWatcherInterval) 265 | .abs() 266 | .dp(0, BigNumber.ROUND_DOWN) 267 | 268 | const loadAccount = (accountName: string) => this._loadAccount(accountName) 269 | const getAccount = (accountName: string) => { 270 | const account = this._accounts.get(accountName) 271 | if (!account) { 272 | throw new Error(`Account ${accountName} is not yet loaded`) 273 | } 274 | 275 | return account 276 | } 277 | 278 | this._plugin = 279 | role === 'server' 280 | ? new EthereumServerPlugin( 281 | { getAccount, loadAccount, ...opts }, 282 | { store, log } 283 | ) 284 | : new EthereumClientPlugin( 285 | { getAccount, loadAccount, ...opts }, 286 | { store, log } 287 | ) 288 | 289 | this._plugin.on('connect', () => this.emitAsync('connect')) 290 | this._plugin.on('disconnect', () => this.emitAsync('disconnect')) 291 | this._plugin.on('error', e => this.emitAsync('error', e)) 292 | } 293 | 294 | async _loadAccount(accountName: string): Promise { 295 | const accountKey = `${accountName}:account` 296 | await this._store.loadObject(accountKey) 297 | 298 | // TODO Add much more robust deserialization from store 299 | const accountData = this._store.getObject(accountKey) as ( 300 | | SerializedAccountData 301 | | undefined) 302 | 303 | // Account data must always be loaded from store before it's in the map 304 | if (!this._accounts.has(accountName)) { 305 | const account = new EthereumAccount({ 306 | sendMessage: (message: BtpPacket) => 307 | this._plugin._sendMessage(accountName, message), 308 | dataHandler: (data: Buffer) => this._dataHandler(data), 309 | moneyHandler: (amount: string) => this._moneyHandler(amount), 310 | accountName, 311 | accountData: { 312 | ...accountData, 313 | accountName, 314 | receivableBalance: new BigNumber( 315 | accountData ? accountData.receivableBalance : 0 316 | ), 317 | payableBalance: new BigNumber( 318 | accountData ? accountData.payableBalance : 0 319 | ), 320 | payoutAmount: new BigNumber( 321 | accountData ? accountData.payoutAmount : 0 322 | ), 323 | incoming: new ReducerQueue( 324 | accountData && accountData.incoming 325 | ? await updateChannel( 326 | await this._contract, 327 | deserializePaymentChannel( 328 | accountData.incoming 329 | ) as ClaimablePaymentChannel 330 | ) 331 | : undefined 332 | ), 333 | outgoing: new ReducerQueue( 334 | accountData && accountData.outgoing 335 | ? await updateChannel( 336 | await this._contract, 337 | deserializePaymentChannel(accountData.outgoing) 338 | ) 339 | : undefined 340 | ) 341 | }, 342 | master: this 343 | }) 344 | 345 | // Since this account didn't previosuly exist, save it in the store 346 | this._accounts.set(accountName, account) 347 | this._store.set('accounts', [...this._accounts.keys()]) 348 | } 349 | 350 | return this._accounts.get(accountName)! 351 | } 352 | 353 | _queueTransaction(sendTransaction: () => Promise): Promise { 354 | return new Promise((resolve, reject) => { 355 | this._txPipeline = this._txPipeline 356 | .then(sendTransaction) 357 | .then(resolve, reject) 358 | }) 359 | } 360 | 361 | async connect() { 362 | this._secp256k1 = await instantiateSecp256k1() 363 | 364 | // Load asset scale and symbol from ERC-20 contract 365 | if (this._tokenContract) { 366 | this._assetCode = await this._tokenContract.functions 367 | .symbol() 368 | .catch(err => { 369 | // DAI incorrectly implements 'symbol' as bytes32, not a string, which throws 370 | if (typeof err.value === 'string') { 371 | return ethers.utils.parseBytes32String(err.value) 372 | } else { 373 | return 'tokens' 374 | } 375 | }) 376 | 377 | this._assetScale = await this._tokenContract.functions 378 | .decimals() 379 | .catch(() => { 380 | this._log.info( 381 | `Configured ERC-20 doesn't have decimal place metadata; defaulting to 18 decimal places` 382 | ) 383 | return 18 384 | }) 385 | } 386 | 387 | // Load all accounts from the store 388 | await this._store.loadObject('accounts') 389 | const accounts = 390 | (this._store.getObject('accounts') as string[] | void) || [] 391 | 392 | for (const accountName of accounts) { 393 | this._log.debug(`Loading account ${accountName} from store`) 394 | await this._loadAccount(accountName) 395 | 396 | // Throttle loading accounts to ~100 per second 397 | // Most accounts should shut themselves down shortly after they're loaded 398 | await new Promise(r => setTimeout(r, 10)) 399 | } 400 | 401 | // Don't allow any incoming messages to accounts until all initial loading is complete 402 | // (this might create an issue, if an account requires _prefix to be known prior) 403 | return this._plugin.connect() 404 | } 405 | 406 | async disconnect() { 407 | // Triggers claiming of channels on client 408 | await this._plugin.disconnect() 409 | 410 | // Unload all accounts: stop channel watcher and perform garbage collection 411 | for (const account of this._accounts.values()) { 412 | account.unload() 413 | } 414 | 415 | // Persist store if there are any pending write operations 416 | await this._store.close() 417 | } 418 | 419 | isConnected() { 420 | return this._plugin.isConnected() 421 | } 422 | 423 | async sendData(data: Buffer) { 424 | return this._plugin.sendData(data) 425 | } 426 | 427 | async sendMoney(amount: string) { 428 | return this._plugin.sendMoney(amount) 429 | } 430 | 431 | registerDataHandler(dataHandler: DataHandler) { 432 | if (this._dataHandler !== defaultDataHandler) { 433 | throw new Error('request handler already registered') 434 | } 435 | 436 | this._dataHandler = dataHandler 437 | return this._plugin.registerDataHandler(dataHandler) 438 | } 439 | 440 | deregisterDataHandler() { 441 | this._dataHandler = defaultDataHandler 442 | return this._plugin.deregisterDataHandler() 443 | } 444 | 445 | registerMoneyHandler(moneyHandler: MoneyHandler) { 446 | if (this._moneyHandler !== defaultMoneyHandler) { 447 | throw new Error('money handler already registered') 448 | } 449 | 450 | this._moneyHandler = moneyHandler 451 | return this._plugin.registerMoneyHandler(moneyHandler) 452 | } 453 | 454 | deregisterMoneyHandler() { 455 | this._moneyHandler = defaultMoneyHandler 456 | return this._plugin.deregisterMoneyHandler() 457 | } 458 | 459 | _format(num: BigNumber.Value, unit: 'base' | 'account') { 460 | const scale = 461 | (unit === 'base' ? 0 : this._accountingScale) - this._assetScale 462 | const amountInExchangeUnits = new BigNumber(num).shiftedBy(scale) 463 | 464 | return amountInExchangeUnits + ' ' + this._assetCode 465 | } 466 | 467 | _convertToBaseUnit(num: BigNumber) { 468 | return num 469 | .shiftedBy(this._assetScale - this._accountingScale) 470 | .decimalPlaces(0, BigNumber.ROUND_DOWN) 471 | } 472 | 473 | _convertFromBaseUnit(num: BigNumber) { 474 | return num 475 | .shiftedBy(this._accountingScale - this._assetScale) 476 | .decimalPlaces(0, BigNumber.ROUND_DOWN) 477 | } 478 | } 479 | -------------------------------------------------------------------------------- /src/plugins/client.ts: -------------------------------------------------------------------------------- 1 | import EthereumAccount, { generateBtpRequestId } from '../account' 2 | import BtpPlugin, { 3 | BtpPacket, 4 | BtpSubProtocol, 5 | IlpPluginBtpConstructorOptions 6 | } from 'ilp-plugin-btp' 7 | import { TYPE_MESSAGE, MIME_APPLICATION_OCTET_STREAM } from 'btp-packet' 8 | import { PluginInstance, PluginServices } from '../types/plugin' 9 | import { 10 | isPrepare, 11 | deserializeIlpPrepare, 12 | deserializeIlpReply 13 | } from 'ilp-packet' 14 | 15 | export interface EthereumClientOpts extends IlpPluginBtpConstructorOptions { 16 | getAccount: (accountName: string) => EthereumAccount 17 | loadAccount: (accountName: string) => Promise 18 | } 19 | 20 | export class EthereumClientPlugin extends BtpPlugin implements PluginInstance { 21 | private getAccount: () => EthereumAccount 22 | private loadAccount: () => Promise 23 | 24 | constructor( 25 | { getAccount, loadAccount, ...opts }: EthereumClientOpts, 26 | { log }: PluginServices 27 | ) { 28 | super(opts, { log }) 29 | 30 | this.getAccount = () => getAccount('peer') 31 | this.loadAccount = () => loadAccount('peer') 32 | } 33 | 34 | _sendMessage(accountName: string, message: BtpPacket) { 35 | return this._call('', message) 36 | } 37 | 38 | async _connect(): Promise { 39 | await this.loadAccount() 40 | } 41 | 42 | _handleData(from: string, message: BtpPacket): Promise { 43 | return this.getAccount().handleData(message) 44 | } 45 | 46 | // Add hooks into sendData before and after sending a packet for 47 | // balance updates and settlement, akin to mini-accounts 48 | async sendData(buffer: Buffer): Promise { 49 | const prepare = deserializeIlpPrepare(buffer) 50 | if (!isPrepare(prepare)) { 51 | throw new Error('Packet must be a PREPARE') 52 | } 53 | 54 | const response = await this._call('', { 55 | type: TYPE_MESSAGE, 56 | requestId: await generateBtpRequestId(), 57 | data: { 58 | protocolData: [ 59 | { 60 | protocolName: 'ilp', 61 | contentType: MIME_APPLICATION_OCTET_STREAM, 62 | data: buffer 63 | } 64 | ] 65 | } 66 | }) 67 | 68 | const ilpResponse = response.protocolData.find( 69 | p => p.protocolName === 'ilp' 70 | ) 71 | if (ilpResponse) { 72 | const reply = deserializeIlpReply(ilpResponse.data) 73 | this.getAccount().handlePrepareResponse(prepare, reply) 74 | return ilpResponse.data 75 | } 76 | 77 | return Buffer.alloc(0) 78 | } 79 | 80 | sendMoney(amount: string) { 81 | return this.getAccount().sendMoney(amount) 82 | } 83 | 84 | _disconnect(): Promise { 85 | return this.getAccount().disconnect() 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/plugins/server.ts: -------------------------------------------------------------------------------- 1 | import EthereumAccount from '../account' 2 | import { PluginInstance, PluginServices } from '../types/plugin' 3 | import MiniAccountsPlugin from 'ilp-plugin-mini-accounts' 4 | import { ServerOptions } from 'ws' 5 | import { IldcpResponse } from 'ilp-protocol-ildcp' 6 | import { BtpPacket, BtpSubProtocol } from 'ilp-plugin-btp' 7 | import { IlpPacket, IlpPrepare, Type, isPrepare } from 'ilp-packet' 8 | 9 | export interface MiniAccountsOpts { 10 | port?: number 11 | wsOpts?: ServerOptions 12 | debugHostIldcpInfo?: IldcpResponse 13 | allowedOrigins?: string[] 14 | } 15 | 16 | export interface EthereumServerOpts extends MiniAccountsOpts { 17 | getAccount: (accountName: string) => EthereumAccount 18 | loadAccount: (accountName: string) => Promise 19 | } 20 | 21 | export class EthereumServerPlugin extends MiniAccountsPlugin 22 | implements PluginInstance { 23 | private getAccount: (address: string) => EthereumAccount 24 | private loadAccount: (address: string) => Promise 25 | 26 | constructor( 27 | { getAccount, loadAccount, ...opts }: EthereumServerOpts, 28 | api: PluginServices 29 | ) { 30 | super(opts, api) 31 | 32 | this.getAccount = (address: string) => 33 | getAccount(this.ilpAddressToAccount(address)) 34 | this.loadAccount = (address: string) => 35 | loadAccount(this.ilpAddressToAccount(address)) 36 | } 37 | 38 | _sendMessage(accountName: string, message: BtpPacket) { 39 | return this._call(this._prefix + accountName, message) 40 | } 41 | 42 | async _connect(address: string, message: BtpPacket): Promise { 43 | await this.loadAccount(address) 44 | } 45 | 46 | _handleCustomData = async ( 47 | from: string, 48 | message: BtpPacket 49 | ): Promise => { 50 | return this.getAccount(from).handleData(message) 51 | } 52 | 53 | _handlePrepareResponse = async ( 54 | destination: string, 55 | responsePacket: IlpPacket, 56 | preparePacket: { 57 | type: Type.TYPE_ILP_PREPARE 58 | typeString?: 'ilp_prepare' 59 | data: IlpPrepare 60 | } 61 | ) => { 62 | if (isPrepare(responsePacket.data)) { 63 | throw new Error('Received PREPARE in response to PREPARE') 64 | } 65 | 66 | return this.getAccount(destination).handlePrepareResponse( 67 | preparePacket.data, 68 | responsePacket.data 69 | ) 70 | } 71 | 72 | async sendMoney() { 73 | throw new Error( 74 | 'sendMoney is not supported: use plugin balance configuration' 75 | ) 76 | } 77 | 78 | async _close(from: string): Promise { 79 | return this.getAccount(from).disconnect() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/types/plugin.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter2 } from 'eventemitter2' 2 | import { Store } from '../utils/store' 3 | 4 | export interface Logger { 5 | info(...msg: any[]): void 6 | warn(...msg: any[]): void 7 | error(...msg: any[]): void 8 | debug(...msg: any[]): void 9 | trace(...msg: any[]): void 10 | } 11 | 12 | export interface DataHandler { 13 | (data: Buffer): Promise 14 | } 15 | 16 | export interface MoneyHandler { 17 | (amount: string): Promise 18 | } 19 | 20 | export interface PluginInstance extends EventEmitter2 { 21 | connect(options: {}): Promise 22 | disconnect(): Promise 23 | isConnected(): boolean 24 | sendData(data: Buffer): Promise 25 | sendMoney(amount: string): Promise 26 | registerDataHandler(dataHandler: DataHandler): void 27 | deregisterDataHandler(): void 28 | registerMoneyHandler(moneyHandler: MoneyHandler): void 29 | deregisterMoneyHandler(): void 30 | getAdminInfo?(): Promise 31 | sendAdminInfo?(info: object): Promise 32 | } 33 | 34 | export interface PluginServices { 35 | log?: Logger 36 | store?: Store 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/channel.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js' 2 | import { Secp256k1 } from 'bitcoin-ts' 3 | import { randomBytes } from 'crypto' 4 | import { Contract, ethers } from 'ethers' 5 | import { promisify } from 'util' 6 | import UNIDIRECTIONAL_MAINNET from '../abi/Unidirectional-mainnet.json' 7 | import UNIDIRECTIONAL_TESTNET from '../abi/Unidirectional-testnet.json' 8 | import TOKEN_UNIDIRECTIONAL from '../abi/TokenUnidirectional.json' 9 | import { ContractReceipt } from 'ethers/contract' 10 | 11 | // Almost never use exponential notation 12 | BigNumber.config({ EXPONENTIAL_AT: 1e9 }) 13 | 14 | const UNIDIRECTIONAL_METADATA: { 15 | [index: number]: { 16 | address: string 17 | metadata: typeof UNIDIRECTIONAL_MAINNET | typeof UNIDIRECTIONAL_TESTNET 18 | } 19 | } = { 20 | /** Mainnet */ 21 | 1: { 22 | metadata: UNIDIRECTIONAL_MAINNET, 23 | address: '0x08e4f70109ccc5135f50cc359d24cb7686247df4' 24 | }, 25 | /** Ropsten (cross-client PoW) */ 26 | 3: { 27 | metadata: UNIDIRECTIONAL_TESTNET, 28 | address: '0x8ffdea290f4dcdc553841a432e56aa26c91ab777' 29 | }, 30 | /** Rinkeby (Geth PoA) */ 31 | 4: { 32 | metadata: UNIDIRECTIONAL_TESTNET, 33 | address: '0x71ed284ea8e26e14b8f2d9b98ce4eff5a1f25120' 34 | }, 35 | /** Kovan (Parity PoA) */ 36 | 42: { 37 | metadata: UNIDIRECTIONAL_TESTNET, 38 | address: '0x481029bf134710832f5b9debdd10275fb7816f59' 39 | } 40 | } 41 | 42 | const TOKEN_UNIDIRECTIONAL_METADATA: { 43 | [index: number]: { 44 | address: string 45 | metadata: typeof TOKEN_UNIDIRECTIONAL 46 | } 47 | } = { 48 | /** Mainnet */ 49 | 1: { 50 | metadata: TOKEN_UNIDIRECTIONAL, 51 | address: '0x59b941b53403f84f42d5c11117b35564881b72f6' 52 | }, 53 | /** Ropsten (cross-client PoW) */ 54 | 3: { 55 | metadata: TOKEN_UNIDIRECTIONAL, 56 | address: '0xf55cf03626dc6d6fdd9e97f88aace0b2ecae34c1' 57 | }, 58 | /** Rinkeby (Geth PoA) */ 59 | 4: { 60 | metadata: TOKEN_UNIDIRECTIONAL, 61 | address: '0x7660f4fb856c0dcf07439d93ec3fe3f438960b89' 62 | }, 63 | /** Kovan (Parity PoA) */ 64 | 42: { 65 | metadata: TOKEN_UNIDIRECTIONAL, 66 | address: '0x396317f2ea46a1cea58a58d44d2d902f1a257588' 67 | } 68 | } 69 | 70 | export interface PaymentChannel { 71 | /** UNIX timestamp in milliseconds when channel state was last fetched */ 72 | lastUpdated: number 73 | 74 | /** Unique identifier on contract for this specific channel */ 75 | channelId: string 76 | 77 | /** Ethereum address of the receiver in the channel */ 78 | receiver: string 79 | 80 | /** Ethereum address of the sender in the channel */ 81 | sender: string 82 | 83 | /** Total collateral the sender added to the channel */ 84 | value: BigNumber 85 | 86 | /** 87 | * Number of blocks between the beginning of the dispute period and when 88 | * the sender could sweep the channel, if the receiver did not claim it 89 | */ 90 | disputePeriod: BigNumber 91 | 92 | /** 93 | * Block number when the sender can end the dispute to get all their money back 94 | * - Only defined if a dispute period is active 95 | */ 96 | disputedUntil?: BigNumber 97 | 98 | /** Ethereum address of the contract the channel is created on */ 99 | contractAddress: string 100 | 101 | /** 102 | * Address of the ERC-20 token contract to use for the token in the payment channel 103 | * - Only defined if this uses the TokenUnidirectional contract for ERC-20s 104 | */ 105 | tokenContract?: string 106 | 107 | /** 108 | * Value of the claim/amount that can be claimed 109 | * - If no claim signature is included, the value defaults to 0 110 | */ 111 | spent: BigNumber 112 | 113 | /** Valid signature to claim the channel */ 114 | signature?: string 115 | } 116 | 117 | export interface ClaimablePaymentChannel extends PaymentChannel { 118 | /** Valid signature to claim the channel */ 119 | signature: string 120 | } 121 | 122 | export interface SerializedClaim { 123 | contractAddress: string 124 | channelId: string 125 | signature: string 126 | value: string 127 | tokenContract?: string 128 | } 129 | 130 | export interface SerializedPaymentChannel { 131 | lastUpdated: number 132 | channelId: string 133 | receiver: string 134 | sender: string 135 | value: string 136 | disputePeriod: string 137 | disputedUntil?: string 138 | contractAddress: string 139 | tokenContract?: string 140 | spent: string 141 | signature?: string 142 | } 143 | 144 | export interface SerializedClaimablePaymentChannel 145 | extends SerializedPaymentChannel { 146 | signature: string 147 | } 148 | 149 | /** 150 | * Parse BigNumbers in serialized payment channel state from database 151 | * @param channel Serialized payment channel state, with BigNumbers converted to strings 152 | */ 153 | export const deserializePaymentChannel = ( 154 | channel: SerializedPaymentChannel 155 | ): PaymentChannel => ({ 156 | ...channel, 157 | value: new BigNumber(channel.value), 158 | disputePeriod: new BigNumber(channel.disputePeriod), 159 | disputedUntil: 160 | typeof channel.disputedUntil === 'string' 161 | ? new BigNumber(channel.disputedUntil) 162 | : channel.disputedUntil, 163 | spent: new BigNumber(channel.spent) 164 | }) 165 | 166 | /** Generate a pseudorandom hex string to use as a channel ID */ 167 | export const generateChannelId = async () => 168 | '0x' + (await promisify(randomBytes)(32)).toString('hex') 169 | 170 | /** 171 | * Create an Ethers contract instances for the Machinomy payment channel contract 172 | * @param signer Ethers signer and provider to perform read & write operations on the contract 173 | * @param useTokenContract Should the default token contract address for the network be used? 174 | * @param contractAddress Custom Machinomy contract address to load ABI 175 | */ 176 | export const getContract = async ( 177 | signer: ethers.Signer, 178 | useTokenContract = false, 179 | contractAddress?: string 180 | ) => { 181 | let contractMetadata 182 | 183 | if (signer.provider) { 184 | const { chainId } = await signer.provider.getNetwork() 185 | 186 | contractMetadata = useTokenContract 187 | ? TOKEN_UNIDIRECTIONAL_METADATA[chainId] 188 | : UNIDIRECTIONAL_METADATA[chainId] 189 | } 190 | 191 | if (!contractMetadata) { 192 | if (contractAddress) { 193 | // Default to using the testnet ABI 194 | contractMetadata = useTokenContract 195 | ? { 196 | metadata: TOKEN_UNIDIRECTIONAL, 197 | address: contractAddress 198 | } 199 | : { 200 | metadata: UNIDIRECTIONAL_TESTNET, 201 | address: contractAddress 202 | } 203 | } else { 204 | throw new Error( 205 | `Machinomy is not supported on the current Ethereum chain` 206 | ) 207 | } 208 | } 209 | 210 | return new ethers.Contract( 211 | contractMetadata.address, 212 | contractMetadata.metadata.abi, 213 | signer 214 | ) 215 | } 216 | 217 | /** 218 | * Check if a channel may have closed between two an initial state and a later proposed state 219 | * - Attempts to protect against channelId reuse in Machinomy contracts, 220 | * in case a channel was closed and reopened 221 | */ 222 | export const didChannelClose = ( 223 | cachedChannel: PaymentChannel, 224 | updatedChannel: PaymentChannel 225 | ) => 226 | // Channel ID must be the same 227 | cachedChannel.channelId !== updatedChannel.channelId || 228 | // Contract address must be the same 229 | cachedChannel.contractAddress.toLowerCase() !== 230 | updatedChannel.contractAddress.toLowerCase() || 231 | // Dispute period must be the same 232 | !cachedChannel.disputePeriod.isEqualTo(updatedChannel.disputePeriod) || 233 | // If the first state is disputed until block x, the second state must also be disputed until block x 234 | (cachedChannel.disputedUntil && 235 | (!updatedChannel.disputedUntil || 236 | !cachedChannel.disputedUntil.isEqualTo(updatedChannel.disputedUntil))) || 237 | // Receiver must be the same 238 | cachedChannel.receiver.toLowerCase() !== 239 | updatedChannel.receiver.toLowerCase() || 240 | // Sender must be the same 241 | cachedChannel.sender.toLowerCase() !== updatedChannel.sender.toLowerCase() || 242 | // If the first state has a token contract, the second must have the same token contract 243 | (cachedChannel.tokenContract && 244 | (!updatedChannel.tokenContract || 245 | cachedChannel.tokenContract.toLowerCase() !== 246 | updatedChannel.tokenContract.toLowerCase())) || 247 | // Total value may not decrease 248 | updatedChannel.value.isLessThan(cachedChannel.value) 249 | 250 | /** 251 | * Fetch updated payment channel state, but include the existing signed claim 252 | * - If fetching the state failed, return the existing cached state 253 | * @param contract Ethers instance of the Machinomy ETH or ERC-20 contract 254 | * @param cachedChannel Payment channel state with claim to fetch from network 255 | */ 256 | export const updateChannel = async ( 257 | contract: Contract, 258 | cachedChannel: TPaymentChannel 259 | ): Promise => 260 | fetchChannelById(contract, cachedChannel.channelId) 261 | .then(updatedChannel => 262 | updatedChannel && !didChannelClose(cachedChannel, updatedChannel) 263 | ? ({ 264 | ...updatedChannel, 265 | spent: cachedChannel.spent, 266 | signature: cachedChannel.signature 267 | } as TPaymentChannel) 268 | : undefined 269 | ) 270 | .catch(() => cachedChannel) 271 | 272 | /** 273 | * Fetch payment channel state by channel ID 274 | * @param contract Ethers instance of the Machinomy ETH or ERC-20 contract 275 | * @param channelId Unique identifier for the payment channel 276 | */ 277 | export const fetchChannelById = async ( 278 | contract: Contract, 279 | channelId: string 280 | ): Promise => { 281 | let { 282 | sender, 283 | receiver, 284 | settlingUntil, 285 | settlingPeriod, 286 | value, 287 | tokenContract 288 | } = await contract.functions.channels(channelId) 289 | 290 | if (sender === ethers.constants.AddressZero) { 291 | return 292 | } 293 | 294 | // In contract, `settlingUntil` should be positive if settling, 0 if open (contract checks if settlingUntil != 0) 295 | const disputedUntil = settlingUntil.gt(0) 296 | ? new BigNumber(settlingUntil.toString()) 297 | : undefined 298 | 299 | return { 300 | lastUpdated: Date.now(), 301 | contractAddress: contract.address, 302 | channelId, 303 | receiver, 304 | sender, 305 | disputedUntil, 306 | disputePeriod: new BigNumber(settlingPeriod.toString()), 307 | value: new BigNumber(value.toString()), 308 | spent: new BigNumber(0), 309 | tokenContract 310 | } 311 | } 312 | 313 | export const prepareTransaction = async ({ 314 | methodName, 315 | params, 316 | contract, 317 | gasPrice, 318 | gasLimit, 319 | value = 0 320 | }: { 321 | methodName: string 322 | params: any[] 323 | contract: ethers.Contract 324 | gasPrice: BigNumber.Value 325 | gasLimit?: BigNumber.Value 326 | value?: BigNumber.Value 327 | }): Promise<{ 328 | txFee: BigNumber 329 | sendTransaction: () => Promise 330 | }> => { 331 | const overrides = { 332 | value: ethers.utils.bigNumberify(value.toString()), 333 | gasPrice: ethers.utils.bigNumberify(gasPrice.toString()) 334 | } 335 | 336 | // If a gasLimit was provided, use that; otherwise, estimate how much gas we need 337 | const estimatedGasLimit: ethers.utils.BigNumber = gasLimit 338 | ? ethers.utils.bigNumberify(gasLimit.toString()) 339 | : await contract.estimate[methodName](...params, overrides) 340 | 341 | const txFee = new BigNumber(gasPrice).times(estimatedGasLimit.toString()) 342 | 343 | return { 344 | txFee, 345 | sendTransaction: async () => { 346 | const tx: ethers.ContractTransaction = await contract.functions[ 347 | methodName 348 | ](...params, { 349 | ...overrides, 350 | gasLimit: estimatedGasLimit 351 | }) 352 | 353 | // Wait 1 confirmation 354 | const receipt = await tx.wait(1) 355 | 356 | /** 357 | * Per EIP 658, a receipt of 1 indicates the tx was successful: 358 | * https://github.com/Arachnid/EIPs/blob/d1ae915d079293480bd6abb0187976c230d57903/EIPS/eip-658.md 359 | */ 360 | if (receipt.status !== 1) { 361 | throw new Error('Ethereum transaction reverted by the EVM') 362 | } 363 | 364 | return receipt 365 | } 366 | } 367 | } 368 | 369 | /** 370 | * Does the transaction receipt include an event with the given name? 371 | * @param receipt Receipt from a transaction on a contract 372 | * @param eventName Name of the contract event to check 373 | */ 374 | export const hasEvent = ( 375 | receipt: ContractReceipt, 376 | eventName: string 377 | ): boolean => 378 | !receipt.events || receipt.events.map(o => o.event).includes(eventName) 379 | 380 | /** 381 | * Does the given payemnt channel include a claim to withdraw funds on the blockchain? 382 | * @param channel Payment channel state 383 | */ 384 | export const hasClaim = ( 385 | channel?: PaymentChannel 386 | ): channel is ClaimablePaymentChannel => !!channel && !!channel.signature 387 | 388 | /** 389 | * What amount in the payment channel has been sent to the receiver? 390 | * @param channel Payment channel state 391 | */ 392 | export const spentFromChannel = (channel?: PaymentChannel): BigNumber => 393 | channel ? channel.spent : new BigNumber(0) 394 | 395 | /** 396 | * What amount in the payment channel is still escrowed in our custody, available to send? 397 | * @param channel Payment channel state 398 | */ 399 | export const remainingInChannel = (channel?: PaymentChannel): BigNumber => 400 | channel ? channel.value.minus(channel.spent) : new BigNumber(0) 401 | 402 | /** 403 | * Has the sender of the payment channel initiated a dispute period? 404 | * @param channel Payment channel state 405 | */ 406 | export const isDisputed = (channel: PaymentChannel): boolean => 407 | !!channel.disputedUntil 408 | 409 | /** 410 | * Was the serialized claim correctly encoded and signed by the given recoveryAddress? 411 | * @param secp256k1 Instance of bitcoin-ts WASM module to sign and verify claims 412 | * @param claim Serialized payment channel channel 413 | * @param recoveryAddress Address to check that the claim was signed by (returns false if signed by a different address) 414 | */ 415 | export const isValidClaimSignature = ( 416 | secp256k1: Secp256k1, 417 | claim: SerializedClaim, 418 | recoveryAddress: string 419 | ): boolean => { 420 | const signature = claim.signature.slice(0, -2) // Remove recovery param from end 421 | const signatureBuffer = hexToBuffer(signature) 422 | 423 | const v = claim.signature.slice(-2) 424 | const recoveryId = v === '1c' ? 1 : 0 425 | 426 | let publicKey: Uint8Array 427 | try { 428 | publicKey = secp256k1.recoverPublicKeyUncompressed( 429 | signatureBuffer, 430 | recoveryId, 431 | createPaymentDigest( 432 | claim.contractAddress, 433 | claim.channelId, 434 | claim.value, 435 | claim.tokenContract 436 | ) 437 | ) 438 | } catch (err) { 439 | return false 440 | } 441 | 442 | const senderAddress = ethers.utils.computeAddress(publicKey) 443 | return senderAddress.toLowerCase() === recoveryAddress.toLowerCase() 444 | } 445 | 446 | /** 447 | * Encode and hash parameters of payment channel claim as Ethereum message 448 | * @param contractAddress Ethereum address of the payment channel contract used 449 | * @param channelId Identifier for the channel 450 | * @param value Total value the receiver in the channel can withdraw on-chain 451 | * @param tokenContract Address of the ERC-20 token contract 452 | */ 453 | export const createPaymentDigest = ( 454 | contractAddress: string, 455 | channelId: string, 456 | value: string, 457 | tokenContract?: string 458 | ): Buffer => { 459 | const paramTypes = ['address', 'bytes32', 'uint256'] 460 | const paramValues = [contractAddress, channelId, value] 461 | 462 | // ERC-20 claims must also encode the token contract address at the end 463 | if (tokenContract) { 464 | paramTypes.push('address') 465 | paramValues.push(tokenContract) 466 | } 467 | 468 | const paymentDigest = ethers.utils.solidityKeccak256(paramTypes, paramValues) 469 | const paymentDigestBuffer = hexToBuffer(paymentDigest) 470 | 471 | // Prefix with `\x19Ethereum Signed Message\n`, encode packed, and hash using keccak256 again 472 | const prefixedPaymentDigest = ethers.utils.hashMessage(paymentDigestBuffer) 473 | return hexToBuffer(prefixedPaymentDigest) 474 | } 475 | 476 | /** 477 | * Convert the given hexadecimal string to a Buffer 478 | * @param hexStr Hexadecimal string, which may optionally begin with "0x" 479 | */ 480 | export const hexToBuffer = (hexStr: string) => 481 | Buffer.from(stripHexPrefix(hexStr), 'hex') 482 | 483 | /** 484 | * If the given string begins with "0x", remove it 485 | * @param hexStr Hexadecimal string, which may optionally begin with "0x" 486 | */ 487 | export const stripHexPrefix = (hexStr: string) => 488 | hexStr.startsWith('0x') ? hexStr.slice(2) : hexStr 489 | -------------------------------------------------------------------------------- /src/utils/queue.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter2 } from 'eventemitter2' 2 | 3 | export default class ReducerQueue extends EventEmitter2 { 4 | private cache: T 5 | private waterfall: Promise 6 | private queue: { 7 | run: (state: T) => Promise 8 | priority: number 9 | }[] = [] 10 | 11 | constructor(initialState: T) { 12 | super() 13 | 14 | this.cache = initialState 15 | this.waterfall = Promise.resolve(initialState) 16 | 17 | this.emit('data', initialState) 18 | } 19 | 20 | add(task: (state: T) => Promise, priority = 0): Promise { 21 | const done = new Promise((resolve, reject) => { 22 | const run = (state: T) => 23 | task(state) 24 | .then(state => { 25 | resolve(state) 26 | return state 27 | }) 28 | .catch(err => { 29 | reject(err) 30 | return state // Return the original state 31 | }) 32 | 33 | const element = { run, priority } 34 | const index = lowerBound( 35 | this.queue, 36 | element, 37 | (a, b) => b.priority - a.priority 38 | ) 39 | this.queue.splice(index, 0, element) 40 | }) 41 | 42 | // Since we added a task to the queue, chain .then() to eventually run another task 43 | // (although not necessarily this task, since it's a priority queue) 44 | this.waterfall = this.waterfall.then(state => this.tryToRunAnother(state)) 45 | 46 | return done 47 | } 48 | 49 | clear(): Promise { 50 | this.queue = [] 51 | return this.waterfall 52 | } 53 | 54 | get state(): T { 55 | return this.cache 56 | } 57 | 58 | toJSON(): T { 59 | return this.cache 60 | } 61 | 62 | private async tryToRunAnother(state: T): Promise { 63 | const next = this.queue.shift() 64 | if (!next) { 65 | return state 66 | } 67 | 68 | // Running the task is guranteed not to reject, since they're already caught when enqueued 69 | // Another task will be queued after this async function resolves, so await the current task 70 | const newState = await next.run(state) 71 | 72 | this.cache = newState 73 | this.emit('data', newState) 74 | 75 | return newState 76 | } 77 | } 78 | 79 | /** 80 | * Copied from p-queue: https://github.com/sindresorhus/p-queue/blob/master/index.js 81 | * 82 | * MIT License 83 | * 84 | * Copyright (c) Sindre Sorhus (sindresorhus.com) 85 | * 86 | * Permission is hereby granted, free of charge, to any person obtaining 87 | * a copy of this software and associated documentation files (the 88 | * "Software"), to deal in the Software without restriction, including 89 | * without limitation the rights to use, copy, modify, merge, publish, 90 | * distribute, sublicense, and/or sell copies of the Software, and to 91 | * permit persons to whom the Software is furnished to do so, subject to 92 | * the following conditions: 93 | * 94 | * The above copyright notice and this permission notice shall be 95 | * included in all copies or substantial portions of the Software. 96 | * 97 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 98 | * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 99 | * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 100 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 101 | * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 102 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 103 | * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 104 | */ 105 | const lowerBound = (array: T[], value: T, comp: (a: T, b: T) => number) => { 106 | let first = 0 107 | let count = array.length 108 | 109 | while (count > 0) { 110 | const step = (count / 2) | 0 111 | let it = first + step 112 | 113 | if (comp(array[it], value) <= 0) { 114 | first = ++it 115 | count -= step + 1 116 | } else { 117 | count = step 118 | } 119 | } 120 | 121 | return first 122 | } 123 | -------------------------------------------------------------------------------- /src/utils/store.ts: -------------------------------------------------------------------------------- 1 | export interface Store { 2 | get: (key: string) => Promise 3 | put: (key: string, value: string) => Promise 4 | del: (key: string) => Promise 5 | } 6 | 7 | export class MemoryStore implements Store { 8 | private _store: Map 9 | 10 | constructor() { 11 | this._store = new Map() 12 | } 13 | 14 | async get(k: string) { 15 | return this._store.get(k) 16 | } 17 | 18 | async put(k: string, v: string) { 19 | this._store.set(k, v) 20 | } 21 | 22 | async del(k: string) { 23 | this._store.delete(k) 24 | } 25 | } 26 | 27 | export class StoreWrapper { 28 | private _store?: Store 29 | private _cache: Map 30 | private _write: Promise 31 | 32 | constructor(store: Store) { 33 | this._store = store 34 | this._cache = new Map() 35 | this._write = Promise.resolve() 36 | } 37 | 38 | async load(key: string) { 39 | return this._load(key, false) 40 | } 41 | async loadObject(key: string) { 42 | return this._load(key, true) 43 | } 44 | 45 | private async _load(key: string, parse: boolean) { 46 | if (!this._store) return 47 | if (this._cache.has(key)) return 48 | const value = await this._store.get(key) 49 | 50 | // once the call to the store returns, double-check that the cache is still empty. 51 | if (!this._cache.has(key)) { 52 | this._cache.set(key, parse && value ? JSON.parse(value) : value) 53 | } 54 | } 55 | 56 | unload(key: string) { 57 | if (this._cache.has(key)) { 58 | this._cache.delete(key) 59 | } 60 | } 61 | 62 | get(key: string): string | void { 63 | const val = this._cache.get(key) 64 | if (typeof val === 'undefined' || typeof val === 'string') return val 65 | throw new Error('StoreWrapper#get: unexpected type for key=' + key) 66 | } 67 | 68 | getObject(key: string): object | void { 69 | const val = this._cache.get(key) 70 | if (typeof val === 'undefined' || typeof val === 'object') return val 71 | throw new Error('StoreWrapper#getObject: unexpected type for key=' + key) 72 | } 73 | 74 | set(key: string, value: string | object) { 75 | this._cache.set(key, value) 76 | const valueStr = typeof value === 'object' ? JSON.stringify(value) : value 77 | this._write = this._write.then(() => { 78 | if (this._store) { 79 | return this._store.put(key, valueStr) 80 | } 81 | }) 82 | } 83 | 84 | delete(key: string) { 85 | this._cache.delete(key) 86 | this._write = this._write.then(() => { 87 | if (this._store) { 88 | return this._store.del(key) 89 | } 90 | }) 91 | } 92 | 93 | setCache(key: string, value: string) { 94 | this._cache.set(key, value) 95 | } 96 | 97 | close(): Promise { 98 | return this._write 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "rootDir": "src", 5 | "outDir": "build", 6 | "target": "esnext", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "declaration": true, 11 | "removeComments": true, 12 | "strict": true /* Enable all strict type-checking options. */, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard", 4 | "tslint-eslint-rules", 5 | "tslint-config-prettier" 6 | ], 7 | "linterOptions": { 8 | "exclude": ["src/**/*.json"] 9 | } 10 | } 11 | --------------------------------------------------------------------------------