UNPKG

ilp-plugin-xrp-paychan

Version:

Uses payment channels on ripple to do fast ILP transactions between you and a peer. Current in-flight payments are at risk (your peer can choose not to give you claims for them), but are secured against the ledger as soon as you get a claim.

618 lines (529 loc) 21.5 kB
'use strict' // imports const log = require('ilp-logger')('ilp-plugin-xrp-paychan') const Debug = require('debug') const BtpPacket = require('btp-packet') const { RippleAPI } = require('ripple-lib') const { deriveAddress, deriveKeypair } = require('ripple-keypairs') const PluginBtp = require('ilp-plugin-btp') const nacl = require('tweetnacl') const BigNumber = require('bignumber.js') const StoreWrapper = require('./store-wrapper') const { MoneyNotSentError } = require('./src/lib/constants') const { createSubmitter, ChannelWatcher, util } = require('ilp-plugin-xrp-paychan-shared') // constants const CHANNEL_KEYS = 'ilp-plugin-xrp-paychan-channel-keys' const DEFAULT_CHANNEL_AMOUNT_XRP = 1 const DEFAULT_FUND_THRESHOLD = 0.5 class PluginXrpPaychan extends PluginBtp { constructor (opts, api) { super(opts, api) this._xrpServer = opts.xrpServer || opts.rippledServer // TODO: deprecate rippledServer this._api = new RippleAPI({ server: this._xrpServer }) this._secret = opts.secret this._address = opts.address || deriveAddress(deriveKeypair(this._secret).publicKey) this._txSubmitter = createSubmitter(this._api, this._address, this._secret) this._maxFeePercent = opts.maxFeePercent || 0.01 if (opts.assetScale && opts.currencyScale) { throw new Error('opts.assetScale is an alias for opts.currencyScale;' + 'only one must be specified') } const currencyScale = opts.assetScale || opts.currencyScale if (typeof currencyScale !== 'number' && currencyScale !== undefined) { throw new Error('currency scale must be a number if specified.' + ' type=' + (typeof currencyScale) + ' value=' + currencyScale) } this._currencyScale = (typeof currencyScale === 'number') ? currencyScale : 6 this._setPeerAddress(opts.peerAddress) this._fundThreshold = opts.fundThreshold || DEFAULT_FUND_THRESHOLD this._channelAmount = opts.channelAmount || this.xrpToBase(DEFAULT_CHANNEL_AMOUNT_XRP) this._claimInterval = opts.claimInterval || util.DEFAULT_CLAIM_INTERVAL this._settleDelay = opts.settleDelay || util.MIN_SETTLE_DELAY this._store = new StoreWrapper(opts._store) this._outgoingChannel = null this._incomingChannel = null this._incomingChannelDetails = null this._incomingClaim = null this._outgoingClaim = null this._paychanReady = false this._reloadingChannel = false this._log = (api && api.log) || log this._log.trace = this._log.trace || Debug(this._log.debug.namespace + ':trace') this._watcher = new ChannelWatcher(60 * 1000, this._api) this._watcher.on('channelClose', () => { this._log.info('channel closing; triggering auto-disconnect') // TODO: should we also close our own channel? this.disconnect() }) } xrpToBase (amount) { return new BigNumber(amount) .times(Math.pow(10, this._currencyScale)) .toString() } baseToXrp (amount) { return new BigNumber(amount) .div(Math.pow(10, this._currencyScale)) .toFixed(6, BigNumber.ROUND_UP) } _setPeerAddress (peerAddress) { if (!this._peerAddress && peerAddress) { this._log.debug(`setting peer address to`, peerAddress) this._peerAddress = peerAddress const keyPairSeed = util.hmac(this._secret, CHANNEL_KEYS + this._peerAddress) this._keyPair = nacl.sign.keyPair.fromSeed(keyPairSeed) } } async _setIncomingChannel (newId) { if (newId === this._incomingChannel) { return } // We're changing the details so don't accept packets. this._reloadingChannel = true this._log.trace('validating new paychan. id=' + newId) const details = await this._api.getPaymentChannel(newId) this._validateChannelDetails(details) this._log.trace('checking that peer\'s scale matches') await this._getPeerInfo() // re-check in case of race conditions if (this._incomingChannel && newId !== this._incomingChannel) { this._log.trace('new paychan does not match old paychan. new=' + newId, 'old=' + this._incomingChannel) try { const oldDetails = this._incomingChannelDetails || await this._api.getPaymentChannel(this._incomingChannel) if (new BigNumber(this.baseToXrp(this._incomingClaim.amount)).gt(oldDetails.balance)) { await this._claimFunds() } } catch (e) { this._log.error('unable to load old incoming channel details. error=', e) } // re-check in case of race conditions if (this._incomingChannel && newId !== this._incomingChannel) { delete this._incomingChannelDetails delete this._incomingClaim delete this._incomingChannel } } if (!this._incomingChannel) { this._reloadingChannel = false this._log.debug(`setting incoming channel to`, newId) this._log.trace(`channel details are`, details) this._incomingChannel = newId this._incomingChannelDetails = details this._lastClaimedAmount = new BigNumber(this.xrpToBase(this._incomingChannelDetails.balance)) this._incomingClaim = { amount: this._lastClaimedAmount.toString() } this._store.set('incoming_channel', this._incomingChannel) this._store.set('incoming_claim', JSON.stringify(this._incomingClaim)) await this._watcher.watch(this._incomingChannel) } } async _handleData (from, { requestId, data }) { const { ilp, protocolMap } = this.protocolDataToIlpAndCustom(data) if (protocolMap.xrp_address) { this._log.debug('got xrp_address request from peer') this._setPeerAddress(protocolMap.xrp_address) return [{ protocolName: 'xrp_address', contentType: BtpPacket.MIME_TEXT_PLAIN_UTF8, data: Buffer.from(this._address) }] } if (!this._paychanReady) throw new Error('paychan initialization has not completed or has failed.') if (protocolMap.info) { this._log.debug('got info request from peer') return [{ protocolName: 'info', contentType: BtpPacket.MIME_APPLICATION_JSON, data: Buffer.from(JSON.stringify({ currencyScale: this._currencyScale })) }] } if (protocolMap.ripple_channel_id) { this._log.debug('got ripple_channel_id request from peer. id=' + protocolMap.ripple_channel_id) await this._reloadIncomingChannelDetails(protocolMap.ripple_channel_id) return [{ protocolName: 'ripple_channel_id', contentType: BtpPacket.MIME_TEXT_PLAIN_UTF8, data: Buffer.from(this._outgoingChannel || '') }] } if (this._reloadingChannel) { throw new Error('channel details are being reloaded.') } // make sure to load channel details if they don't exist yet if (!this._incomingChannel || !this._incomingChannelDetails) { await this._reloadIncomingChannelDetails() } if (!this._dataHandler) { throw new Error('no request handler registered') } if (!ilp) { throw new Error('no ilp protocol on request') } const response = await this._dataHandler(ilp) return this.ilpAndCustomToProtocolData({ ilp: response }) } async _sendRippleChannelIdRequest () { return this._call(null, { type: BtpPacket.TYPE_MESSAGE, requestId: await util._requestId(), data: { protocolData: [{ protocolName: 'ripple_channel_id', contentType: BtpPacket.MIME_TEXT_PLAIN_UTF8, data: Buffer.from(this._outgoingChannel) }] } }) } async _sendXrpAddressRequest () { return this._call(null, { type: BtpPacket.TYPE_MESSAGE, requestId: await util._requestId(), data: { protocolData: [{ protocolName: 'xrp_address', contentType: BtpPacket.MIME_TEXT_PLAIN_UTF8, data: Buffer.from(this._address) }] } }) } async _reloadIncomingChannelDetails (peerChannelId) { let chanId = peerChannelId if (!chanId) { this._log.debug('querying peer for incoming channel id') try { const response = await this._sendRippleChannelIdRequest() this._log.trace('got ripple_channel_id response:', response) chanId = response .protocolData .filter(p => p.protocolName === 'ripple_channel_id')[0] .data .toString() } catch (err) { this._log.debug('error requesting incoming channel from peer.', err) return } } if (!this._incomingChannel || this._incomingChannel !== chanId) { this._log.debug('setting incoming channel. old=' + this._incomingChannel, 'new=' + chanId) await this._setIncomingChannel(chanId) } else { this._log.debug('refreshing details for incoming channel', this._incomingChannel) try { this._incomingChannelDetails = await this._api.getPaymentChannel(this._incomingChannel) this._lastClaimedAmount = new BigNumber(this.xrpToBase(this._incomingChannelDetails.balance)) this._log.trace('incoming channel details are:', this._incomingChannelDetails) } catch (err) { if (err.name === 'RippledError' && err.message === 'entryNotFound') { this._log.error('incoming payment channel does not exist:', this._incomingChannel) } else { this._log.debug(err) } return } } this._setupAutoClaim() } async _getPeerInfo () { // now uses the info protocol to make sure scales are matching let infoResponse try { this._log.debug('querying peer for info') infoResponse = await this._call(null, { type: BtpPacket.TYPE_MESSAGE, requestId: await util._requestId(), data: { protocolData: [{ protocolName: 'info', contentType: BtpPacket.MIME_APPLICATION_OCTET_STREAM, data: Buffer.from([ util.INFO_REQUEST_ALL ]) }] } }) } catch (e) { if (this._currencyScale !== 6) { throw new Error('peer is unable to accomodate our currencyScale;' + ' they are on an out of date version of this plugin. error=' + e.stack) } else { this._log.warn('peer is on an outdated plugin, but currency scales match') } } if (infoResponse) { const protocol = infoResponse.protocolData[0] if (protocol.protocolName !== 'info') throw new Error('invalid response to info request') const info = JSON.parse(protocol.data.toString()) this._log.trace('info subprotocol response is:', info) if (info.currencyScale !== this._currencyScale) { throw new Error('Fatal! Currency scale mismatch. this=' + this._currencyScale + ' peer=' + (info.currencyScale || 6)) } } } async _isClaimProfitable () { const income = new BigNumber(this._incomingClaim.amount).minus(this._lastClaimedAmount) const fee = new BigNumber(this.xrpToBase(await this._api.getFee())) return income.isGreaterThan(0) && fee.dividedBy(income).lte(this._maxFeePercent) } _setupAutoClaim () { if (!this._claimIntervalId) { this._claimIntervalId = setInterval(async () => { if (await this._isClaimProfitable()) { this._log.trace('starting automatic claim. amount=' + this._incomingClaim.amount) this._lastClaimedAmount = new BigNumber(this._incomingClaim.amount) await this._claimFunds() this._log.info('claimed funds.') } }, this._claimInterval) } } _validateChannelDetails (details) { // Make sure the watcher has enough time to submit the best // claim before the channel closes const settleDelay = details.settleDelay if (settleDelay < util.MIN_SETTLE_DELAY) { this._log.debug(`incoming payment channel has a too low settle delay of ${settleDelay.toString()}` + ` seconds. Minimum settle delay is ${util.MIN_SETTLE_DELAY} seconds.`) throw new Error('settle delay of incoming payment channel too low') } if (details.cancelAfter) { this._log.debug(`channel has cancelAfter set`) throw new Error('cancelAfter must not be set') } if (details.expiration) { this._log.debug(`channel has expiration set`) throw new Error('expiration must not be set') } if (details.destination !== this._address) { this._log.debug('incoming channel destination is not our address: ' + details.destination) throw new Error('Channel destination address wrong') } } // run after connections are established, but before connect resolves async _connect () { this._log.info('connecting to rippled') await this._api.connect() await this._api.connection.request({ command: 'subscribe', accounts: [ this._address ] }) this._log.info('connected to rippled') await this._store.load('incoming_channel') await this._store.load('outgoing_channel') await this._store.load('incoming_claim') await this._store.load('outgoing_claim') this._incomingChannel = this._store.get('incoming_channel') this._outgoingChannel = this._store.get('outgoing_channel') this._incomingClaim = JSON.parse(this._store.get('incoming_claim') || '{"amount":"0"}') this._outgoingClaim = JSON.parse(this._store.get('outgoing_claim') || '{"amount":"0"}') this._log.trace('loaded incoming claim:', this._incomingClaim) if (this._incomingChannel) { await this._watcher.watch(this._incomingChannel) await this._reloadIncomingChannelDetails() } if (!this._peerAddress) { try { this._log.error('No peer xrp address was specified; fetching from peer.') const data = await this._sendXrpAddressRequest() const peerAddress = data .protocolData .filter(p => p.protocolName === 'xrp_address')[0] .data .toString() this._setPeerAddress(peerAddress) } catch (e) { this._log.error('No peer address was specified and peer does not support xrp_address protocol.' + ' error=' + e.message) throw e } } if (!this._outgoingChannel) { this._log.info('creating new payment channel') let ev try { const txTag = util.randomTag() ev = await this._txSubmitter.submit('preparePaymentChannelCreate', { amount: this.baseToXrp(this._channelAmount), destination: this._peerAddress, settleDelay: this._settleDelay, publicKey: 'ED' + Buffer.from(this._keyPair.publicKey).toString('hex').toUpperCase(), sourceTag: txTag }) } catch (err) { this._log.error('Error creating payment channel') throw err } this._outgoingChannel = util.computeChannelId( ev.transaction.Account, ev.transaction.Destination, ev.transaction.Sequence ) this._store.set('outgoing_channel', this._outgoingChannel) this._log.info('payment channel successfully created: ', this._outgoingChannel) } await this._reloadOutgoingChannelDetails() this._paychanReady = true } async _reloadOutgoingChannelDetails () { while (true) { try { this._outgoingChannelDetails = await this._api.getPaymentChannel(this._outgoingChannel) break } catch (e) { if (e.name === 'TimeoutError') { this._log.debug('timed out while loading outgoing channel details. retrying in 2s.' + ' channel=' + this._outgoingChannel) await new Promise(resolve => setTimeout(resolve, 2000)) continue } else { throw e } } } } async _claimFunds () { if (!this._incomingClaim.signature) { return } await this._txSubmitter.submit('preparePaymentChannelClaim', { balance: this.baseToXrp(this._incomingClaim.amount), channel: this._incomingChannel, signature: this._incomingClaim.signature.toUpperCase(), publicKey: this._incomingChannelDetails.publicKey }) } async _disconnect () { this._log.info('disconnecting payment channel') clearInterval(this._claimIntervalId) try { await this._claimFunds() } catch (e) { this._log.error('claim error on disconnect:', e) } try { this._api.disconnect() } catch (e) { this._log.error('error disconnecting from rippled:', e) } } async sendMoney (amount) { const claimAmount = new BigNumber(this._outgoingClaim.amount).plus(amount) if (claimAmount.gt(this.xrpToBase(this._outgoingChannelDetails.amount))) { throw new MoneyNotSentError('claim amount exceeds channel balance.' + ' claimAmount=' + claimAmount + ' channelAmount=' + this.xrpToBase(this._outgoingChannelDetails.amount) + ' channel=' + this._outgoingChannel) } const dropClaimAmount = util.xrpToDrops(this.baseToXrp(claimAmount)) const encodedClaim = util.encodeClaim(dropClaimAmount, this._outgoingChannel) const signature = nacl.sign.detached(encodedClaim, this._keyPair.secretKey) this._log.trace(`signed outgoing claim for ${claimAmount.toString()} drops on ` + `channel ${this._outgoingChannel}`) if (!this._funding && new BigNumber(dropClaimAmount).isGreaterThan(new BigNumber(util.xrpToDrops(this._outgoingChannelDetails.amount)).times(this._fundThreshold))) { this._funding = true util.fundChannel({ api: this._api, channel: this._outgoingChannel, amount: util.xrpToDrops(this._outgoingChannelDetails.amount), address: this._address, secret: this._secret }) .then(async () => { await this._sendRippleChannelIdRequest() await this._reloadOutgoingChannelDetails() this._funding = false }) .catch((e) => { this._funding = false this._log.error('error issuing fund tx:', e) }) } this._outgoingClaim = { amount: claimAmount.toString(), signature: Buffer.from(signature).toString('hex') } this._store.set('outgoing_claim', JSON.stringify(this._outgoingClaim)) await this._call(null, { type: BtpPacket.TYPE_TRANSFER, requestId: await util._requestId(), data: { amount, protocolData: [{ protocolName: 'claim', contentType: BtpPacket.MIME_APPLICATION_JSON, data: Buffer.from(JSON.stringify(this._outgoingClaim)) }] } }) } async _handleMoney (from, { requestId, data }) { if (!this._paychanReady) throw new Error('paychan initialization has not completed or has failed.') if (!this._incomingChannelDetails || !this._incomingChannel) { await this._reloadIncomingChannelDetails() } const amount = data.amount const protocolData = data.protocolData const claim = JSON.parse(protocolData .filter(p => p.protocolName === 'claim')[0] .data .toString()) const claimAmount = new BigNumber(claim.amount) const dropClaimAmount = util.xrpToDrops(this.baseToXrp(claimAmount)) const encodedClaim = util.encodeClaim(dropClaimAmount, this._incomingChannel) const addedMoney = claimAmount.minus(this._incomingClaim.amount) if (addedMoney.lte(0)) { throw new Error('new claim is less than old claim. new=' + claim.amount + ' old=' + this._incomingClaim.amount) } // Don't throw an error here; we'll just emit the addedMoney amount and keep going. // This can happen during high throughput when transfers may get out of sync with // settlements. So long as one peer doesn't crash before balances are written, the // discrepency should go away automatically. if (!addedMoney.isEqualTo(amount)) { this._log.warn('warning: peer balance is out of sync with ours. peer thinks they sent ' + amount + '; we got ' + addedMoney.toString()) } this._log.trace(`received claim for ${addedMoney.toString()} drops on channel ${this._incomingChannel}`) let valid = false try { valid = nacl.sign.detached.verify( encodedClaim, Buffer.from(claim.signature, 'hex'), Buffer.from(this._incomingChannelDetails.publicKey.substring(2), 'hex') ) } catch (err) { this._log.error('verifying signature failed:', err.message) } // TODO: better reconciliation if claims are invalid if (!valid) { this._log.error(`got invalid claim signature ${claim.signature} for amount ${dropClaimAmount.toString()} drops total`) throw new Error('got invalid claim signature ' + claim.signature + ' for amount ' + dropClaimAmount.toString() + ' drops total') } // validate claim against balance const channelAmount = util.xrpToDrops(this._incomingChannelDetails.amount) if (new BigNumber(dropClaimAmount).isGreaterThan(channelAmount)) { const message = 'got claim for amount higher than channel balance. amount: ' + dropClaimAmount.toString() + ' incoming channel amount: ' + channelAmount this._log.error(message) throw new Error(message) } this._incomingClaim = { amount: claimAmount.toString(), signature: claim.signature.toUpperCase() } this._store.set('incoming_claim', JSON.stringify(this._incomingClaim)) if (this._moneyHandler) { await this._moneyHandler(addedMoney.toString()) } return [] } } PluginXrpPaychan.version = 2 module.exports = PluginXrpPaychan