├── index.js ├── tslint.json ├── .gitignore ├── test ├── util │ └── memStore.js └── pluginSpec.js ├── tsconfig.json ├── src ├── util.ts ├── store-wrapper.ts ├── account.ts └── index.ts ├── README.md ├── package.json └── .circleci └── config.yml /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = require('./src').default 3 | module.exports.default = module.exports 4 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-standard", 3 | "linterOptions": { 4 | "exclude": [ 5 | "src/schemas/*.ts" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.nyc_output/ 3 | /dist/ 4 | /coverage/ 5 | /src/**/*.js 6 | /src/**/*.js.map 7 | /src/**/*.d.ts 8 | /test/**/*.js 9 | /test/**/*.js.map 10 | /test/**/*.d.ts 11 | #/src/schemas/*.ts 12 | !wallaby.js 13 | /parallel-2018522* 14 | /secret 15 | *.tgz 16 | -------------------------------------------------------------------------------- /test/util/memStore.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class MemStore { 4 | constructor (uri, name) { 5 | this.name = name 6 | this.store = {} 7 | } 8 | 9 | async get (key) { 10 | return this.store[key] 11 | } 12 | 13 | async put (key, value) { 14 | this.store[key] = value 15 | } 16 | 17 | async del (key) { 18 | delete this.store[key] 19 | } 20 | } 21 | 22 | module.exports = MemStore 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "sourceMap": true, 6 | "declaration": true, 7 | "noImplicitAny": true, 8 | "removeComments": true, 9 | "moduleResolution": "node", 10 | "inlineSources": true, 11 | "strictNullChecks": true, 12 | "suppressImplicitAnyIndexErrors": true 13 | }, 14 | "compileOnSave": true 15 | } 16 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export type Protocol = { 2 | protocolName: string 3 | contentType: number 4 | data: Buffer 5 | } 6 | 7 | export type BtpData = { 8 | data: { 9 | protocolData: Protocol[] 10 | } 11 | requestId: number 12 | } 13 | 14 | export type Claim = { 15 | signature?: string 16 | amount: string 17 | } 18 | 19 | export type Paychan = { 20 | account: string, 21 | amount: string, 22 | balance: string, 23 | publicKey: string, 24 | destination: string, 25 | settleDelay: number, 26 | expiration?: string, 27 | cancelAfter?: string, 28 | sourceTag?: number, 29 | destinationTag?: number, 30 | previousAffectingTransactionID: string, 31 | previousAffectingTransactionLedgerVersion: number 32 | } 33 | 34 | export type Store = { 35 | get: (key: string) => Promise 36 | put: (key: string, value: string) => Promise 37 | del: (key: string) => Promise 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ILP Plugin XRP Asym Server 2 | 3 | ILP Plugin XRP Asym Server allows you to accept payment channel connections 4 | from many users without adding them as peers. If you're running a connector, 5 | this is a great way to get sub-connectors and provide ILP connection to users 6 | without asking them to trust you with their money. 7 | 8 | Details of how the connection is established are described in this plugin's 9 | client, 10 | [`ilp-plugin-xrp-asym-client`](https://github.com/interledgerjs/ilp-plugin-xrp-asym-client) 11 | 12 | This plugin is based off of 13 | [`ilp-plugin-mini-accounts`](https://github.com/interledgerjs/ilp-plugin-mini-accounts), 14 | with XRP payment channel functionality on top. 15 | 16 | ```js 17 | const serverPlugin = new IlpPluginXrpAsymServer({ 18 | // Port on which to listen 19 | port: 6666, 20 | 21 | // XRP credentials of the server 22 | address: 'rKzfaLjeVZXasCSU2heTUGw9VhQmFNSd8k', 23 | secret: 'snHNnoL6S67wNvydcZg9y9bFzPZwG', 24 | 25 | // Rippled server for the server to use 26 | xrpServer: 'wss://s.altnet.rippletest.net:51233', 27 | 28 | // Max amount to be unsecured at any one time 29 | maxBalance: 1000000, 30 | 31 | // Maximum packet amount to allow (returns F08 if exceeded) 32 | maxPacketAmount: 1000, 33 | 34 | // Persistent Key-value store. ILP-Connector will pass 35 | // this parameter in automatically. 36 | _store: new Store() 37 | }) 38 | ``` 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ilp-plugin-xrp-asym-server", 3 | "version": "1.8.1", 4 | "description": "Asymmetric XRP paychan server for ILP", 5 | "main": "index.js", 6 | "types": "src/index.d.ts", 7 | "files": [ 8 | "index.js", 9 | "src/**/*.ts", 10 | "src/**/*.js", 11 | "src/**/*.js.map", 12 | "src/schemas/*.json" 13 | ], 14 | "scripts": { 15 | "build": "npm run compile-ts", 16 | "compile-ts": "tsc --project .", 17 | "lint": "tslint --project .", 18 | "pretest": "npm run build", 19 | "watch": "mocha-typescript-watch", 20 | "prepare": "npm run build", 21 | "test": "nyc --reporter=lcov mocha --exit" 22 | }, 23 | "author": "", 24 | "license": "ISC", 25 | "dependencies": { 26 | "@types/debug": "0.0.30", 27 | "base64url": "^3.0.0", 28 | "bignumber.js": "^7.2.1", 29 | "btp-packet": "^2.2.0", 30 | "debug": "^3.1.0", 31 | "ilp-logger": "^1.0.2", 32 | "ilp-packet": "^3.0.8", 33 | "ilp-plugin-mini-accounts": "^4.0.1", 34 | "ilp-plugin-xrp-paychan-shared": "^4.1.0", 35 | "ilp-protocol-ildcp": "^2.0.1", 36 | "ripple-address-codec": "^2.0.1", 37 | "ripple-lib": "^0.21.0", 38 | "sodium-universal": "^2.0.0", 39 | "ws": "^4.0.0" 40 | }, 41 | "devDependencies": { 42 | "@types/bignumber.js": "^5.0.0", 43 | "chai": "^4.1.2", 44 | "chai-as-promised": "^7.1.1", 45 | "mocha": "^5.0.0", 46 | "mocha-typescript": "^1.1.12", 47 | "nyc": "^13.3.0", 48 | "sinon": "^4.2.0", 49 | "source-map-support": "^0.5.5", 50 | "standard": "^10.0.3", 51 | "ts-node": "^6.0.2", 52 | "tslint": "^5.10.0", 53 | "tslint-config-standard": "^7.0.0", 54 | "typescript": "^2.8.3" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.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 run tests 25 | command: npm test 26 | - run: 27 | name: Lint files 28 | command: npm run lint 29 | - persist_to_workspace: 30 | root: ~/repo 31 | paths: . 32 | 33 | publish: 34 | docker: 35 | - image: circleci/node:10 36 | working_directory: ~/repo 37 | steps: 38 | - attach_workspace: 39 | at: ~/repo 40 | - run: 41 | name: Authenticate with registry 42 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc 43 | - run: 44 | name: Publish package 45 | command: npm publish 46 | 47 | workflows: 48 | version: 2 49 | build_and_publish: 50 | jobs: 51 | - build: 52 | filters: 53 | tags: 54 | only: /.*/ 55 | - publish: 56 | requires: 57 | - build 58 | filters: 59 | tags: 60 | only: /^v.*/ 61 | branches: 62 | ignore: /.*/ 63 | -------------------------------------------------------------------------------- /src/store-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { Store } from './util' 2 | 3 | export default class StoreWrapper { 4 | private _store?: Store 5 | private _cache: Map 6 | private _write: Promise 7 | 8 | constructor (store: Store) { 9 | this._store = store 10 | this._cache = new Map() 11 | this._write = Promise.resolve() 12 | } 13 | 14 | async load (key: string) { return this._load(key, false) } 15 | async loadObject (key: string) { return this._load(key, true) } 16 | 17 | private async _load (key: string, parse: boolean) { 18 | if (!this._store) return 19 | if (this._cache.has(key)) return 20 | const value = await this._store.get(key) 21 | 22 | // once the call to the store returns, double-check that the cache is still empty. 23 | if (!this._cache.has(key)) { 24 | this._cache.set(key, (parse && value) ? JSON.parse(value) : value) 25 | } 26 | } 27 | 28 | unload (key: string) { 29 | if (this._cache.has(key)) { 30 | this._cache.delete(key) 31 | } 32 | } 33 | 34 | get (key: string): string | void { 35 | const val = this._cache.get(key) 36 | if (val === undefined || typeof val === 'string') return val 37 | throw new Error('StoreWrapper#get: unexpected type for key=' + key) 38 | } 39 | 40 | getObject (key: string): object | void { 41 | const val = this._cache.get(key) 42 | if (val === undefined || typeof val === 'object') return val 43 | throw new Error('StoreWrapper#getObject: unexpected type for key=' + key) 44 | } 45 | 46 | set (key: string, value: string | object) { 47 | this._cache.set(key, value) 48 | const valueStr = typeof value === 'object' ? JSON.stringify(value) : value 49 | this._write = this._write.then(() => { 50 | if (this._store) { 51 | return this._store.put(key, valueStr) 52 | } 53 | }) 54 | } 55 | 56 | delete (key: string) { 57 | this._cache.delete(key) 58 | this._write = this._write.then(() => { 59 | if (this._store) { 60 | return this._store.del(key) 61 | } 62 | }) 63 | } 64 | 65 | setCache (key: string, value: string) { 66 | this._cache.set(key, value) 67 | } 68 | 69 | close (): Promise { return this._write } 70 | } 71 | -------------------------------------------------------------------------------- /src/account.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { RippleAPI } from 'ripple-lib' 4 | import BigNumber from 'bignumber.js' 5 | import StoreWrapper from './store-wrapper' 6 | import { 7 | Claim, 8 | Paychan 9 | } from './util' 10 | 11 | const BALANCE = (a: string) => a 12 | const INCOMING_CLAIM = (a: string) => a + ':claim' 13 | const CHANNEL = (a: string) => a + ':channel' 14 | const IS_BLOCKED = (a: string) => a + ':block' 15 | const BLOCK_REASON = (a: string) => a + ':block_reason' 16 | const CLIENT_CHANNEL = (a: string) => a + ':client_channel' 17 | const OUTGOING_BALANCE = (a: string) => a + ':outgoing_balance' 18 | const OWED_BALANCE = (a: string) => a + ':owed_balance' 19 | const LAST_CLAIMED = (a: string) => a + ':last_claimed' 20 | // TODO: the channels to accounts map 21 | 22 | const RETRY_DELAY = 2000 23 | const DEFAULT_BLOCK_REASON = 'channel must be re-established' 24 | 25 | export interface AccountParams { 26 | account: string 27 | store: StoreWrapper 28 | api: RippleAPI 29 | currencyScale: number, 30 | log: any 31 | } 32 | 33 | export enum ReadyState { 34 | INITIAL = 0, 35 | LOADING_CHANNEL = 1, 36 | ESTABLISHING_CHANNEL = 2, 37 | PREPARING_CHANNEL = 3, 38 | LOADING_CLIENT_CHANNEL = 4, 39 | ESTABLISHING_CLIENT_CHANNEL = 5, 40 | PREPARING_CLIENT_CHANNEL = 6, 41 | READY = 7, 42 | BLOCKED = 8 43 | } 44 | 45 | function stateToString (state: ReadyState): string { 46 | switch (state) { 47 | case ReadyState.INITIAL: return 'INITIAL' 48 | case ReadyState.LOADING_CHANNEL: return 'LOADING_CHANNEL' 49 | case ReadyState.ESTABLISHING_CHANNEL: return 'ESTABLISHING_CHANNEL' 50 | case ReadyState.PREPARING_CHANNEL: return 'PREPARING_CHANNEL' 51 | case ReadyState.LOADING_CLIENT_CHANNEL: return 'LOADING_CLIENT_CHANNEL' 52 | case ReadyState.ESTABLISHING_CLIENT_CHANNEL: return 'ESTABLISHING_CLIENT_CHANNEL' 53 | case ReadyState.PREPARING_CLIENT_CHANNEL: return 'PREPARING_CLIENT_CHANNEL' 54 | case ReadyState.READY: return 'READY' 55 | case ReadyState.BLOCKED: return 'BLOCKED' 56 | } 57 | } 58 | 59 | export class Account { 60 | private _store: StoreWrapper 61 | private _account: string 62 | private _api: RippleAPI // TODO: rippleAPI type? 63 | private _currencyScale: number 64 | private _paychan?: Paychan 65 | private _clientPaychan?: Paychan 66 | private _clientChannel?: string 67 | private _funding: boolean 68 | private _claimIntervalId?: NodeJS.Timer 69 | private _log: any 70 | private _state: ReadyState 71 | 72 | constructor (opts: AccountParams) { 73 | this._store = opts.store 74 | this._account = opts.account 75 | this._api = opts.api 76 | this._currencyScale = opts.currencyScale 77 | this._funding = false 78 | this._log = opts.log 79 | this._state = ReadyState.INITIAL 80 | } 81 | 82 | xrpToBase (amount: BigNumber.Value): string { 83 | return new BigNumber(amount) 84 | .times(Math.pow(10, this._currencyScale)) 85 | .toString() 86 | } 87 | 88 | getAccount (): string { 89 | return this._account 90 | } 91 | 92 | getPaychan (): any { 93 | return this._paychan 94 | } 95 | 96 | getClientPaychan (): Paychan | void { 97 | return this._clientPaychan 98 | } 99 | 100 | setClaimIntervalId (claimIntervalId: NodeJS.Timer) { 101 | this._claimIntervalId = claimIntervalId 102 | } 103 | 104 | getClaimIntervalId (): NodeJS.Timer | void { 105 | return this._claimIntervalId 106 | } 107 | 108 | getLastClaimedAmount (): string { 109 | return this._store.get(LAST_CLAIMED(this._account)) || '0' 110 | } 111 | 112 | setLastClaimedAmount (amount: string) { 113 | this._store.set(LAST_CLAIMED(this._account), amount) 114 | } 115 | 116 | isFunding (): boolean { 117 | return this._funding 118 | } 119 | 120 | setFunding (funding: boolean) { 121 | this._funding = funding 122 | } 123 | 124 | async connect (): Promise { 125 | this._assertState(ReadyState.INITIAL) 126 | 127 | await Promise.all([ 128 | this._store.load(BALANCE(this._account)), 129 | this._store.loadObject(INCOMING_CLAIM(this._account)), 130 | this._store.load(CHANNEL(this._account)), 131 | this._store.load(IS_BLOCKED(this._account)), 132 | this._store.load(BLOCK_REASON(this._account)), 133 | this._store.load(CLIENT_CHANNEL(this._account)), 134 | this._store.load(OUTGOING_BALANCE(this._account)), 135 | this._store.load(LAST_CLAIMED(this._account)) 136 | ]) 137 | 138 | if (this._store.get(IS_BLOCKED(this._account)) === 'true') { 139 | this._state = ReadyState.BLOCKED 140 | return 141 | } 142 | 143 | this._state = ReadyState.LOADING_CHANNEL 144 | return this._connectChannel() 145 | } 146 | 147 | async _connectChannel (): Promise { 148 | this._assertState(ReadyState.LOADING_CHANNEL) 149 | 150 | const channelId = this._store.get(CHANNEL(this._account)) 151 | if (channelId) { 152 | try { 153 | const paychan = await this._api.getPaymentChannel(channelId) as Paychan 154 | this._paychan = paychan 155 | this.setLastClaimedAmount(this.xrpToBase(paychan.balance)) 156 | 157 | this._state = ReadyState.LOADING_CLIENT_CHANNEL 158 | return this._connectClientChannel() 159 | } catch (e) { 160 | this._log.error('failed to load channel entry. error=' + e.message) 161 | if (e.name === 'RippledError' && e.message === 'entryNotFound') { 162 | this._log.error('removing channel because it has been deleted') 163 | this.block(true, 'channel cannot be loaded. channelId=' + channelId) 164 | this.deleteChannel() 165 | return // TODO: do we need to do anything with the client channel still? 166 | } else if (e.name === 'TimeoutError') { 167 | // TODO: should this apply for all other errors too? 168 | this._log.error('timed out loading channel. retrying. account=' + this.getAccount()) 169 | await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)) 170 | return this._connectChannel() 171 | } 172 | } 173 | } else { 174 | this._state = ReadyState.ESTABLISHING_CHANNEL 175 | } 176 | } 177 | 178 | async _connectClientChannel (): Promise { 179 | this._assertState(ReadyState.LOADING_CLIENT_CHANNEL) 180 | 181 | const clientChannelId = this._store.get(CLIENT_CHANNEL(this._account)) 182 | if (clientChannelId) { 183 | try { 184 | this._clientPaychan = await this._api.getPaymentChannel(clientChannelId) as Paychan 185 | if (this.getOutgoingBalance().lt(this.xrpToBase(this._clientPaychan.balance))) { 186 | this.setOutgoingBalance(this.xrpToBase(this._clientPaychan.balance)) 187 | } 188 | 189 | this._state = ReadyState.READY 190 | } catch (e) { 191 | this._log.error('failed to load client channel entry. error=' + e.message) 192 | if (e.name === 'RippledError' && e.message === 'entryNotFound') { 193 | this._log.error('blocking account because client channel cannot be loaded.') 194 | this.block(true, 'client channel cannot be loaded. clientChannelId=' + clientChannelId) 195 | return // TODO: do we need to do anything with the client channel still? 196 | } else if (e.name === 'TimeoutError') { 197 | this._log.error('timed out loading client channel. retrying. account=' + this.getAccount()) 198 | await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)) 199 | return this._connectClientChannel() 200 | } 201 | } 202 | } else { 203 | // in this scenario we have a channel but no client channel. that means we 204 | // should make one 205 | this._state = ReadyState.ESTABLISHING_CLIENT_CHANNEL 206 | } 207 | } 208 | 209 | disconnect () { 210 | this._state = ReadyState.BLOCKED 211 | this._store.unload(BALANCE(this._account)) 212 | this._store.unload(INCOMING_CLAIM(this._account)) 213 | this._store.unload(CHANNEL(this._account)) 214 | this._store.unload(IS_BLOCKED(this._account)) 215 | this._store.unload(BLOCK_REASON(this._account)) 216 | this._store.unload(CLIENT_CHANNEL(this._account)) 217 | this._store.unload(OUTGOING_BALANCE(this._account)) 218 | const interval = this.getClaimIntervalId() 219 | if (interval) clearInterval(interval) 220 | } 221 | 222 | getBalance () { 223 | return new BigNumber(this._store.get(BALANCE(this._account)) || '0') 224 | } 225 | 226 | getIncomingClaim (): Claim { 227 | const paychanAmount = new BigNumber(this.getLastClaimedAmount()) 228 | const storedClaim = this._store.getObject(INCOMING_CLAIM(this._account)) as Claim || 229 | { amount: '0' } 230 | 231 | if (paychanAmount.gt(storedClaim.amount)) { 232 | return { amount: paychanAmount.toString() } 233 | } else { 234 | return storedClaim 235 | } 236 | } 237 | 238 | getChannel (): string { 239 | const channel = this._store.get(CHANNEL(this._account)) 240 | if (!channel) { 241 | throw new Error('channel does not exist on this account') 242 | } 243 | 244 | return channel 245 | } 246 | 247 | isBlocked () { 248 | return this._state === ReadyState.BLOCKED || 249 | this._store.get(IS_BLOCKED(this._account)) === 'true' 250 | } 251 | 252 | getBlockReason () { 253 | return this._state === ReadyState.BLOCKED && 254 | (this._store.get(BLOCK_REASON(this._account)) || DEFAULT_BLOCK_REASON) 255 | } 256 | 257 | getClientChannel () { 258 | const clientChannel = this._store.get(CLIENT_CHANNEL(this._account)) 259 | if (!clientChannel) { 260 | throw new Error('clientChannel does not exist on this account') 261 | } 262 | 263 | return clientChannel 264 | } 265 | 266 | getOwedBalance () { 267 | return new BigNumber(this._store.get(OWED_BALANCE(this._account)) || '0') 268 | } 269 | 270 | setOwedBalance (balance: string) { 271 | return this._store.set(OWED_BALANCE(this._account), balance) 272 | } 273 | 274 | getOutgoingBalance () { 275 | return new BigNumber(this._store.get(OUTGOING_BALANCE(this._account)) || '0') 276 | } 277 | 278 | setBalance (balance: string) { 279 | return this._store.set(BALANCE(this._account), balance) 280 | } 281 | 282 | setIncomingClaim (incomingClaim: Claim) { 283 | return this._store.set(INCOMING_CLAIM(this._account), incomingClaim) 284 | } 285 | 286 | prepareChannel () { 287 | this._assertState(ReadyState.ESTABLISHING_CHANNEL) 288 | this._state = ReadyState.PREPARING_CHANNEL 289 | } 290 | 291 | resetChannel () { 292 | this._assertState(ReadyState.PREPARING_CHANNEL) 293 | this._state = ReadyState.ESTABLISHING_CHANNEL 294 | } 295 | 296 | async setChannel (channel: string, paychan: Paychan) { 297 | this._assertState(ReadyState.PREPARING_CHANNEL) 298 | this._paychan = paychan 299 | this.setLastClaimedAmount(this.xrpToBase(paychan.balance)) 300 | this._store.set(CHANNEL(this._account), channel) 301 | 302 | this._state = ReadyState.LOADING_CLIENT_CHANNEL 303 | return this._connectClientChannel() 304 | } 305 | 306 | reloadChannel (channel: string, paychan: Paychan) { 307 | if (this.getState() < ReadyState.LOADING_CLIENT_CHANNEL) { 308 | throw new Error('state must be at least LOADING_CLIENT_CHANNEL to reload channel details.' + 309 | ' state=' + this.getStateString()) 310 | } 311 | this._paychan = paychan 312 | this.setLastClaimedAmount(this.xrpToBase(paychan.balance)) 313 | } 314 | 315 | deleteChannel () { 316 | if (new BigNumber(this.getLastClaimedAmount()).lt(this.getIncomingClaim().amount)) { 317 | this._log.error('Critical Error! Full balance was not able to be claimed before channel deletion.' + 318 | ' claim=' + JSON.stringify(this._store.getObject(INCOMING_CLAIM(this._account))) + 319 | ' lastClaimedAmount=' + this.getLastClaimedAmount() + 320 | ' channelId=' + this._store.get(CHANNEL(this._account))) 321 | } 322 | 323 | const newBalance = new BigNumber(this.getBalance()) 324 | .minus(this.getLastClaimedAmount()) 325 | .toString() 326 | 327 | this.setBalance(newBalance) 328 | 329 | delete this._paychan 330 | 331 | this._store.delete(LAST_CLAIMED(this._account)) 332 | this._store.delete(INCOMING_CLAIM(this._account)) 333 | return this._store.delete(CHANNEL(this._account)) 334 | } 335 | 336 | block (isBlocked = true, reason = DEFAULT_BLOCK_REASON) { 337 | if (isBlocked) { 338 | this._state = ReadyState.BLOCKED 339 | this._store.set(BLOCK_REASON(this._account), reason) 340 | } 341 | return this._store.set(IS_BLOCKED(this._account), String(isBlocked)) 342 | } 343 | 344 | prepareClientChannel () { 345 | this._assertState(ReadyState.ESTABLISHING_CLIENT_CHANNEL) 346 | this._state = ReadyState.PREPARING_CLIENT_CHANNEL 347 | } 348 | 349 | resetClientChannel () { 350 | this._assertState(ReadyState.PREPARING_CLIENT_CHANNEL) 351 | this._state = ReadyState.ESTABLISHING_CLIENT_CHANNEL 352 | } 353 | 354 | setClientChannel (clientChannel: string, clientPaychan: Paychan) { 355 | this._assertState(ReadyState.PREPARING_CLIENT_CHANNEL) 356 | 357 | this._clientPaychan = clientPaychan 358 | if (this.getOutgoingBalance().lt(this.xrpToBase(this._clientPaychan.balance))) { 359 | this.setOutgoingBalance(this.xrpToBase(this._clientPaychan.balance)) 360 | } 361 | 362 | this._store.set(CLIENT_CHANNEL(this._account), clientChannel) 363 | this._state = ReadyState.READY 364 | } 365 | 366 | reloadClientChannel (clientChannel: string, clientPaychan: Paychan) { 367 | this._assertState(ReadyState.READY) 368 | this._clientPaychan = clientPaychan 369 | this._store.set(CLIENT_CHANNEL(this._account), clientChannel) 370 | } 371 | 372 | setOutgoingBalance (outgoingBalance: string) { 373 | return this._store.set(OUTGOING_BALANCE(this._account), outgoingBalance) 374 | } 375 | 376 | isReady () { 377 | return this._state === ReadyState.READY 378 | } 379 | 380 | getState () { 381 | return this._state 382 | } 383 | 384 | getStateString () { 385 | return stateToString(this._state) 386 | } 387 | 388 | private _assertState (state: ReadyState) { 389 | if (this._state !== state) { 390 | throw new Error(`account must be in state ${stateToString(state)}.` + 391 | ' state=' + this.getStateString() + 392 | ' account=' + this.getAccount()) 393 | } 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import * as IlpPacket from 'ilp-packet' 4 | 5 | const { Errors } = IlpPacket 6 | 7 | import { RippleAPI } from 'ripple-lib' 8 | import BigNumber from 'bignumber.js' 9 | import * as ILDCP from 'ilp-protocol-ildcp' 10 | import StoreWrapper from './store-wrapper' 11 | import { Account, ReadyState } from './account' 12 | 13 | import { 14 | Protocol, 15 | BtpData, 16 | Claim, 17 | Paychan, 18 | Store 19 | } from './util' 20 | 21 | const sodium = require('sodium-universal') 22 | const BtpPacket = require('btp-packet') 23 | const MiniAccountsPlugin = require('ilp-plugin-mini-accounts') 24 | 25 | const OUTGOING_CHANNEL_DEFAULT_AMOUNT = Math.pow(10, 6) // 1 XRP 26 | const MIN_INCOMING_CHANNEL = 10000000 27 | const ASSET_CODE = 'XRP' 28 | 29 | import * as debug from 'debug' 30 | import createLogger = require('ilp-logger') 31 | const DEBUG_NAMESPACE = 'ilp-plugin-xrp-server' 32 | 33 | const CHANNEL_KEYS = 'ilp-plugin-multi-xrp-paychan-channel-keys' 34 | const { 35 | createSubmitter, 36 | util, 37 | ChannelWatcher 38 | } = require('ilp-plugin-xrp-paychan-shared') 39 | 40 | function ilpAddressToAccount (prefix: string, ilpAddress: string) { 41 | if (ilpAddress.substr(0, prefix.length) !== prefix) { 42 | throw new Error('ILP address (' + ilpAddress + ') must start with prefix (' + prefix + ')') 43 | } 44 | 45 | return ilpAddress.substr(prefix.length).split('.')[0] 46 | } 47 | 48 | export interface ExtraInfo { 49 | address: string 50 | account: string 51 | currencyScale: number 52 | channel?: string 53 | clientChannel?: string 54 | } 55 | 56 | export enum AdminCommandName { 57 | BLOCK = 'block', 58 | SETTLE = 'settle' 59 | } 60 | 61 | export interface AdminCommand { 62 | command: AdminCommandName, 63 | account: string, 64 | amount?: string 65 | } 66 | 67 | export interface IlpPluginAsymServerOpts { 68 | assetScale?: number 69 | currencyScale?: number 70 | maxPacketAmount?: string 71 | xrpServer: string 72 | secret: string 73 | address: string 74 | maxBalance?: string 75 | bandwidth?: string 76 | claimInterval?: number 77 | outgoingChannelAmount?: number 78 | minIncomingChannelAmount?: number 79 | _store: Store 80 | maxFeePercent?: string, 81 | log: any 82 | } 83 | 84 | export default class IlpPluginAsymServer extends MiniAccountsPlugin { 85 | static version: number = 2 86 | private _maxPacketAmount: BigNumber 87 | private _currencyScale: number 88 | private _xrpServer: string 89 | private _secret: string 90 | private _address: string 91 | private _api: RippleAPI 92 | private _watcher: any 93 | private _bandwidth: string 94 | private _claimInterval: number 95 | private _outgoingChannelAmount: number 96 | private _minIncomingChannelAmount: number 97 | private _store: StoreWrapper 98 | private _txSubmitter: any 99 | private _maxFeePercent: string 100 | private _channelToAccount: Map 101 | private _accounts: Map 102 | private _log: any 103 | 104 | constructor (opts: IlpPluginAsymServerOpts) { 105 | super(opts) 106 | 107 | if (opts.assetScale && opts.currencyScale) { 108 | throw new Error('opts.assetScale is an alias for opts.currencyScale;' + 109 | 'only one must be specified') 110 | } 111 | 112 | const currencyScale = opts.assetScale || opts.currencyScale 113 | 114 | // Typescript thinks we don't need to check this, but it's being called 115 | // from regular javascript so we still need this. 116 | /* tslint:disable-next-line:strict-type-predicates */ 117 | if (typeof currencyScale !== 'number' && currencyScale !== undefined) { 118 | throw new Error('currency scale must be a number if specified.' + 119 | ' type=' + (typeof currencyScale) + 120 | ' value=' + currencyScale) 121 | } 122 | 123 | this._maxPacketAmount = new BigNumber(opts.maxPacketAmount || 'Infinity') 124 | this._currencyScale = (typeof currencyScale === 'number') ? currencyScale : 6 125 | this._xrpServer = opts.xrpServer 126 | this._secret = opts.secret 127 | this._address = opts.address 128 | this._api = new RippleAPI({ server: this._xrpServer }) 129 | this._watcher = new ChannelWatcher(10 * 60 * 1000, this._api) 130 | this._bandwidth = opts.maxBalance || opts.bandwidth || '0' // TODO: deprecate _bandwidth 131 | this._claimInterval = opts.claimInterval || util.DEFAULT_CLAIM_INTERVAL 132 | this._outgoingChannelAmount = opts.outgoingChannelAmount || OUTGOING_CHANNEL_DEFAULT_AMOUNT 133 | this._minIncomingChannelAmount = opts.minIncomingChannelAmount || MIN_INCOMING_CHANNEL 134 | this._store = new StoreWrapper(opts._store) 135 | this._txSubmitter = createSubmitter(this._api, this._address, this._secret) 136 | this._maxFeePercent = opts.maxFeePercent || '0.01' 137 | 138 | this._channelToAccount = new Map() 139 | this._accounts = new Map() 140 | 141 | this._watcher.on('channelClose', async (channelId: string, paychan: Paychan) => { 142 | try { 143 | await this._channelClose(channelId) 144 | } catch (e) { 145 | console.error('ERROR: failed to close channel. channel=' + channelId + 146 | ' error=' + e.stack) 147 | } 148 | }) 149 | 150 | this._log = opts.log || createLogger(DEBUG_NAMESPACE) 151 | this._log.trace = this._log.trace || debug(DEBUG_NAMESPACE + ':trace') 152 | } 153 | 154 | xrpToBase (amount: BigNumber.Value) { 155 | return new BigNumber(amount) 156 | .times(Math.pow(10, this._currencyScale)) 157 | .toString() 158 | } 159 | 160 | baseToXrp (amount: BigNumber.Value) { 161 | return new BigNumber(amount) 162 | .div(Math.pow(10, this._currencyScale)) 163 | .toFixed(6, BigNumber.ROUND_UP) 164 | } 165 | 166 | sendTransfer () { 167 | this._log.debug('send transfer no-op') 168 | } 169 | 170 | _validatePaychanDetails (paychan: Paychan) { 171 | const settleDelay = paychan.settleDelay 172 | if (settleDelay < util.MIN_SETTLE_DELAY) { 173 | this._log.warn(`incoming payment channel has a too low settle delay of ${settleDelay.toString()}` + 174 | ` seconds. Minimum settle delay is ${util.MIN_SETTLE_DELAY} seconds.`) 175 | throw new Error('settle delay of incoming payment channel too low') 176 | } 177 | 178 | if (paychan.cancelAfter) { 179 | this._log.warn('got incoming payment channel with cancelAfter') 180 | throw new Error('channel must not have a cancelAfter') 181 | } 182 | 183 | if (paychan.expiration) { 184 | this._log.warn('got incoming payment channel with expiration') 185 | throw new Error('channel must not be in the process of closing') 186 | } 187 | 188 | if (paychan.destination !== this._address) { 189 | this._log.warn('incoming channel destination is not our address: ' + 190 | paychan.destination) 191 | throw new Error('Channel destination address wrong') 192 | } 193 | } 194 | 195 | _getAccount (from: string) { 196 | const accountName = ilpAddressToAccount(this._prefix, from) 197 | let account = this._accounts.get(accountName) 198 | 199 | if (!account) { 200 | account = new Account({ 201 | account: accountName, 202 | store: this._store, 203 | api: this._api, 204 | currencyScale: this._currencyScale, 205 | log: this._log 206 | }) 207 | this._accounts.set(accountName, account) 208 | } 209 | 210 | return account 211 | } 212 | 213 | _extraInfo (account: Account) { 214 | const info: ExtraInfo = { 215 | address: this._address, 216 | account: this._prefix + account.getAccount(), 217 | currencyScale: this._currencyScale 218 | } 219 | 220 | if (account.getState() > ReadyState.PREPARING_CHANNEL) { 221 | info.channel = account.getChannel() 222 | } 223 | 224 | if (account.isReady()) { 225 | info.clientChannel = account.getClientChannel() 226 | } 227 | 228 | return info 229 | } 230 | 231 | async _channelClaim (account: Account, close: boolean = false) { 232 | this._log.trace('creating claim for claim.' + 233 | ' account=' + account.getAccount() + 234 | ' channel=' + account.getChannel() + 235 | ' close=' + close) 236 | 237 | const channel = account.getChannel() 238 | if (!channel) { 239 | throw new Error('no channel exists. ' + 240 | 'account=' + account.getAccount()) 241 | } 242 | 243 | const claim = account.getIncomingClaim() 244 | const publicKey = account.getPaychan().publicKey 245 | 246 | this._log.trace('creating claim tx. account=' + account.getAccount()) 247 | 248 | try { 249 | this._log.trace('querying to make sure a claim is reasonable') 250 | const xrpClaimAmount = this.baseToXrp(claim.amount.toString()) 251 | const paychan = await this._api.getPaymentChannel(channel) 252 | 253 | if (new BigNumber(paychan.balance).gte(xrpClaimAmount)) { 254 | const baseBalance = this.xrpToBase(paychan.balance) 255 | account.setLastClaimedAmount(baseBalance) 256 | this._log.trace('claim was lower than channel balance.' + 257 | ' balance=' + baseBalance + 258 | ' claim=' + claim.amount.toString()) 259 | return 260 | } 261 | 262 | if (!claim.signature) { 263 | throw new Error('claim has no signature') 264 | } 265 | 266 | await this._txSubmitter.submit('preparePaymentChannelClaim', { 267 | balance: xrpClaimAmount, 268 | signature: claim.signature.toUpperCase(), 269 | publicKey, 270 | close, 271 | channel 272 | }) 273 | } catch (err) { 274 | throw new Error('Error submitting claim. err=' + err) 275 | } 276 | } 277 | 278 | async _channelClose (channelId: string) { 279 | const account = this._channelToAccount.get(channelId) 280 | if (!account) { 281 | throw new Error('cannot close channel of nonexistant account. ' + 282 | 'channelId=' + channelId) 283 | } 284 | 285 | // disable the account once the channel is closing 286 | account.block(true, 'channel is closing/closed. channelId=' + channelId) 287 | await this._channelClaim(account, true) 288 | } 289 | 290 | async _preConnect () { 291 | await this._api.connect() 292 | await this._api.connection.request({ 293 | command: 'subscribe', 294 | accounts: [ this._address ] 295 | }) 296 | } 297 | 298 | // TODO: also implement cleanup logic 299 | async _connect (address: string, btpData: BtpData) { 300 | const { requestId, data } = btpData 301 | const account = this._getAccount(address) 302 | 303 | if (account.getState() === ReadyState.INITIAL) { 304 | await account.connect() 305 | } 306 | 307 | if (account.isBlocked()) { 308 | throw new Error('cannot connect to blocked account. ' + 309 | 'reconfigure your uplink to connect with a new payment channel.' + 310 | ' reason=' + account.getBlockReason()) 311 | } 312 | 313 | if (account.getState() > ReadyState.PREPARING_CHANNEL) { 314 | try { 315 | this._validatePaychanDetails(account.getPaychan()) 316 | this._channelToAccount.set(account.getChannel(), account) 317 | await this._watcher.watch(account.getChannel()) 318 | await this._registerAutoClaim(account) 319 | } catch (e) { 320 | this._log.debug('deleting channel because of failed validate.' + 321 | ' account=' + account.getAccount() + 322 | ' channel=' + account.getChannel() + 323 | ' error=', e) 324 | try { 325 | await this._channelClaim(account) 326 | account.deleteChannel() 327 | } catch (err) { 328 | this._log.error('could not delete channel. error=', err) 329 | } 330 | this._log.trace('blocking account. account=' + account.getAccount()) 331 | account.block(true, 'failed to validate channel.' + 332 | ' channelId=' + account.getChannel() + 333 | ' error=' + e.message) 334 | } 335 | } 336 | 337 | return null 338 | } 339 | 340 | async _fundOutgoingChannel (account: Account, primary: Protocol): Promise { 341 | if (account.getState() === ReadyState.READY) { 342 | this._log.warn('outgoing channel already exists') 343 | return account.getClientChannel() 344 | } else if (account.getState() !== ReadyState.ESTABLISHING_CLIENT_CHANNEL) { 345 | throw new Error('account must be in ESTABLISHING_CLIENT_CHANNEL state to create client channel.' + 346 | ' state=' + account.getStateString()) 347 | } 348 | 349 | // lock the account's client channel field so a second call to this won't 350 | // overwrite or race 351 | account.prepareClientChannel() 352 | 353 | let clientChannelId 354 | let clientPaychan 355 | 356 | try { 357 | const outgoingAccount = primary.data.toString() 358 | 359 | this._log.trace('creating outgoing channel fund transaction') 360 | 361 | const keyPair = { 362 | publicKey: sodium.sodium_malloc(sodium.crypto_sign_PUBLICKEYBYTES), 363 | secretKey: sodium.sodium_malloc(sodium.crypto_sign_SECRETKEYBYTES) 364 | } 365 | sodium.crypto_sign_seed_keypair( 366 | keyPair.publicKey, 367 | keyPair.secretKey, 368 | util.hmac(this._secret, CHANNEL_KEYS + account.getAccount()) 369 | ) 370 | 371 | const publicKey = 'ED' + keyPair.publicKey.toString('hex').toUpperCase() 372 | const txTag = util.randomTag() 373 | 374 | const ev = await this._txSubmitter.submit('preparePaymentChannelCreate', { 375 | amount: util.dropsToXrp(this._outgoingChannelAmount), 376 | destination: outgoingAccount, 377 | settleDelay: util.MIN_SETTLE_DELAY, 378 | publicKey, 379 | sourceTag: txTag 380 | }) 381 | 382 | clientChannelId = util.computeChannelId( 383 | ev.transaction.Account, 384 | ev.transaction.Destination, 385 | ev.transaction.Sequence) 386 | 387 | this._log.trace('created outgoing channel. channel=', clientChannelId) 388 | account.setOutgoingBalance('0') 389 | 390 | clientPaychan = await this._api.getPaymentChannel(clientChannelId) as Paychan 391 | } catch (e) { 392 | // relinquish lock on the client channel field 393 | account.resetClientChannel() 394 | throw e 395 | } 396 | 397 | account.setClientChannel(clientChannelId, clientPaychan) 398 | return clientChannelId 399 | } 400 | 401 | async _handleCustomData (from: string, message: BtpData) { 402 | const account = this._getAccount(from) 403 | const protocols = message.data.protocolData 404 | if (!protocols.length) return undefined 405 | 406 | const getLastClaim = protocols.find((p: Protocol) => p.protocolName === 'last_claim') 407 | const fundChannel = protocols.find((p: Protocol) => p.protocolName === 'fund_channel') 408 | const channelProtocol = protocols.find((p: Protocol) => p.protocolName === 'channel') 409 | const channelSignatureProtocol = protocols.find((p: Protocol) => p.protocolName === 'channel_signature') 410 | const ilp = protocols.find((p: Protocol) => p.protocolName === 'ilp') 411 | const info = protocols.find((p: Protocol) => p.protocolName === 'info') 412 | 413 | // TODO: STATE ASSERTION HERE 414 | if (getLastClaim) { 415 | this._log.trace('got request for last claim. claim=', account.getIncomingClaim()) 416 | return [{ 417 | protocolName: 'last_claim', 418 | contentType: BtpPacket.MIME_APPLICATION_JSON, 419 | data: Buffer.from(JSON.stringify(account.getIncomingClaim())) 420 | }] 421 | } 422 | 423 | if (info) { 424 | this._log.trace('got info request') 425 | return [{ 426 | protocolName: 'info', 427 | contentType: BtpPacket.MIME_APPLICATION_JSON, 428 | data: Buffer.from(JSON.stringify(this._extraInfo(account))) 429 | }] 430 | } 431 | 432 | if (channelProtocol) { 433 | // TODO: should this be allowed so long as the channel exists already or is being established? 434 | if (!account.isReady() && account.getState() !== ReadyState.ESTABLISHING_CHANNEL) { 435 | throw new Error('channel protocol can only be used in READY and ESTABLISHING_CHANNEL states.' + 436 | ' state=' + account.getStateString()) 437 | } 438 | 439 | this._log.trace('got message for incoming channel. account=', account.getAccount()) 440 | const channel = channelProtocol.data 441 | .toString('hex') 442 | .toUpperCase() 443 | 444 | if (!channelSignatureProtocol) { 445 | throw new Error(`got channel without signature of channel ownership.`) 446 | } 447 | 448 | if (account.getState() > ReadyState.PREPARING_CHANNEL) { 449 | if (account.getChannel() !== channel) { 450 | throw new Error(`there is already an existing channel on this account 451 | and it doesn't match the 'channel' protocolData`) 452 | } else { 453 | // if we already have a channel, that means we should just reload the details 454 | const paychan = await this._api.getPaymentChannel(channel) as Paychan 455 | account.reloadChannel(channel, paychan) 456 | // don't return here because the fund_channel protocol may still need to be processed 457 | } 458 | } else { 459 | // lock to make sure we don't have this going two times 460 | account.prepareChannel() 461 | 462 | let paychan 463 | 464 | try { 465 | // Because this reloads channel details even if the channel exists, 466 | // we can use it to refresh the channel details after extra funds are 467 | // added 468 | paychan = await this._api.getPaymentChannel(channel) as Paychan 469 | 470 | // TODO: factor reverse-channel lookup into other class? 471 | await this._store.load('channel:' + channel) 472 | const accountForChannel = this._store.get('channel:' + channel) 473 | if (accountForChannel && account.getAccount() !== accountForChannel) { 474 | throw new Error(`this channel has already been associated with a ` + 475 | `different account. account=${account.getAccount()} associated=${accountForChannel}`) 476 | } 477 | 478 | const fullAccount = this._prefix + account.getAccount() 479 | const encodedChannelProof = util.encodeChannelProof(channel, fullAccount) 480 | const isValid = sodium.crypto_sign_verify_detached( 481 | channelSignatureProtocol.data, 482 | encodedChannelProof, 483 | Buffer.from(paychan.publicKey.substring(2), 'hex') 484 | ) 485 | if (!isValid) { 486 | throw new Error(`invalid signature for proving channel ownership. ` + 487 | `account=${account.getAccount()} channelId=${channel}`) 488 | } 489 | 490 | // TODO: fix the ripple-lib FormattedPaymentChannel type to be compatible 491 | this._validatePaychanDetails(paychan) 492 | } catch (e) { 493 | 494 | // if we failed to load or validate the channel, then we need to reset the state 495 | // of this account to 'ESTABLISHING_CHANNEL' 496 | account.resetChannel() 497 | throw e 498 | } 499 | 500 | this._channelToAccount.set(channel, account) 501 | this._store.set('channel:' + channel, account.getAccount()) 502 | await account.setChannel(channel, paychan) 503 | 504 | await this._watcher.watch(channel) 505 | await this._registerAutoClaim(account) 506 | this._log.trace('registered payment channel. account=', account.getAccount()) 507 | } 508 | } 509 | 510 | if (fundChannel) { 511 | if (account.getState() !== ReadyState.ESTABLISHING_CLIENT_CHANNEL) { 512 | throw new Error('fund protocol can only be used in ESTABLISHING_CLIENT_CHANNEL state.' + 513 | ' state=' + account.getStateString()) 514 | } 515 | 516 | if (new BigNumber(util.xrpToDrops(account.getPaychan().amount)).lt(this._minIncomingChannelAmount)) { 517 | this._log.debug('denied outgoing paychan request; not enough has been escrowed') 518 | throw new Error('not enough has been escrowed in channel; must put ' + 519 | this._minIncomingChannelAmount + ' drops on hold') 520 | } 521 | 522 | this._log.info('an outgoing paychan has been authorized for ', account.getAccount(), '; establishing') 523 | const clientChannelId = await this._fundOutgoingChannel(account, fundChannel) 524 | return [{ 525 | protocolName: 'fund_channel', 526 | contentType: BtpPacket.MIME_APPLICATION_OCTET_STREAM, 527 | data: Buffer.from(clientChannelId, 'hex') 528 | }] 529 | } 530 | 531 | // in the case of an ilp message, we behave as a connector 532 | if (ilp) { 533 | try { 534 | if (ilp.data[0] === IlpPacket.Type.TYPE_ILP_PREPARE) { 535 | this._handleIncomingPrepare(account, ilp.data) 536 | } 537 | 538 | // TODO: don't do this, use connector only instead 539 | if (ilp.data[0] === IlpPacket.Type.TYPE_ILP_PREPARE && IlpPacket.deserializeIlpPrepare(ilp.data).destination === 'peer.config') { 540 | return [{ 541 | protocolName: 'ilp', 542 | contentType: BtpPacket.MIME_APPLICATION_OCTET_STRING, 543 | data: IlpPacket.serializeIlpFulfill({ 544 | fulfillment: Buffer.alloc(32), 545 | data: ILDCP.serializeIldcpResponse({ 546 | clientAddress: this._prefix + account.getAccount(), 547 | assetCode: ASSET_CODE, 548 | assetScale: this._currencyScale 549 | }) 550 | }) 551 | }] 552 | } 553 | 554 | let response = await this._dataHandler(ilp.data) 555 | 556 | if (ilp.data[0] === IlpPacket.Type.TYPE_ILP_PREPARE) { 557 | if (response[0] === IlpPacket.Type.TYPE_ILP_REJECT) { 558 | this._rejectIncomingTransfer(account, ilp.data) 559 | } else if (response[0] === IlpPacket.Type.TYPE_ILP_FULFILL) { 560 | // TODO: should await, or no? 561 | const { amount } = IlpPacket.deserializeIlpPrepare(ilp.data) 562 | if (amount !== '0' && this._moneyHandler) this._moneyHandler(amount) 563 | } 564 | } 565 | 566 | return this.ilpAndCustomToProtocolData({ ilp: response }) 567 | } catch (e) { 568 | return this.ilpAndCustomToProtocolData({ ilp: IlpPacket.errorToReject(this._prefix, e) }) 569 | } 570 | } 571 | 572 | return [] 573 | } 574 | 575 | async _isClaimProfitable (account: Account) { 576 | const lastClaimedAmount = account.getLastClaimedAmount() 577 | const amount = account.getIncomingClaim().amount 578 | const fee = new BigNumber(this.xrpToBase(await this._api.getFee())) 579 | const income = new BigNumber(amount).minus(lastClaimedAmount) 580 | 581 | this._log.trace('calculating auto-claim. account=' + account.getAccount(), 'amount=' + amount, 582 | 'lastClaimedAmount=' + lastClaimedAmount, 'fee=' + fee) 583 | 584 | return income.isGreaterThan(0) && fee.dividedBy(income).lte(this._maxFeePercent) 585 | } 586 | 587 | async _autoClaim (account: Account) { 588 | if (await this._isClaimProfitable(account)) { 589 | const amount = account.getIncomingClaim().amount 590 | this._log.trace('starting automatic claim. amount=' + amount + ' account=' + account.getAccount()) 591 | account.setLastClaimedAmount(amount) 592 | try { 593 | await this._channelClaim(account) 594 | this._log.trace('claimed funds. account=' + account.getAccount()) 595 | } catch (err) { 596 | this._log.warn('WARNING. Error on claim submission: ', err) 597 | } 598 | } 599 | } 600 | 601 | async _registerAutoClaim (account: Account) { 602 | if (account.getClaimIntervalId()) return 603 | 604 | this._log.trace('registering auto-claim. interval=' + this._claimInterval, 605 | 'account=' + account.getAccount()) 606 | 607 | account.setClaimIntervalId(global.setInterval( 608 | this._autoClaim.bind(this, account), 609 | this._claimInterval)) 610 | } 611 | 612 | _handleIncomingPrepare (account: Account, ilpData: Buffer) { 613 | const { amount } = IlpPacket.deserializeIlpPrepare(ilpData) 614 | 615 | if (!account.isReady()) { 616 | throw new Errors.UnreachableError('ilp packets will only be forwarded in READY state.' + 617 | ' state=' + account.getStateString()) 618 | } 619 | 620 | if (this._maxPacketAmount.isLessThan(amount)) { 621 | throw new Errors.AmountTooLargeError('Packet size is too large.', { 622 | receivedAmount: amount, 623 | maximumAmount: this._maxPacketAmount.toString() 624 | }) 625 | } 626 | 627 | const lastValue = account.getIncomingClaim().amount 628 | const prepared = account.getBalance() 629 | const newPrepared = prepared.plus(amount) 630 | const unsecured = newPrepared.minus(lastValue) 631 | this._log.trace(unsecured.toString(), 'unsecured; last claim is', 632 | lastValue.toString(), 'prepared amount', amount, 'newPrepared', 633 | newPrepared.toString(), 'prepared', prepared.toString()) 634 | 635 | if (unsecured.gt(this._bandwidth)) { 636 | throw new Errors.InsufficientLiquidityError('Insufficient bandwidth, used: ' + 637 | unsecured + ' max: ' + 638 | this._bandwidth) 639 | } 640 | 641 | if (newPrepared.gt(this.xrpToBase(account.getPaychan().amount))) { 642 | throw new Errors.InsufficientLiquidityError('Insufficient funds, have: ' + 643 | this.xrpToBase(account.getPaychan().amount) + 644 | ' need: ' + newPrepared.toString()) 645 | } 646 | 647 | account.setBalance(newPrepared.toString()) 648 | this._log.trace(`account ${account.getAccount()} debited ${amount} units, new balance ${newPrepared.toString()}`) 649 | } 650 | 651 | _rejectIncomingTransfer (account: Account, ilpData: Buffer) { 652 | const { amount } = IlpPacket.deserializeIlpPrepare(ilpData) 653 | const prepared = account.getBalance() 654 | const newPrepared = prepared.minus(amount) 655 | 656 | account.setBalance(newPrepared.toString()) 657 | this._log.trace(`account ${account.getAccount()} roll back ${amount} units, new balance ${newPrepared.toString()}`) 658 | } 659 | 660 | _sendPrepare (destination: string, parsedPacket: IlpPacket.IlpPacket) { 661 | const account = this._getAccount(destination) 662 | if (!account.isReady()) { 663 | throw new Errors.UnreachableError('account must be in READY state to receive packets.' + 664 | ' state=' + account.getStateString()) 665 | } 666 | } 667 | 668 | _handlePrepareResponse (destination: string, parsedResponse: IlpPacket.IlpPacket, preparePacket: { 669 | type: IlpPacket.Type.TYPE_ILP_PREPARE, 670 | typeString?: IlpPacket.Type.TYPE_ILP_PREPARE, 671 | data: IlpPacket.IlpPrepare 672 | }) { 673 | this._log.trace('got prepare response', parsedResponse) 674 | if (parsedResponse.type === IlpPacket.Type.TYPE_ILP_FULFILL) { 675 | if (preparePacket.data.amount === '0') { 676 | this._log.trace('validated fulfillment for zero-amount packet, not settling.') 677 | return 678 | } 679 | 680 | // send off a transfer in the background to settle 681 | this._log.trace('validated fulfillment. paying settlement.') 682 | util._requestId() 683 | .then((requestId: number) => { 684 | let protocolData 685 | let amount 686 | 687 | try { 688 | const owed = this._getAmountOwed(destination) 689 | amount = owed.plus(preparePacket.data.amount).toString() 690 | protocolData = this._sendMoneyToAccount( 691 | amount, 692 | destination) 693 | this._decreaseAmountOwed(owed.toString(), destination) 694 | } catch (e) { 695 | this._increaseAmountOwed(preparePacket.data.amount, destination) 696 | throw new Error('failed to create valid claim.' + 697 | ' error=' + e.message) 698 | } 699 | 700 | return this._call(destination, { 701 | type: BtpPacket.TYPE_TRANSFER, 702 | requestId, 703 | data: { 704 | amount, 705 | protocolData 706 | } 707 | }) 708 | }) 709 | .catch((e: Error) => { 710 | this._log.error(`failed to pay account. 711 | destination=${destination} 712 | error=${e && e.stack}`) 713 | }) 714 | } else if (parsedResponse.type === IlpPacket.Type.TYPE_ILP_REJECT) { 715 | if (parsedResponse.data.code === 'T04') { 716 | const owed = this._getAmountOwed(destination) 717 | this._log.trace('sending settlement on T04 to pay owed balance.' + 718 | ' destination=' + destination + 719 | ' owed=' + owed.toString()) 720 | 721 | util._requestId() 722 | .then((requestId: number) => { 723 | const protocolData = this._sendMoneyToAccount(owed.toString(), destination) 724 | this._decreaseAmountOwed(owed.toString(), destination) 725 | 726 | return this._call(destination, { 727 | type: BtpPacket.TYPE_TRANSFER, 728 | requestId, 729 | data: { 730 | amount: owed.toString(), 731 | protocolData 732 | } 733 | }) 734 | }) 735 | .catch((e: Error) => { 736 | this._log.error('failed to settle after T04.' + 737 | ` destination=${destination}` + 738 | ` owed=${owed.toString()}` + 739 | ` error=${e && e.stack}`) 740 | }) 741 | } 742 | } 743 | } 744 | 745 | _getAmountOwed (to: string) { 746 | const account = this._getAccount(to) 747 | return account.getOwedBalance() 748 | } 749 | 750 | _increaseAmountOwed (amount: string, to: string) { 751 | const account = this._getAccount(to) 752 | const owed = account.getOwedBalance() 753 | const newOwed = owed.plus(amount) 754 | account.setOwedBalance(newOwed.toString()) 755 | } 756 | 757 | _decreaseAmountOwed (amount: string, to: string) { 758 | const account = this._getAccount(to) 759 | const owed = account.getOwedBalance() 760 | const newOwed = owed.minus(amount) 761 | account.setOwedBalance(newOwed.toString()) 762 | } 763 | 764 | async sendMoney () { 765 | // NO-OP 766 | } 767 | 768 | _sendMoneyToAccount (transferAmount: string, to: string) { 769 | const account = this._getAccount(to) 770 | if (!account.isReady()) { 771 | this._log.error('tried to send settlement to account which is not connected.' + 772 | ' account=' + account.getAccount() + 773 | ' state=' + account.getStateString() + 774 | ' transferAmount=' + transferAmount) 775 | throw new Error('account is not initialized. account=' + account.getAccount()) 776 | } 777 | 778 | const currentBalance = account.getOutgoingBalance() 779 | const newBalance = currentBalance.plus(transferAmount) 780 | 781 | // sign a claim 782 | const clientChannel = account.getClientChannel() 783 | if (!clientChannel) { 784 | throw new Error('no client channel exists') 785 | } 786 | 787 | const clientPaychan = account.getClientPaychan() 788 | if (!clientPaychan) { 789 | throw new Error('no client channel details have been loaded') 790 | } 791 | 792 | const newDropBalance = util.xrpToDrops(this.baseToXrp(newBalance)) 793 | const encodedClaim = util.encodeClaim(newDropBalance.toString(), clientChannel) 794 | 795 | const keyPair = { 796 | publicKey: sodium.sodium_malloc(sodium.crypto_sign_PUBLICKEYBYTES), 797 | secretKey: sodium.sodium_malloc(sodium.crypto_sign_SECRETKEYBYTES) 798 | } 799 | sodium.crypto_sign_seed_keypair( 800 | keyPair.publicKey, 801 | keyPair.secretKey, 802 | util.hmac(this._secret, CHANNEL_KEYS + account.getAccount()) 803 | ) 804 | 805 | const signature = sodium.sodium_malloc(sodium.crypto_sign_BYTES) 806 | sodium.crypto_sign_detached(signature, encodedClaim, keyPair.secretKey) 807 | this._log.trace(`signing outgoing claim for ${newDropBalance.toString()} drops on ` + 808 | `channel ${clientChannel}`) 809 | 810 | const aboveThreshold = new BigNumber(util 811 | .xrpToDrops(clientPaychan.amount)) 812 | .minus(this._outgoingChannelAmount / 2) 813 | .lt(newDropBalance.toString()) 814 | 815 | // if the claim we're signing is for more than the channel's max balance 816 | // minus half the minimum balance, add some funds 817 | if (!account.isFunding() && aboveThreshold) { 818 | this._log.info('adding funds to channel. account=', account.getAccount()) 819 | account.setFunding(true) 820 | util.fundChannel({ 821 | api: this._api, 822 | channel: clientChannel, 823 | address: this._address, 824 | secret: this._secret, 825 | amount: this._outgoingChannelAmount 826 | }) 827 | .then(async () => { 828 | // reload channel details for the channel we just added funds to 829 | const clientPaychan = await this._api.getPaymentChannel(clientChannel) as Paychan 830 | account.reloadClientChannel(clientChannel, clientPaychan) 831 | 832 | account.setFunding(false) 833 | this._log.trace('completed fund tx. account=', account.getAccount()) 834 | await this._call(to, { 835 | type: BtpPacket.TYPE_MESSAGE, 836 | requestId: await util._requestId(), 837 | data: { protocolData: [{ 838 | protocolName: 'channel', 839 | contentType: BtpPacket.MIME_APPLICATION_OCTET_STREAM, 840 | data: Buffer.from(clientChannel, 'hex') 841 | }] } 842 | }) 843 | }) 844 | .catch((e: Error) => { 845 | this._log.error('funding tx/notify failed:', e) 846 | account.setFunding(false) 847 | }) 848 | } 849 | 850 | const aboveCapacity = new BigNumber(util 851 | .xrpToDrops(clientPaychan.amount)) 852 | .lt(newDropBalance.toString()) 853 | 854 | if (aboveCapacity) { 855 | throw new Error('channel does not have enough capacity to process claim.' + 856 | ' claimAmount=' + newDropBalance.toString() + 857 | ' clientPaychan.amount=' + util.xrpToDrops(clientPaychan.amount)) 858 | } 859 | 860 | account.setOutgoingBalance(newBalance.toString()) 861 | this._log.trace(`account ${account.getAccount()} added ${transferAmount} units, new balance ${newBalance}`) 862 | 863 | return [{ 864 | protocolName: 'claim', 865 | contentType: 2, 866 | data: Buffer.from(JSON.stringify({ 867 | amount: newBalance.toString(), 868 | signature: signature.toString('hex') 869 | })) 870 | }] 871 | } 872 | 873 | _handleClaim (account: Account, claim: Claim) { 874 | let valid = false 875 | 876 | // TODO: if the channel somehow is null, make sure this behaves OK 877 | const { amount, signature } = claim 878 | if (!signature) { 879 | throw new Error('signature must be provided on claim') 880 | } 881 | 882 | const dropAmount = util.xrpToDrops(this.baseToXrp(amount)) 883 | const encodedClaim = util.encodeClaim(dropAmount, account.getChannel()) 884 | this._log.trace('handling claim. account=' + account.getAccount(), 'amount=' + dropAmount) 885 | 886 | try { 887 | valid = sodium.crypto_sign_verify_detached( 888 | Buffer.from(signature, 'hex'), 889 | encodedClaim, 890 | Buffer.from(account.getPaychan().publicKey.substring(2), 'hex') 891 | ) 892 | } catch (err) { 893 | this._log.debug('verifying signature failed:', err.message) 894 | } 895 | 896 | // TODO: better reconciliation if claims are invalid 897 | if (!valid) { 898 | this._log.error(`got invalid claim signature ${signature} for amount ${dropAmount} drops`) 899 | /* throw new Error('got invalid claim signature ' + 900 | signature + ' for amount ' + amount + ' drops') */ 901 | throw new Error('Invalid claim: invalid signature') 902 | } 903 | 904 | // validate claim against balance 905 | const channelBalance = util.xrpToDrops(account.getPaychan().amount) 906 | this._log.trace('got channel balance. balance=' + channelBalance) 907 | if (new BigNumber(dropAmount).gt(channelBalance)) { 908 | const message = 'got claim for amount higher than channel balance. amount: ' + dropAmount + ', incoming channel balance: ' + channelBalance 909 | this._log.error(message) 910 | // throw new Error(message) 911 | throw new Error('Invalid claim: claim amount (' + dropAmount + ') exceeds channel balance (' + channelBalance + ')') 912 | } 913 | 914 | const lastValue = new BigNumber(account.getIncomingClaim().amount) 915 | this._log.trace('got last value. value=' + lastValue.toString(), 'signature=' + account.getIncomingClaim().signature) 916 | if (lastValue.lt(amount)) { 917 | this._log.trace('set new claim for amount', amount) 918 | account.setIncomingClaim(claim) 919 | } else if (lastValue.eq(amount)) { 920 | this._log.trace(`got claim for same amount as before. lastValue=${lastValue}, amount=${amount} (this is not necessarily a problem, but may represent an error on the client's side)`) 921 | } else { 922 | this._log.trace('last value is less than amount. lastValue=' + lastValue.toString(), 923 | 'amount=' + amount) 924 | } 925 | } 926 | 927 | _handleMoney (from: string, btpData: BtpData) { 928 | const account = this._getAccount(from) 929 | if (account.getState() < ReadyState.LOADING_CLIENT_CHANNEL) { 930 | this._log.error('got claim from account which is not fully connected.' + 931 | ' account=' + account.getAccount() + 932 | ' state=' + account.getStateString()) 933 | throw new Error('account is not initialized; claim cannot be accepted.' + 934 | ' account=' + account.getAccount()) 935 | } 936 | 937 | this._log.trace('handling money. account=' + account.getAccount()) 938 | 939 | // TODO: match the transfer amount 940 | const protocolData = btpData.data.protocolData 941 | if (!protocolData.length) { 942 | throw new Error('got transfer with empty protocolData.' + 943 | ' requestId=' + btpData.requestId) 944 | } 945 | 946 | const [ jsonClaim ] = btpData.data.protocolData 947 | .filter((p: Protocol) => p.protocolName === 'claim') 948 | if (!jsonClaim || !jsonClaim.data.length) { 949 | this._log.debug('no claim was supplied on transfer') 950 | throw new Error('No claim was supplied on transfer') 951 | } 952 | 953 | const claim = JSON.parse(jsonClaim.data.toString()) 954 | this._handleClaim(account, claim) 955 | } 956 | 957 | async _disconnect () { 958 | this._log.info('disconnecting accounts and api') 959 | for (const account of this._accounts.values()) { 960 | account.disconnect() 961 | } 962 | this._api.connection.removeAllListeners() 963 | await this._api.disconnect() 964 | await this._store.close() 965 | } 966 | 967 | async getAdminInfo () { 968 | const accountInfo = await this._api.getAccountInfo(this._address) 969 | const serverInfo = await this._api.getServerInfo() 970 | const reserved = Number(accountInfo.ownerCount) * 971 | Number(serverInfo.validatedLedger.reserveIncrementXRP) 972 | 973 | return { 974 | xrpAddress: this._address, 975 | xrpBalance: { 976 | total: accountInfo.xrpBalance, 977 | reserved: String(reserved), 978 | available: String(Number(accountInfo.xrpBalance) - reserved) 979 | }, 980 | clients: Array.from(this._accounts.values()).map(account => { 981 | try { 982 | return { 983 | account: account.getAccount(), 984 | xrpAddress: account.getPaychan().account, 985 | channel: account.getChannel(), 986 | channelBalance: account.getPaychan().balance, 987 | clientChannel: account.getClientChannel(), 988 | clientChannelBalance: this.baseToXrp(account.getOutgoingBalance()), 989 | state: account.getStateString() 990 | } 991 | } catch (e) { 992 | this._log.trace('skipping account.' + 993 | ' account=' + account.getAccount() + 994 | ' error=' + e.message) 995 | return null 996 | } 997 | }).filter(a => a) 998 | } 999 | } 1000 | 1001 | async sendAdminInfo (cmd: AdminCommand) { 1002 | const account = this._accounts.get(cmd.account) 1003 | if (!account) { 1004 | throw new Error('no account by that name. account=' + account) 1005 | } 1006 | 1007 | switch (cmd.command) { 1008 | case 'settle': 1009 | const amount = this.xrpToBase(cmd.amount || '0') 1010 | const requestId = await util._requestId() 1011 | const destination = this._prefix + account.getAccount() 1012 | await this._call(destination, { 1013 | type: BtpPacket.TYPE_TRANSFER, 1014 | requestId, 1015 | data: { 1016 | amount, 1017 | protocolData: this._sendMoneyToAccount( 1018 | amount, 1019 | destination) 1020 | } 1021 | }) 1022 | break 1023 | 1024 | case 'block': 1025 | account.block() 1026 | break 1027 | 1028 | default: 1029 | throw Error('unknown command') 1030 | } 1031 | 1032 | return {} 1033 | } 1034 | } 1035 | -------------------------------------------------------------------------------- /test/pluginSpec.js: -------------------------------------------------------------------------------- 1 | 'use strict' /* eslint-env mocha */ 2 | 3 | const BigNumber = require('bignumber.js') 4 | const BtpPacket = require('btp-packet') 5 | const IlpPacket = require('ilp-packet') 6 | const crypto = require('crypto') 7 | const chai = require('chai') 8 | const chaiAsPromised = require('chai-as-promised') 9 | chai.use(chaiAsPromised) 10 | const assert = chai.assert 11 | const sinon = require('sinon') 12 | const debug = require('debug')('ilp-plugin-xrp-asym-server:test') 13 | const sodium = require('sodium-universal') 14 | const EventEmitter = require('events') 15 | 16 | const PluginXrpAsymServer = require('..') 17 | const Store = require('./util/memStore') 18 | const { ReadyState } = require('../src/account') 19 | const { 20 | util 21 | } = require('ilp-plugin-xrp-paychan-shared') 22 | 23 | function createPlugin (opts = {}) { 24 | return new PluginXrpAsymServer(Object.assign({ 25 | prefix: 'test.example.', 26 | port: 3033, 27 | address: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 28 | secret: 'snRHsS3wLzbfeDNSVmtLKjE6sPMws', 29 | xrpServer: 'wss://s.altnet.rippletest.net:51233', 30 | claimInterval: 1000 * 30, 31 | bandwidth: 1000000, 32 | _store: new Store(null, 'test.example.'), 33 | debugHostIldcpInfo: { 34 | clientAddress: 'test.example', 35 | assetScale: 6, 36 | assetCode: 'XRP' 37 | } 38 | }, opts)) 39 | } 40 | 41 | describe('pluginSpec', () => { 42 | describe('constructor', function () { 43 | it('should throw if currencyScale is neither undefined nor a number', function () { 44 | assert.throws(() => createPlugin({ currencyScale: 'oaimwdaiowdoamwdaoiw' }), 45 | /currency scale must be a number if specified/) 46 | }) 47 | }) 48 | 49 | beforeEach(async function () { 50 | this.timeout(10000) 51 | this.sinon = sinon.sandbox.create() 52 | this.plugin = createPlugin() 53 | this.plugin._api.connect = () => Promise.resolve() 54 | this.plugin._api.connection = new EventEmitter() 55 | this.plugin._api.connection.request = () => Promise.resolve() 56 | this.plugin._api.disconnect = () => Promise.resolve() 57 | this.plugin._api.getPaymentChannel = () => Promise.resolve({ 58 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 59 | amount: '1', 60 | balance: '0', 61 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 62 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 63 | settleDelay: 3600, 64 | sourceTag: 1280434065, 65 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 66 | previousAffectingTransactionLedgerVersion: 6089142 67 | }) 68 | this.plugin._api.submit = () => Promise.resolve({ 69 | resultCode: 'tesSUCCESS' 70 | }) 71 | 72 | debug('connecting plugin') 73 | await this.plugin.connect() 74 | debug('connected') 75 | 76 | this.feeStub = this.sinon.stub(this.plugin._api, 'getFee') 77 | .resolves('0.000016') 78 | }) 79 | 80 | afterEach(async function () { 81 | this.sinon.restore() 82 | await this.plugin.disconnect() 83 | }) 84 | 85 | describe('validate channel details', () => { 86 | beforeEach(function () { 87 | this.paychan = { 88 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 89 | amount: '1', 90 | balance: '0', 91 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 92 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 93 | settleDelay: 3600, 94 | sourceTag: 1280434065, 95 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 96 | previousAffectingTransactionLedgerVersion: 6089142 97 | } 98 | }) 99 | 100 | it('should not accept a channel with a short settle delay', function () { 101 | this.paychan.settleDelay = 1 102 | assert.throws( 103 | () => this.plugin._validatePaychanDetails(this.paychan), 104 | 'settle delay of incoming payment channel too low') 105 | }) 106 | 107 | it('should not accept a channel with a cancelAfter', function () { 108 | this.paychan.cancelAfter = new Date(Date.now() + 50000000) 109 | assert.throws( 110 | () => this.plugin._validatePaychanDetails(this.paychan), 111 | 'channel must not have a cancelAfter') 112 | }) 113 | 114 | it('should not accept a channel with an expiration', function () { 115 | this.paychan.expiration = new Date(Date.now() + 50000000) 116 | assert.throws( 117 | () => this.plugin._validatePaychanDetails(this.paychan), 118 | 'channel must not be in the process of closing') 119 | }) 120 | 121 | it('should not accept a channel for someone else', function () { 122 | this.paychan.destination = this.paychan.account 123 | assert.throws( 124 | () => this.plugin._validatePaychanDetails(this.paychan), 125 | 'Channel destination address wrong') 126 | }) 127 | 128 | it('should accept a channel which does not have any flaws', function () { 129 | this.plugin._validatePaychanDetails(this.paychan) 130 | }) 131 | }) 132 | 133 | describe('set client channel', () => { 134 | beforeEach(async function () { 135 | this.accountId = '35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 136 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 137 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 138 | this.account = await this.plugin._getAccount(this.from) 139 | this.plugin._channelToAccount.set(this.channelId, this.account) 140 | this.paychan = { 141 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 142 | amount: '1', 143 | balance: '0', 144 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 145 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 146 | settleDelay: 3600, 147 | sourceTag: 1280434065, 148 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 149 | previousAffectingTransactionLedgerVersion: 6089142 150 | } 151 | this.account._state = ReadyState.PREPARING_CHANNEL 152 | await this.account.setChannel(this.channelId, this.paychan) 153 | this.account._state = ReadyState.PREPARING_CLIENT_CHANNEL 154 | }) 155 | 156 | it('should set outgoing balance to client channel balance if higher', async function () { 157 | assert.equal(this.account.getOutgoingBalance().toString(), '0') 158 | 159 | await this.account.setClientChannel(this.channelId, { balance: '1' }) 160 | assert.equal(this.account.getOutgoingBalance().toString(), '1000000') 161 | }) 162 | 163 | it('should set outgoing balance if higher when connecting client channel', async function () { 164 | this.account._state = ReadyState.LOADING_CLIENT_CHANNEL 165 | this.account._store.setCache(this.accountId + ':client_channel', 'client_channel_id') 166 | const stub = this.sinon.stub(this.plugin._api, 'getPaymentChannel') 167 | .resolves({ 168 | balance: '1' 169 | }) 170 | 171 | assert.equal(this.account.getOutgoingBalance().toString(), '0') 172 | await this.account._connectClientChannel() 173 | assert.equal(this.account.getOutgoingBalance().toString(), '1000000') 174 | }) 175 | 176 | it('should not set outgoing balance if higher when connecting client channel', async function () { 177 | this.account._state = ReadyState.LOADING_CLIENT_CHANNEL 178 | this.account._store.setCache(this.accountId + ':client_channel', 'client_channel_id') 179 | const stub = this.sinon.stub(this.plugin._api, 'getPaymentChannel') 180 | .resolves({ 181 | balance: '1' 182 | }) 183 | 184 | this.account.setOutgoingBalance('2000000') 185 | assert.equal(this.account.getOutgoingBalance().toString(), '2000000') 186 | await this.account._connectClientChannel() 187 | assert.equal(this.account.getOutgoingBalance().toString(), '2000000') 188 | }) 189 | 190 | it('should not set outgoing balance to client channel balance if not higher', async function () { 191 | this.account.setOutgoingBalance('2000000') 192 | assert.equal(this.account.getOutgoingBalance().toString(), '2000000') 193 | 194 | await this.account.setClientChannel(this.channelId, { balance: '1' }) 195 | assert.equal(this.account.getOutgoingBalance().toString(), '2000000') 196 | }) 197 | }) 198 | 199 | describe('channel close', () => { 200 | beforeEach(async function () { 201 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 202 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 203 | this.account = await this.plugin._getAccount(this.from) 204 | this.plugin._channelToAccount.set(this.channelId, this.account) 205 | this.paychan = { 206 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 207 | amount: '1', 208 | balance: '0', 209 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 210 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 211 | settleDelay: 3600, 212 | sourceTag: 1280434065, 213 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 214 | previousAffectingTransactionLedgerVersion: 6089142 215 | } 216 | this.account._state = ReadyState.PREPARING_CHANNEL 217 | await this.account.setChannel(this.channelId, this.paychan) 218 | this.account._state = ReadyState.PREPARING_CLIENT_CHANNEL 219 | await this.account.setClientChannel(this.channelId, { balance: '0' }) 220 | this.account.setIncomingClaim({ 221 | amount: 1000, 222 | signature: 'foo' 223 | }) 224 | }) 225 | 226 | it('should call channelClose when close event is emitted', async function () { 227 | const closeStub = this.sinon.stub(this.plugin, '_channelClose').resolves() 228 | await this.plugin._watcher.emitAsync('channelClose', this.channelId, this.paychan) 229 | assert.deepEqual(closeStub.firstCall.args, [ this.channelId ]) 230 | }) 231 | 232 | it('should submit the correct claim tx on channel close', async function () { 233 | this.account.setBalance('1000') 234 | const submitStub = this.sinon.stub(this.plugin._txSubmitter, 'submit').resolves() 235 | 236 | await this.plugin._channelClose(this.channelId) 237 | 238 | const [ method, args ] = submitStub.firstCall.args 239 | assert.equal(method, 'preparePaymentChannelClaim') 240 | assert.equal(args.balance, '0.001000') 241 | assert.equal(args.publicKey, 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D') 242 | assert.equal(args.channel, this.channelId) 243 | assert.equal(args.close, true) 244 | }) 245 | }) 246 | 247 | describe('handle custom data', () => { 248 | describe('channel protocol', () => { 249 | beforeEach(async function () { 250 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 251 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 252 | this.account = await this.plugin._getAccount(this.from) 253 | this.plugin._channelToAccount.set(this.channelId, this.account) 254 | this.paychan = { 255 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 256 | amount: '1', 257 | balance: '0', 258 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 259 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 260 | settleDelay: 3600, 261 | sourceTag: 1280434065, 262 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 263 | previousAffectingTransactionLedgerVersion: 6089142 264 | } 265 | this.account._state = ReadyState.PREPARING_CHANNEL 266 | await this.account.setChannel(this.channelId, this.paychan) 267 | this.plugin._channelToAccount.set(this.channelId, this.account) 268 | this.account._state = ReadyState.READY 269 | this.channelSig = '9F878049FBBF4CEBAB29E6D840984D777C10ECE0FB96B0A56FF2CBC90D38DD03571A7D95A7721173970D39E1FC8AE694D777F5363AA37950D91F9B4B7E179C00' 270 | this.channelProtocol = { 271 | data: { 272 | protocolData: [{ 273 | protocolName: 'channel', 274 | contentType: 0, 275 | data: Buffer.from(this.channelId, 'hex') }, 276 | { protocolName: 'channel_signature', 277 | contentType: 0, 278 | data: Buffer.from(this.channelSig, 'hex') }] 279 | } 280 | } 281 | }) 282 | 283 | it('does not race when assigning a channel to an account', async function () { 284 | this.account._state = ReadyState.ESTABLISHING_CHANNEL 285 | 286 | const getStub = this.sinon.stub(this.plugin._store._store, 'get') 287 | getStub.withArgs('channel:' + this.channelId).onFirstCall().callsFake(() => { 288 | // simulate another process writing to the cache while we wait for the store to return 289 | this.plugin._store.set('channel:' + this.channelId, 'some_other_account') 290 | return Promise.resolve(null) 291 | }) 292 | 293 | return assert.isRejected(this.plugin._handleCustomData(this.from, this.channelProtocol), 294 | 'this channel has already been associated with a different account. ' + 295 | 'account=35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak associated=some_other_account') 296 | }) 297 | 298 | it('don\'t throw if an account associates the same paychan again', async function () { 299 | const sendChannelProof = () => this.plugin._handleCustomData(this.from, this.channelProtocol) 300 | return assert.isFulfilled(Promise.all([sendChannelProof(), sendChannelProof()])) 301 | }) 302 | }) 303 | }) 304 | 305 | describe('connect account', () => { 306 | beforeEach(function () { 307 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 308 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 309 | this.account = '35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 310 | }) 311 | 312 | it('should check for existing paychan', async function () { 313 | const spy = this.sinon.spy(this.plugin._store._store, 'get') 314 | await this.plugin._connect(this.from, {}) 315 | assert.isTrue(spy.calledWith(this.account + ':channel')) 316 | }) 317 | 318 | it('should reject and give block reason if account blocked', async function () { 319 | const account = await this.plugin._getAccount(this.from) 320 | await account.connect() 321 | account.block(true, 'blocked for a reason') 322 | 323 | await assert.isRejected(this.plugin._connect(this.from, {}), 324 | /cannot connect to blocked account. reconfigure your uplink to connect with a new payment channel. reason=blocked for a reason/) 325 | }) 326 | 327 | it('should load details for existing paychan', async function () { 328 | const spy = this.sinon.spy(this.plugin._api, 'getPaymentChannel') 329 | this.plugin._store.setCache(this.account + ':channel', this.channelId) 330 | 331 | await this.plugin._connect(this.from, {}) 332 | assert.isTrue(spy.calledWith(this.channelId)) 333 | assert.equal(this.plugin._channelToAccount.get(this.channelId).getAccount(), this.account) 334 | }) 335 | 336 | it('should load lastClaimedAmount successfully', async function () { 337 | this.plugin._api.getPaymentChannel = () => Promise.resolve({ 338 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 339 | amount: '1', 340 | balance: '0.000050', 341 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 342 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 343 | settleDelay: 3600, 344 | sourceTag: 1280434065, 345 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 346 | previousAffectingTransactionLedgerVersion: 6089142 347 | }) 348 | 349 | this.plugin._store.setCache(this.account + ':channel', this.channelId) 350 | await this.plugin._connect(this.from, {}) 351 | assert.equal(this.plugin._channelToAccount.get(this.channelId).getLastClaimedAmount(), '50') 352 | }) 353 | 354 | it('should delete persisted paychan if it does not exist on the ledger', async function () { 355 | this.plugin._store.setCache(this.account + ':channel', this.channelId) 356 | const stub = this.sinon.stub(this.plugin._api, 'getPaymentChannel').callsFake(async () => { 357 | const error = new Error() 358 | error.name = 'RippledError' 359 | error.message = 'entryNotFound' 360 | throw error 361 | }) 362 | 363 | await assert.isRejected(this.plugin._connect(this.from, {})) 364 | assert.isTrue(stub.calledWith(this.channelId)) 365 | assert.isNotOk(this.plugin._channelToAccount.get(this.channelId)) 366 | assert.isNotOk(this.plugin._store.get(this.account + ':channel')) 367 | assert.isNotOk(this.plugin._store.get(this.account + ':last_claimed')) 368 | }) 369 | }) 370 | 371 | describe('get extra info', () => { 372 | beforeEach(async function () { 373 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 374 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 375 | this.account = await this.plugin._getAccount(this.from) 376 | }) 377 | 378 | it('should return channel if it exists', function () { 379 | const info = this.plugin._extraInfo(this.account) 380 | assert.equal(info.channel, undefined) 381 | 382 | this.plugin._store.setCache(this.account.getAccount() + ':channel', this.channelId) 383 | this.account._state = ReadyState.LOADING_CLIENT_CHANNEL 384 | 385 | const info2 = this.plugin._extraInfo(this.account) 386 | assert.equal(info2.channel, this.channelId) 387 | }) 388 | 389 | it('should return client channel if it exists', function () { 390 | const info = this.plugin._extraInfo(this.account) 391 | assert.equal(info.clientChannel, undefined) 392 | 393 | this.plugin._store.setCache(this.account.getAccount() + ':channel', this.channelId) 394 | this.plugin._store.setCache(this.account.getAccount() + ':client_channel', this.channelId) 395 | this.account._state = ReadyState.READY 396 | 397 | const info2 = this.plugin._extraInfo(this.account) 398 | assert.equal(info2.clientChannel, this.channelId) 399 | }) 400 | 401 | it('should return full address', function () { 402 | const info = this.plugin._extraInfo(this.account) 403 | assert.equal(info.account, this.from) 404 | }) 405 | }) 406 | 407 | describe('channel claim', () => { 408 | beforeEach(async function () { 409 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 410 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 411 | this.account = await this.plugin._getAccount(this.from) 412 | this.account._state = ReadyState.READY 413 | this.plugin._store.setCache(this.account.getAccount() + ':channel', this.channelId) 414 | this.plugin._store.setCache(this.account.getAccount() + ':claim', { 415 | amount: '12345', 416 | signature: 'foo' 417 | }) 418 | this.account._paychan = { publicKey: 'bar', balance: '0' } 419 | }) 420 | 421 | it('should create a fund transaction with proper parameters', async function () { 422 | const stub = this.sinon.stub(this.plugin._txSubmitter, 'submit').resolves() 423 | await this.plugin._channelClaim(this.account) 424 | assert(stub.calledWithExactly('preparePaymentChannelClaim', { 425 | balance: '0.012345', 426 | signature: 'FOO', 427 | publicKey: 'bar', 428 | close: false, 429 | channel: '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 430 | }), 'unexpected args: ' + JSON.stringify(stub.args)) 431 | }) 432 | 433 | it('should scale the claim amount appropriately', async function () { 434 | this.plugin._currencyScale = 9 435 | const stub = this.sinon.stub(this.plugin._txSubmitter, 'submit').resolves() 436 | await this.plugin._channelClaim(this.account) 437 | assert(stub.calledWithExactly('preparePaymentChannelClaim', { 438 | balance: '0.000013', 439 | signature: 'FOO', 440 | publicKey: 'bar', 441 | close: false, 442 | channel: '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 443 | }), 'unexpected args: ' + JSON.stringify(stub.args)) 444 | }) 445 | 446 | it('should give an error if submit fails', async function () { 447 | const api = this.plugin._txSubmitter._api 448 | this.sinon.stub(api, 'preparePaymentChannelClaim').returns({ txJSON: 'xyz' }) 449 | this.sinon.stub(api, 'sign').returns({ signedTransaction: 'abc' }) 450 | this.sinon.stub(api, 'submit').returns(Promise.resolve({ 451 | resultCode: 'temMALFORMED', 452 | resultMessage: 'malformed' 453 | })) 454 | 455 | await assert.isRejected( 456 | this.plugin._channelClaim(this.account), 457 | 'Error submitting claim') 458 | }) 459 | 460 | it('should not auto claim when more has been claimed than the plugin thought', async function () { 461 | this.plugin._api.getPaymentChannel = () => Promise.resolve({ 462 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 463 | amount: '1', 464 | balance: '0.012345', 465 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 466 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 467 | settleDelay: 3600, 468 | sourceTag: 1280434065, 469 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 470 | previousAffectingTransactionLedgerVersion: 6089142 471 | }) 472 | 473 | const stub = this.sinon.stub(this.plugin._txSubmitter, 'submit').resolves() 474 | await this.plugin._channelClaim(this.account) 475 | assert.isFalse(stub.called, 'claim should not have been submitted') 476 | }) 477 | }) 478 | 479 | describe('handle money', () => { 480 | beforeEach(async function () { 481 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 482 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 483 | this.account = await this.plugin._getAccount(this.from) 484 | this.account._state = ReadyState.READY 485 | this.plugin._store.setCache(this.account.getAccount() + ':channel', this.channelId) 486 | this.plugin._store.setCache(this.account.getAccount() + ':claim', { 487 | amount: '12345', 488 | signature: 'foo' 489 | }) 490 | this.account._paychan = { 491 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 492 | amount: '1', 493 | balance: '0', 494 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 495 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 496 | settleDelay: 3600, 497 | sourceTag: 1280434065, 498 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 499 | previousAffectingTransactionLedgerVersion: 6089142 500 | } 501 | }) 502 | 503 | it('should throw error if no claim protocol is present', function () { 504 | assert.throws( 505 | () => this.plugin._handleMoney(this.from, { requestId: 10, data: { protocolData: [] } }), 506 | 'got transfer with empty protocolData. requestId=10') 507 | }) 508 | 509 | it('should throw error if claim protocol is empty', function () { 510 | assert.throws( 511 | () => this.plugin._handleMoney(this.from, { data: { protocolData: [{ 512 | protocolName: 'claim', 513 | data: Buffer.alloc(0) 514 | }] }}), 515 | 'No claim was supplied on transfer') 516 | }) 517 | 518 | it('should pass claim to _handleClaim if present', function () { 519 | const stub = this.sinon.stub(this.plugin, '_handleClaim') 520 | this.plugin._handleMoney(this.from, { data: { protocolData: [{ 521 | protocolName: 'claim', 522 | data: Buffer.from('{}') 523 | }] }}) 524 | 525 | assert.isTrue(stub.calledWith(this.account, {})) 526 | }) 527 | 528 | describe('_handleClaim', () => { 529 | beforeEach(function () { 530 | this.claim = { 531 | amount: 12345, 532 | signature: 'foo' 533 | } 534 | }) 535 | 536 | it('should throw if the signature is not valid', function () { 537 | assert.throws( 538 | () => this.plugin._handleClaim(this.account, this.claim), 539 | 'Invalid claim: invalid signature') 540 | }) 541 | 542 | it('should throw if the signature is for a higher amount than the channel max', function () { 543 | this.claim.amount = 1000001 544 | // This stub works because require uses a cache 545 | this.sinon.stub(require('sodium-universal'), 'crypto_sign_verify_detached') 546 | .returns(true) 547 | 548 | assert.throws( 549 | () => this.plugin._handleClaim(this.account, this.claim), 550 | 'Invalid claim: claim amount (1000001) exceeds channel balance (1000000)') 551 | }) 552 | 553 | it('should not save the claim if it is lower than the previous', function () { 554 | // This stub works because require uses a cache 555 | this.sinon.stub(require('sodium-universal'), 'crypto_sign_verify_detached') 556 | .returns(true) 557 | 558 | const spy = this.sinon.spy(this.account, 'setIncomingClaim') 559 | this.plugin._handleClaim(this.account, this.claim) 560 | 561 | assert.isFalse(spy.called) 562 | // assert.isTrue(spy.calledWith(JSON.stringify(this.claim))) 563 | }) 564 | 565 | it('should save the claim if it is higher than the previous', function () { 566 | // This stub works because require uses a cache 567 | this.claim.amount = 123456 568 | this.sinon.stub(require('sodium-universal'), 'crypto_sign_verify_detached') 569 | .returns(true) 570 | 571 | const spy = this.sinon.spy(this.account, 'setIncomingClaim') 572 | this.plugin._handleClaim(this.account, this.claim) 573 | 574 | assert.isTrue(spy.calledWith(this.claim)) 575 | }) 576 | }) 577 | }) 578 | 579 | describe('send money', () => { 580 | beforeEach(async function () { 581 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 582 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 583 | this.account = await this.plugin._getAccount(this.from) 584 | this.account._state = ReadyState.READY 585 | this.plugin._store.setCache(this.account.getAccount() + ':channel', this.channelId) 586 | this.plugin._store.setCache(this.account.getAccount() + ':client_channel', this.channelId) 587 | this.plugin._store.setCache(this.account.getAccount() + ':claim', { 588 | amount: '12345', 589 | signature: 'foo' 590 | }) 591 | this.plugin._store.setCache(this.account.getAccount() + ':outgoing_balance', '12345') 592 | this.plugin._keyPair = { 593 | publicKey: Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES), 594 | secretKey: Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES) 595 | } 596 | this.claim = { 597 | amount: '12345', 598 | signature: 'foo' 599 | } 600 | this.account._clientPaychan = this.account._paychan = { 601 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 602 | amount: '1', 603 | balance: '0', 604 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 605 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 606 | settleDelay: 3600, 607 | sourceTag: 1280434065, 608 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 609 | previousAffectingTransactionLedgerVersion: 6089142 610 | } 611 | }) 612 | 613 | describe('_sendMoneyToAccount', () => { 614 | it('should return a claim for a higher balance', function () { 615 | const oldAmount = Number(this.claim.amount) 616 | const [ claim ] = this.plugin._sendMoneyToAccount(100, this.from) 617 | 618 | assert.equal(claim.protocolName, 'claim') 619 | assert.equal(claim.contentType, 2) 620 | 621 | const parsed = JSON.parse(claim.data.toString()) 622 | assert.equal(Number(parsed.amount), oldAmount + 100) 623 | }) 624 | 625 | describe('with high scale', function () { 626 | beforeEach(function () { 627 | this.plugin._currencyScale = 9 628 | 629 | this.plugin._keyPair = { 630 | publicKey: Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES), 631 | secretKey: Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES) 632 | } 633 | this.plugin._funding = true 634 | this.plugin._store.setCache(this.account.getAccount() + ':outgoing_balance', '990') 635 | }) 636 | 637 | it('should issue a fund and throw an error if amount is above the capacity', function () { 638 | const stub = this.sinon.stub(require('ilp-plugin-xrp-paychan-shared').util, 'fundChannel') 639 | .returns(Promise.resolve()) 640 | 641 | const initialOutgoingBalance = this.account.getOutgoingBalance() 642 | assert.throws(() => this.plugin._sendMoneyToAccount(1000000000, this.from), 643 | /channel does not have enough capacity to process claim. claimAmount=1000001 clientPaychan.amount=1000000/) 644 | assert.equal(this.account.getOutgoingBalance().toString(), initialOutgoingBalance.toString()) 645 | 646 | assert.isTrue(stub.calledWith({ 647 | api: this.plugin._api, 648 | channel: this.channelId, 649 | address: this.plugin._address, 650 | secret: this.plugin._secret, 651 | amount: 1000000 652 | })) 653 | }) 654 | 655 | it('should round high-scale amount up to next drop', async function () { 656 | const encodeSpy = this.sinon.spy(util, 'encodeClaim') 657 | this.sinon.stub(this.plugin, '_call').resolves(null) 658 | 659 | this.plugin._sendMoneyToAccount(100, this.from) 660 | 661 | assert.deepEqual(encodeSpy.getCall(0).args, [ '2', this.channelId ]) 662 | }) 663 | 664 | it('should scale up low-scale amount', async function () { 665 | this.plugin._currencyScale = 2 666 | const encodeSpy = this.sinon.spy(util, 'encodeClaim') 667 | this.sinon.stub(this.plugin, '_call').resolves(null) 668 | 669 | // make sure we don't exceed the channel balance 670 | this.account._clientPaychan.amount = 1e6 671 | this.plugin._sendMoneyToAccount(100, this.from) 672 | 673 | assert.deepEqual(encodeSpy.getCall(0).args, [ '10900000', this.channelId ]) 674 | }) 675 | 676 | it('should keep error under a drop even on repeated roundings', async function () { 677 | const encodeSpy = this.sinon.spy(util, 'encodeClaim') 678 | this.sinon.stub(this.plugin, '_call').resolves(null) 679 | 680 | this.plugin._sendMoneyToAccount(100, this.from) 681 | this.plugin._sendMoneyToAccount(100, this.from) 682 | 683 | assert.deepEqual(encodeSpy.getCall(0).args, [ '2', this.channelId ]) 684 | assert.deepEqual(encodeSpy.getCall(1).args, [ '2', this.channelId ]) 685 | }) 686 | 687 | it('should handle a claim', async function () { 688 | // this stub isn't working, which is why handleMoney is throwing 689 | this.sinon.stub(sodium, 'crypto_sign_verify_detached') 690 | .returns(true) 691 | const encodeSpy = this.sinon.spy(util, 'encodeClaim') 692 | 693 | this.plugin._store.setCache(this.account.getAccount() + ':balance', 990) 694 | this.plugin._handleMoney(this.from, { 695 | requestId: 1, 696 | data: { 697 | amount: '160', 698 | protocolData: [{ 699 | protocolName: 'claim', 700 | contentType: BtpPacket.MIME_APPLICATION_JSON, 701 | data: Buffer.from(JSON.stringify({ 702 | amount: '2150', 703 | signature: 'abcdef' 704 | })) 705 | }] 706 | } 707 | }) 708 | 709 | assert.deepEqual(encodeSpy.getCall(0).args, [ '3', this.channelId ]) 710 | }) 711 | }) 712 | 713 | it('should not issue a fund if the amount is below the threshold', function () { 714 | const spy = this.sinon.spy(require('ilp-plugin-xrp-paychan-shared').util, 'fundChannel') 715 | this.plugin._sendMoneyToAccount(100, this.from) 716 | assert.isFalse(spy.called) 717 | }) 718 | 719 | it('should issue a fund if the amount is above the threshold', function () { 720 | const stub = this.sinon.stub(require('ilp-plugin-xrp-paychan-shared').util, 'fundChannel') 721 | .returns(Promise.resolve()) 722 | 723 | const initialOutgoingBalance = this.account.getOutgoingBalance() 724 | this.plugin._sendMoneyToAccount(500000, this.from) 725 | assert.equal(this.account.getOutgoingBalance().toString(), 726 | initialOutgoingBalance.plus(500000).toString()) 727 | 728 | assert.isTrue(stub.calledWith({ 729 | api: this.plugin._api, 730 | channel: this.channelId, 731 | address: this.plugin._address, 732 | secret: this.plugin._secret, 733 | amount: 1000000 734 | })) 735 | }) 736 | 737 | it('should issue a fund and throw an error if amount is above the capacity', function () { 738 | const stub = this.sinon.stub(require('ilp-plugin-xrp-paychan-shared').util, 'fundChannel') 739 | .returns(Promise.resolve()) 740 | 741 | const initialOutgoingBalance = this.account.getOutgoingBalance() 742 | assert.throws(() => this.plugin._sendMoneyToAccount(1000000, this.from), 743 | /channel does not have enough capacity to process claim. claimAmount=1012345 clientPaychan.amount=1000000/) 744 | assert.equal(this.account.getOutgoingBalance().toString(), initialOutgoingBalance.toString()) 745 | 746 | assert.isTrue(stub.calledWith({ 747 | api: this.plugin._api, 748 | channel: this.channelId, 749 | address: this.plugin._address, 750 | secret: this.plugin._secret, 751 | amount: 1000000 752 | })) 753 | }) 754 | 755 | it('reloads client\'s paychan details after funding', async function () { 756 | this.sinon.stub(require('ilp-plugin-xrp-paychan-shared').util, 'fundChannel').resolves() 757 | const expectedClientChan = Object.assign({}, this.account._clientPaychan, {amount: '2'}) 758 | sinon.stub(this.plugin._api, 'getPaymentChannel').resolves(expectedClientChan) 759 | 760 | // this will trigger a fund tx 761 | this.plugin._sendMoneyToAccount(500000, this.from) 762 | 763 | // wait for the fund tx to be completed 764 | await new Promise((resolve, reject) => { 765 | this.account.setFunding = () => resolve() 766 | }) 767 | assert.deepEqual(this.account.getClientPaychan(), expectedClientChan, 768 | 'expected client paychan to be updated after funding completed') 769 | }) 770 | }) 771 | }) 772 | 773 | describe('handle custom data', () => { 774 | beforeEach(async function () { 775 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 776 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 777 | this.account = await this.plugin._getAccount(this.from) 778 | this.account._state = ReadyState.READY 779 | this.plugin._store.setCache(this.account.getAccount() + ':channel', this.channelId) 780 | this.plugin._store.setCache(this.account.getAccount() + ':claim', { 781 | amount: '12345', 782 | signature: 'foo' 783 | }) 784 | this.account._paychan = { 785 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 786 | amount: '1', 787 | balance: '0', 788 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 789 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 790 | settleDelay: 3600, 791 | sourceTag: 1280434065, 792 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 793 | previousAffectingTransactionLedgerVersion: 6089142 794 | } 795 | 796 | this.fulfillment = crypto.randomBytes(32) 797 | this.condition = crypto.createHash('sha256') 798 | .update(this.fulfillment) 799 | .digest() 800 | 801 | this.prepare = { data: { protocolData: [ { 802 | protocolName: 'ilp', 803 | contentType: BtpPacket.MIME_APPLICATION_OCTET_STREAM, 804 | data: IlpPacket.serializeIlpPrepare({ 805 | destination: this.from, 806 | amount: '123', 807 | executionCondition: this.condition, 808 | expiresAt: new Date(Date.now() + 10000), 809 | data: Buffer.alloc(0) 810 | }) 811 | } ] } } 812 | 813 | this.fulfill = { data: { protocolData: [ { 814 | protocolName: 'ilp', 815 | contentType: BtpPacket.MIME_APPLICATION_OCTET_STREAM, 816 | data: IlpPacket.serializeIlpFulfill({ 817 | fulfillment: this.fulfillment, 818 | data: Buffer.alloc(0) 819 | }) 820 | } ] } } 821 | 822 | this.sinon.stub(this.plugin, '_sendMoneyToAccount') 823 | .returns([]) 824 | this.sinon.stub(require('ilp-plugin-xrp-paychan-shared').util, '_requestId') 825 | .returns(Promise.resolve(1)) 826 | }) 827 | 828 | it('should return a reject if the packet is too big', async function () { 829 | this.plugin._maxPacketAmount = new BigNumber(1000) 830 | this.prepare = { data: { protocolData: [ { 831 | protocolName: 'ilp', 832 | contentType: BtpPacket.MIME_APPLICATION_OCTET_STREAM, 833 | data: IlpPacket.serializeIlpPrepare({ 834 | destination: this.from, 835 | amount: '1234567', 836 | executionCondition: this.condition, 837 | expiresAt: new Date(Date.now() + 10000), 838 | data: Buffer.alloc(0) 839 | }) 840 | } ] } } 841 | 842 | const res = await this.plugin._handleCustomData(this.from, this.prepare) 843 | 844 | assert.equal(res[0].protocolName, 'ilp') 845 | 846 | const parsed = IlpPacket.deserializeIlpReject(res[0].data) 847 | 848 | assert.deepEqual(parsed, { 849 | code: 'F08', 850 | triggeredBy: 'test.example.', 851 | message: 'Packet size is too large.', 852 | data: Buffer.from([ 0, 0, 0, 0, 0, 18, 214, 135, 0, 0, 0, 0, 0, 0, 3, 232 ]) 853 | }) 854 | }) 855 | 856 | it('should return a reject if insufficient bandwidth', async function () { 857 | this.prepare = { data: { protocolData: [ { 858 | protocolName: 'ilp', 859 | contentType: BtpPacket.MIME_APPLICATION_OCTET_STREAM, 860 | data: IlpPacket.serializeIlpPrepare({ 861 | destination: this.from, 862 | amount: '1234567', 863 | executionCondition: this.condition, 864 | expiresAt: new Date(Date.now() + 10000), 865 | data: Buffer.alloc(0) 866 | }) 867 | } ] } } 868 | 869 | const res = await this.plugin._handleCustomData(this.from, this.prepare) 870 | 871 | assert.equal(res[0].protocolName, 'ilp') 872 | 873 | const parsed = IlpPacket.deserializeIlpReject(res[0].data) 874 | 875 | assert.deepEqual(parsed, { 876 | code: 'T04', 877 | triggeredBy: 'test.example.', 878 | message: 'Insufficient bandwidth, used: 1222222 max: 1000000', 879 | data: Buffer.alloc(0) 880 | }) 881 | }) 882 | 883 | it('should return a reject if there is no channel to peer', async function () { 884 | delete this.account._paychan 885 | this.account._state = ReadyState.LOADING_CHANNEL 886 | 887 | const res = await this.plugin._handleCustomData(this.from, this.prepare) 888 | 889 | assert.equal(res[0].protocolName, 'ilp') 890 | 891 | const parsed = IlpPacket.deserializeIlpReject(res[0].data) 892 | 893 | assert.deepEqual(parsed, { 894 | code: 'F02', 895 | triggeredBy: 'test.example.', 896 | message: 'ilp packets will only be forwarded in READY state. state=LOADING_CHANNEL', 897 | data: Buffer.alloc(0) 898 | }) 899 | }) 900 | }) 901 | 902 | describe('auto claim logic', () => { 903 | beforeEach(async function () { 904 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 905 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 906 | this.account = await this.plugin._getAccount(this.from) 907 | this.account._state = ReadyState.READY 908 | this.plugin._store.setCache(this.account.getAccount() + ':channel', this.channelId) 909 | this.plugin._store.setCache(this.account.getAccount() + ':last_claimed', '12300') 910 | this.plugin._store.setCache(this.account.getAccount() + ':claim', { 911 | amount: '13901', 912 | signature: 'foo' 913 | }) 914 | this.account._paychan = { 915 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 916 | amount: '1', 917 | balance: '0.012300', 918 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 919 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 920 | settleDelay: 3600, 921 | sourceTag: 1280434065, 922 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 923 | previousAffectingTransactionLedgerVersion: 6089142 924 | } 925 | }) 926 | 927 | it('should auto claim when amount is more than 100 * fee + last claim', async function () { 928 | this.feeStub.resolves('0.000016') 929 | const stub = this.sinon.stub(this.plugin, '_channelClaim').resolves() 930 | 931 | await this.plugin._autoClaim(this.account) 932 | assert.isTrue(stub.called) 933 | }) 934 | 935 | it('should not auto claim when amount is less than 100 * fee + last claim', async function () { 936 | this.feeStub.resolves('0.000017') 937 | const stub = this.sinon.stub(this.plugin, '_channelClaim').resolves() 938 | 939 | await this.plugin._autoClaim(this.account) 940 | assert.isFalse(stub.called) 941 | }) 942 | 943 | describe('with high scale', () => { 944 | beforeEach(function () { 945 | this.plugin._currencyScale = 9 946 | this.plugin._store.setCache(this.account.getAccount() + ':last_claimed', '12300000') 947 | this.plugin._store.setCache(this.account.getAccount() + ':claim', { 948 | amount: '13901000', 949 | signature: 'foo' 950 | }) 951 | }) 952 | 953 | it('should auto claim when amount is more than 100 * fee + last claim', async function () { 954 | this.feeStub.resolves('0.000016') 955 | const stub = this.sinon.stub(this.plugin, '_channelClaim').resolves() 956 | 957 | await this.plugin._autoClaim(this.account) 958 | assert.isTrue(stub.called) 959 | }) 960 | 961 | it('should not auto claim when amount is less than 100 * fee + last claim', async function () { 962 | this.feeStub.resolves('0.000017') 963 | const stub = this.sinon.stub(this.plugin, '_channelClaim').resolves() 964 | 965 | await this.plugin._autoClaim(this.account) 966 | assert.isFalse(stub.called) 967 | }) 968 | }) 969 | }) 970 | 971 | describe('handle prepare response', () => { 972 | beforeEach(async function () { 973 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 974 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 975 | this.account = await this.plugin._getAccount(this.from) 976 | this.account._state = ReadyState.READY 977 | this.plugin._store.setCache(this.account.getAccount() + ':channel', this.channelId) 978 | this.plugin._store.setCache(this.account.getAccount() + ':claim', { 979 | amount: '12345', 980 | signature: 'foo' 981 | }) 982 | this.account._paychan = { 983 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 984 | amount: '1', 985 | balance: '0', 986 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 987 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 988 | settleDelay: 3600, 989 | sourceTag: 1280434065, 990 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 991 | previousAffectingTransactionLedgerVersion: 6089142 992 | } 993 | 994 | this.fulfillment = crypto.randomBytes(32) 995 | this.condition = crypto.createHash('sha256') 996 | .update(this.fulfillment) 997 | .digest() 998 | 999 | this.prepare = { 1000 | type: IlpPacket.Type.TYPE_ILP_PREPARE, 1001 | data: { 1002 | destination: this.from, 1003 | amount: 123, 1004 | executionCondition: this.condition, 1005 | expiresAt: new Date(Date.now() + 10000), 1006 | data: Buffer.alloc(0) 1007 | } 1008 | } 1009 | 1010 | this.fulfill = { 1011 | type: IlpPacket.Type.TYPE_ILP_FULFILL, 1012 | data: { 1013 | fulfillment: this.fulfillment, 1014 | data: Buffer.alloc(0) 1015 | } 1016 | } 1017 | 1018 | this.reject = { 1019 | type: IlpPacket.Type.TYPE_ILP_REJECT, 1020 | data: { 1021 | code: 'F00' 1022 | } 1023 | } 1024 | 1025 | this.sendMoneyStub = this.sinon.stub(this.plugin, '_sendMoneyToAccount') 1026 | .returns([]) 1027 | this.sinon.stub(require('ilp-plugin-xrp-paychan-shared').util, '_requestId') 1028 | .returns(Promise.resolve(1)) 1029 | }) 1030 | 1031 | it('should handle a prepare response (fulfill)', async function () { 1032 | const stub = this.sinon.stub(this.plugin, '_call') 1033 | .returns(Promise.resolve()) 1034 | 1035 | this.plugin._handlePrepareResponse(this.from, this.fulfill, this.prepare) 1036 | await new Promise(resolve => setTimeout(resolve, 10)) 1037 | assert.equal(this.account.getOwedBalance().toString(), '0') 1038 | assert.deepEqual(stub.firstCall.args, [this.from, { 1039 | type: BtpPacket.TYPE_TRANSFER, 1040 | requestId: 1, 1041 | data: { 1042 | amount: '123', 1043 | protocolData: [] 1044 | } 1045 | }]) 1046 | }) 1047 | 1048 | it('should settle owed balance in addition to prepare', async function () { 1049 | const stub = this.sinon.stub(this.plugin, '_call') 1050 | .returns(Promise.resolve()) 1051 | 1052 | this.account.setOwedBalance('10') 1053 | 1054 | this.plugin._handlePrepareResponse(this.from, this.fulfill, this.prepare) 1055 | await new Promise(resolve => setTimeout(resolve, 10)) 1056 | assert.equal(this.account.getOwedBalance().toString(), '0') 1057 | assert.deepEqual(stub.firstCall.args, [this.from, { 1058 | type: BtpPacket.TYPE_TRANSFER, 1059 | requestId: 1, 1060 | data: { 1061 | amount: '133', 1062 | protocolData: [] 1063 | } 1064 | }]) 1065 | }) 1066 | 1067 | it('should ignore fulfillments for zero-amount packets', async function () { 1068 | const stub = this.sinon.stub(this.plugin, '_call') 1069 | .returns(Promise.resolve()) 1070 | 1071 | this.prepare.data.amount = '0' 1072 | 1073 | this.plugin._handlePrepareResponse(this.from, this.fulfill, this.prepare) 1074 | await new Promise(resolve => setTimeout(resolve, 10)) 1075 | assert.isFalse(stub.called) 1076 | }) 1077 | 1078 | it('should handle a prepare response on which transfer fails', async function () { 1079 | this.sinon.stub(this.plugin, '_call') 1080 | .returns(Promise.reject(new Error('no'))) 1081 | 1082 | this.plugin._handlePrepareResponse(this.from, this.fulfill, this.prepare) 1083 | }) 1084 | 1085 | it('should handle a prepare response (non-fulfill)', async function () { 1086 | const stub = this.sinon.stub(this.plugin, '_call') 1087 | .returns(Promise.resolve()) 1088 | 1089 | this.plugin._handlePrepareResponse(this.from, this.reject, this.prepare) 1090 | await new Promise(resolve => setTimeout(resolve, 10)) 1091 | assert.isFalse(stub.called) 1092 | }) 1093 | 1094 | it('should increase owed amount when settle fails', async function () { 1095 | const stub = this.sinon.stub(this.plugin, '_call') 1096 | .returns(Promise.resolve()) 1097 | 1098 | this.sendMoneyStub.throws(new Error('failed to sign claim')) 1099 | 1100 | this.plugin._handlePrepareResponse(this.from, this.fulfill, this.prepare) 1101 | await new Promise(resolve => setTimeout(resolve, 10)) 1102 | assert.isFalse(stub.called) 1103 | assert.equal(this.account.getOwedBalance().toString(), '123') 1104 | 1105 | this.plugin._handlePrepareResponse(this.from, this.fulfill, this.prepare) 1106 | await new Promise(resolve => setTimeout(resolve, 10)) 1107 | assert.isFalse(stub.called) 1108 | assert.equal(this.account.getOwedBalance().toString(), '246') 1109 | }) 1110 | 1111 | describe('T04 handling', () => { 1112 | beforeEach(function () { 1113 | this.reject.data.code = 'T04' 1114 | }) 1115 | 1116 | it('should trigger settlement on a T04 error', async function () { 1117 | const stub = this.sinon.stub(this.plugin, '_call') 1118 | .returns(Promise.resolve()) 1119 | 1120 | this.account.setOwedBalance('10') 1121 | 1122 | this.plugin._handlePrepareResponse(this.from, this.reject, this.prepare) 1123 | await new Promise(resolve => setTimeout(resolve, 10)) 1124 | assert.equal(this.account.getOwedBalance().toString(), '0') 1125 | assert.isTrue(this.sendMoneyStub.calledWith('10', this.from)) 1126 | assert.deepEqual(stub.firstCall.args, [this.from, { 1127 | type: BtpPacket.TYPE_TRANSFER, 1128 | requestId: 1, 1129 | data: { 1130 | amount: '10', 1131 | protocolData: [] 1132 | } 1133 | }]) 1134 | }) 1135 | 1136 | it('should not adjust owed balance if settle fails', async function () { 1137 | const stub = this.sinon.stub(this.plugin, '_call') 1138 | .returns(Promise.resolve()) 1139 | 1140 | this.account.setOwedBalance('10') 1141 | this.sendMoneyStub.throws(new Error('failed to sign claim')) 1142 | 1143 | this.plugin._handlePrepareResponse(this.from, this.reject, this.prepare) 1144 | await new Promise(resolve => setTimeout(resolve, 10)) 1145 | assert.equal(this.account.getOwedBalance().toString(), '10') 1146 | assert.isTrue(this.sendMoneyStub.calledWith('10', this.from)) 1147 | assert.isFalse(stub.called) 1148 | }) 1149 | }) 1150 | }) 1151 | 1152 | describe('Account', function () { 1153 | beforeEach(function () { 1154 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 1155 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 1156 | this.account = this.plugin._getAccount(this.from) 1157 | this.paychan = { 1158 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 1159 | amount: '1', 1160 | balance: '0', 1161 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 1162 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 1163 | settleDelay: 3600, 1164 | sourceTag: 1280434065, 1165 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 1166 | previousAffectingTransactionLedgerVersion: 6089142 1167 | } 1168 | }) 1169 | 1170 | it('should block the account if the store says so', async function () { 1171 | this.account._store.setCache(this.account.getAccount() + ':block', 'true') 1172 | await this.account.connect() 1173 | assert.equal(this.account.getStateString(), 'BLOCKED') 1174 | assert.equal(this.account.getBlockReason(), 'channel must be re-established') 1175 | }) 1176 | 1177 | it('should set to ESTABLISHING_CHANNEL if no channel exists', async function () { 1178 | await this.account.connect() 1179 | assert.equal(this.account.getStateString(), 'ESTABLISHING_CHANNEL') 1180 | }) 1181 | 1182 | it('should load channel from ledger if it exists', async function () { 1183 | this.account._store.setCache(this.account.getAccount() + ':channel', 'my_channel_id') 1184 | this.sinon.stub(this.account._api, 'getPaymentChannel').resolves(this.paychan) 1185 | await this.account.connect() 1186 | assert.equal(this.account.getStateString(), 'ESTABLISHING_CLIENT_CHANNEL') 1187 | }) 1188 | 1189 | it('should retry call to ledger if channel gives timeout', async function () { 1190 | this.account._store.setCache(this.account.getAccount() + ':channel', 'my_channel_id') 1191 | this.sinon.stub(this.account._api, 'getPaymentChannel') 1192 | .onFirstCall().callsFake(() => { 1193 | const e = new Error('timed out') 1194 | e.name = 'TimeoutError' 1195 | throw e 1196 | }) 1197 | .onSecondCall().resolves(this.paychan) 1198 | 1199 | const oldSetTimeout = setTimeout 1200 | setTimeout = setImmediate 1201 | await this.account.connect() 1202 | setTimeout = oldSetTimeout 1203 | 1204 | assert.equal(this.account.getStateString(), 'ESTABLISHING_CLIENT_CHANNEL') 1205 | }) 1206 | 1207 | it('should retry call to ledger if client channel gives timeout', async function () { 1208 | this.account._store.setCache(this.account.getAccount() + ':channel', 'my_channel_id') 1209 | this.account._store.setCache(this.account.getAccount() + ':client_channel', 'my_channel_id') 1210 | this.sinon.stub(this.account._api, 'getPaymentChannel') 1211 | .onCall(0).resolves(this.paychan) 1212 | .onCall(1).callsFake(() => { 1213 | const e = new Error('timed out') 1214 | e.name = 'TimeoutError' 1215 | throw e 1216 | }) 1217 | .onCall(2).resolves(this.paychan) 1218 | 1219 | const oldSetTimeout = setTimeout 1220 | setTimeout = setImmediate 1221 | await this.account.connect() 1222 | setTimeout = oldSetTimeout 1223 | 1224 | assert.equal(this.account.getStateString(), 'READY') 1225 | }) 1226 | }) 1227 | 1228 | describe('admin interface', function () { 1229 | beforeEach(async function () { 1230 | this.sinon.stub(this.plugin._api, 'getAccountInfo') 1231 | .resolves({ 1232 | xrpBalance: '10000', 1233 | ownerCount: '200' 1234 | }) 1235 | 1236 | this.sinon.stub(this.plugin._api, 'getServerInfo') 1237 | .resolves({ 1238 | validatedLedger: { 1239 | reserveIncrementXRP: '4' 1240 | } 1241 | }) 1242 | 1243 | this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak' 1244 | this.channelId = '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0' 1245 | this.account = await this.plugin._getAccount(this.from) 1246 | this.account._state = ReadyState.READY 1247 | this.plugin._store.setCache(this.account.getAccount() + ':client_channel', this.channelId) 1248 | this.plugin._store.setCache(this.account.getAccount() + ':channel', this.channelId) 1249 | this.plugin._store.setCache(this.account.getAccount() + ':claim', { 1250 | amount: '12345', 1251 | signature: 'foo' 1252 | }) 1253 | this.account._paychan = this.account._clientPaychan = { 1254 | account: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot', 1255 | amount: '1', 1256 | balance: '0', 1257 | destination: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 1258 | publicKey: 'EDD69138B8AB9B0471A734927FABE2B20D2943215C8EEEC61DC11598C79424414D', 1259 | settleDelay: 3600, 1260 | sourceTag: 1280434065, 1261 | previousAffectingTransactionID: '51F331B863D078CF5EFEF1FBFF2D0F4C4D12FD160272EEB03F572C904B800057', 1262 | previousAffectingTransactionLedgerVersion: 6089142 1263 | } 1264 | }) 1265 | 1266 | it('should get admin info', async function () { 1267 | assert.deepEqual(await this.plugin.getAdminInfo(), { 1268 | clients: [{ 1269 | account: '35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak', 1270 | channel: '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0', 1271 | channelBalance: '0', 1272 | clientChannel: '45455C767516029F34E9A9CEDD8626E5D955964A041F6C9ACD11F9325D6164E0', 1273 | clientChannelBalance: '0.000000', 1274 | state: 'READY', 1275 | xrpAddress: 'rPbVxek7Bovu4pWyCfGCVtgGbhwL6D55ot' 1276 | }], 1277 | xrpAddress: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 1278 | xrpBalance: { 1279 | 'available': '9200', 1280 | 'reserved': '800', 1281 | 'total': '10000' 1282 | } 1283 | }) 1284 | }) 1285 | 1286 | it('should filter out uninitialized accounts', async function () { 1287 | this.account._paychan = null 1288 | assert.deepEqual(await this.plugin.getAdminInfo(), { 1289 | clients: [], 1290 | xrpAddress: 'r9Ggkrw4VCfRzSqgrkJTeyfZvBvaG9z3hg', 1291 | xrpBalance: { 1292 | 'available': '9200', 1293 | 'reserved': '800', 1294 | 'total': '10000' 1295 | } 1296 | }) 1297 | }) 1298 | 1299 | it('should apply a "settle" command', async function () { 1300 | const idStub = this.sinon.stub(util, '_requestId').resolves(12345) 1301 | const callStub = this.sinon.stub(this.plugin, '_call').resolves(null) 1302 | const sendStub = this.sinon.stub(this.plugin, '_sendMoneyToAccount') 1303 | .returns([]) 1304 | 1305 | assert.deepEqual(await this.plugin.sendAdminInfo({ 1306 | command: 'settle', 1307 | amount: '100', 1308 | account: this.account.getAccount() 1309 | }), {}) 1310 | assert.deepEqual(sendStub.firstCall.args, [ '100000000', this.from ]) 1311 | assert.deepEqual(callStub.firstCall.args, [ 1312 | 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak', 1313 | { 1314 | data: { 1315 | amount: '100000000', 1316 | protocolData: [] 1317 | }, 1318 | requestId: 12345, 1319 | type: 7 1320 | } 1321 | ]) 1322 | }) 1323 | 1324 | it('should apply a "block" command', async function () { 1325 | assert.isFalse(this.account.isBlocked()) 1326 | assert.deepEqual(await this.plugin.sendAdminInfo({ 1327 | command: 'block', 1328 | account: this.account.getAccount() 1329 | }), {}) 1330 | assert.isTrue(this.account.isBlocked()) 1331 | }) 1332 | }) 1333 | }) 1334 | --------------------------------------------------------------------------------