ilp-plugin-payment-channel-framework
Version:
ILP virtual ledger plugin for directly transacting connectors
448 lines (368 loc) • 16.3 kB
JavaScript
'use strict'
const crypto = require('crypto')
const uuid = require('uuid4')
const ilpPacket = require('ilp-packet')
const btpPacket = require('btp-packet')
const base64url = require('base64url')
const sinon = require('sinon')
const chai = require('chai')
chai.use(require('chai-as-promised'))
const assert = chai.assert
const expect = chai.expect
const ObjStore = require('./helpers/objStore')
const PluginPaymentChannel = require('..')
const MockSocket = require('./helpers/mockSocket')
const { protocolDataToIlpAndCustom, ilpAndCustomToProtocolData } =
require('../src/util/protocolDataConverter')
const info = {
prefix: 'example.red.',
currencyCode: 'USD',
currencyScale: 2,
connectors: [ { id: 'other', name: 'other', connector: 'peer.usd.other' } ]
}
const peerAddress = 'example.red.client'
const options = {
prefix: 'example.red.',
currencyCode: 'USD',
currencyScale: 2,
maxBalance: '1000000',
minBalance: '-40',
server: 'btp+wss://user:placeholder@example.com/rpc',
info: info
}
describe('Send', () => {
beforeEach(function * () {
options._store = new ObjStore()
this.plugin = new PluginPaymentChannel(options)
this.mockSocketIndex = 0
this.mockSocket = new MockSocket()
this.mockSocket
.reply(btpPacket.TYPE_MESSAGE, ({ requestId }) => btpPacket.serializeResponse(requestId, []))
yield this.plugin.addSocket(this.mockSocket, { username: 'user', token: 'placeholder' })
yield this.plugin.connect()
this.error = {
code: 'F00',
name: 'Bad Request',
triggeredAt: new Date(),
data: JSON.stringify({ message: 'Peer isn\'t feeling like it.' })
}
})
afterEach(function * () {
assert(yield this.mockSocket.isDone(), 'request handlers must have been called')
})
describe('RPC', () => {
it('should throw an error on an error code', function () {
const expectedRequestId = 1234
this.mockSocket.reply(btpPacket.TYPE_MESSAGE, ({requestId, data}) => {
assert.equal(requestId, expectedRequestId)
return btpPacket.serializeError(this.error, requestId, [])
})
const cllpMessage = btpPacket.serializeMessage(expectedRequestId, [])
return expect(this.plugin._rpc._call(expectedRequestId, cllpMessage))
.to.eventually.be.rejected
})
// TODO: reassess whether this test case is still necessary (cc: sharafian)
it.skip('should send authorization bearer token', function () {
// nock('https://example.com', {
// reqheaders: {
// 'Authorization': 'Bearer ' + this.plugin._getAuthToken()
// }
// })
// .post('/rpc?method=method&prefix=example.red.', [])
// .reply(200, { a: 'b' })
return expect(this.plugin._rpc.call('method', 'example.red.', []))
.to.eventually.deep.equal({ a: 'b' })
})
})
describe('sendRequest', () => {
beforeEach(function * () {
this.message = {
from: this.plugin.getAccount(),
to: peerAddress,
ledger: this.plugin.getInfo().prefix,
ilp: base64url('some_base64_encoded_data_goes_here'),
custom: {
field: 'some stuff'
}
}
this.response = {
from: peerAddress,
to: this.plugin.getAccount(),
ledger: this.plugin.getInfo().prefix,
ilp: base64url('some_other_base64_encoded_data_goes_here'),
custom: {
field: 'some other stuff'
}
}
})
it('should send a request', function * () {
this.mockSocket.reply(btpPacket.TYPE_MESSAGE, ({requestId, data}) => {
const {ilp, custom} = protocolDataToIlpAndCustom(data)
assert.equal(ilp, this.message.ilp)
assert.deepEqual(custom, this.message.custom)
return btpPacket.serializeResponse(requestId,
ilpAndCustomToProtocolData(this.response))
})
const outgoing = new Promise((resolve) => this.plugin.on('outgoing_request', resolve))
const incoming = new Promise((resolve) => this.plugin.on('incoming_response', resolve))
const response = yield this.plugin.sendRequest(this.message)
yield outgoing
yield incoming
assert.equal(response.ilp, this.response.ilp)
assert.deepEqual(response.custom, this.response.custom)
})
it('should respond to a request', function * () {
this.response.to = this.message.from = peerAddress
this.response.from = this.message.to = this.plugin.getAccount()
this.plugin.registerRequestHandler((request) => {
assert.equal(request.ilp, this.message.ilp)
assert.deepEqual(request.custom, this.message.custom)
return Promise.resolve(this.response)
})
this.mockSocket.reply(btpPacket.TYPE_RESPONSE, ({requestId, data}) => {
const {ilp, custom} = protocolDataToIlpAndCustom(data)
assert.equal(ilp, this.response.ilp)
assert.deepEqual(custom, this.response.custom)
})
const incoming = new Promise((resolve) => this.plugin.on('incoming_request', resolve))
const outgoing = new Promise((resolve) => this.plugin.on('outgoing_response', resolve))
const btpMessage = btpPacket.serializeMessage(1111,
ilpAndCustomToProtocolData(this.message))
yield this.plugin._rpc.handleMessage(this.mockSocketIndex, btpMessage)
yield incoming
yield outgoing
})
it('should return an ILP error if the request handler errors', function * () {
this.response.to = this.message.from = peerAddress
this.response.from = this.message.to = this.plugin.getAccount()
this.plugin.registerRequestHandler((request) => {
return Promise.reject(new Error('this is an error'))
})
this.mockSocket.reply(btpPacket.TYPE_RESPONSE, ({data}) => {
const {ilp} = protocolDataToIlpAndCustom(data)
const error = ilpPacket.deserializeIlpError(Buffer.from(ilp, 'base64'))
assert.equal(error.code, 'F00')
assert.equal(error.name, 'Bad Request')
assert.equal(error.triggeredBy, this.plugin.getAccount())
assert.deepEqual(error.forwardedBy, [])
assert.deepEqual(JSON.parse(error.data), { message: 'this is an error' })
})
const btpMessage = btpPacket.serializeMessage(1111,
ilpAndCustomToProtocolData(this.message))
yield this.plugin._rpc.handleMessage(this.mockSocketIndex, btpMessage)
})
it('should throw an error if a handler is already registered', function * () {
this.plugin.registerRequestHandler(() => {})
assert.throws(() => this.plugin.registerRequestHandler(() => {}),
/requestHandler is already registered/)
})
it('should throw an error if no handler is registered', function * () {
this.response.to = this.message.from = peerAddress
this.response.from = this.message.to = this.plugin.getAccount()
assert.isNotOk(this.plugin._requestHandler, 'handler should not be registered yet')
this.plugin.registerRequestHandler((request) => {
assert.deepEqual(request, this.message)
return Promise.resolve(this.response)
})
this.plugin.deregisterRequestHandler()
const btpMessage = btpPacket.serializeMessage(1111,
ilpAndCustomToProtocolData(this.message))
yield expect(this.plugin._rpc.handleMessage(this.mockSocketIndex, btpMessage))
.to.be.rejectedWith(/no request handler registered/)
})
it('should throw an error on no response', function * () {
const clock = sinon.useFakeTimers({ toFake: ['setTimeout'] })
this.mockSocket.reply(btpPacket.TYPE_MESSAGE, () => {
// sending no response back triggers a timeout on the sending side
clock.tick(10000)
})
return expect(this.plugin.sendRequest(this.message)).to.eventually.be.rejected
})
it('should not send without an account or to-from', function () {
this.message.account = undefined
this.message.to = undefined
this.message.from = undefined
return expect(this.plugin.sendRequest(this.message))
.to.eventually.be.rejectedWith(/must have a destination/)
})
it('should not send with incorrect ledger', function () {
this.message.ledger = 'bogus'
return expect(this.plugin.sendRequest(this.message))
.to.eventually.be.rejectedWith(/ledger .+ must match ILP prefix/)
})
})
describe('sendTransfer (log and balance logic)', () => {
beforeEach(function * () {
this.fulfillment = require('crypto').randomBytes(32)
this.transfer = {
id: uuid(),
ledger: this.plugin.getInfo().prefix,
from: this.plugin.getAccount(),
to: peerAddress,
expiresAt: new Date(Date.now() + 10000).toISOString(),
amount: '5',
custom: {
field: 'some stuff'
},
executionCondition: base64url(crypto
.createHash('sha256')
.update(this.fulfillment)
.digest())
}
this.btpTransfer = btpPacket.serializePrepare(
Object.assign({}, this.transfer, {transferId: this.transfer.id}),
12345, // requestId
ilpAndCustomToProtocolData(this.transfer)
)
this.btpFulfillment = btpPacket.serializeFulfill({
transferId: this.transfer.id,
fulfillment: this.fulfillment
}, 98765, [])
})
it('should send a transfer', async function () {
this.mockSocket.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
assert.equal(data.transferId, this.transfer.id)
assert.equal(data.amount, +this.transfer.amount)
assert.equal(data.executionCondition, this.transfer.executionCondition)
assert.equal(data.expiresAt.getTime(),
new Date(this.transfer.expiresAt).getTime())
const {custom} = protocolDataToIlpAndCustom(data)
assert.deepEqual(custom, this.transfer.custom)
return btpPacket.serializeResponse(requestId, [])
})
const sent = new Promise((resolve) => this.plugin.on('outgoing_prepare', resolve))
await this.plugin.sendTransfer(this.transfer)
await sent
// TODO: @sharafian, when is the balance supposed to be updated?
// At the moment, a call to sendTransfer does not update the balance, see below:
// await new Promise(async (resolve, reject) => {
// setTimeout(async () => {
// const balance = await this.plugin.getBalance()
// console.log('balance', balance)
// resolve()
// }, 200)
// })
})
it('should roll back a transfer if the RPC call fails', function * () {
this.mockSocket.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
return btpPacket.serializeError(this.error,
requestId, [])
})
yield expect(this.plugin.sendTransfer(this.transfer))
.to.eventually.be.rejected
assert.equal((yield this.plugin.getBalance()), '0', 'balance should be rolled back')
})
it('should receive a transfer', function * () {
const received = new Promise((resolve, reject) => {
this.plugin.on('incoming_prepare', (transfer) => {
try {
assert.deepEqual(transfer, this.transfer)
} catch (e) {
reject(e)
}
resolve()
})
})
this.transfer.from = peerAddress
this.transfer.to = this.plugin.getAccount()
this.mockSocket.reply(btpPacket.TYPE_RESPONSE)
yield this.plugin._rpc.handleMessage(this.mockSocketIndex, this.btpTransfer)
yield received
})
it('should not race when reading the balance', function * () {
const transfer2 = Object.assign({}, this.transfer, { id: uuid() })
const fulfillment2 = btpPacket.serializeFulfill({
transferId: transfer2.id,
fulfillment: this.fulfillment
}, 98765, [])
this.mockSocket
.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
return btpPacket.serializeResponse(requestId, [])
})
.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
return btpPacket.serializeResponse(requestId, [])
})
.reply(btpPacket.TYPE_RESPONSE)
.reply(btpPacket.TYPE_RESPONSE)
yield this.plugin.sendTransfer(this.transfer)
yield this.plugin.sendTransfer(transfer2)
const send1 = this.plugin._rpc.handleMessage(this.mockSocketIndex, this.btpFulfillment)
const send2 = this.plugin._rpc.handleMessage(this.mockSocketIndex, fulfillment2)
yield Promise.all([ send1, send2 ])
assert.equal(yield this.plugin.getBalance(), '-10',
'both transfers should be applied to the balance')
})
it('should not apply twice when two identical transfers come in with the same id', function * () {
this.mockSocket
.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
return btpPacket.serializeResponse(requestId, [])
})
.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
return btpPacket.serializeResponse(requestId, [])
})
.reply(btpPacket.TYPE_ERROR)
.reply(btpPacket.TYPE_RESPONSE)
yield this.plugin.sendTransfer(this.transfer)
yield this.plugin.sendTransfer(this.transfer)
const send1 = this.plugin._rpc.handleMessage(this.mockSocketIndex, this.btpFulfillment)
const send2 = this.plugin._rpc.handleMessage(this.mockSocketIndex, this.btpFulfillment)
.catch((e) => {})
yield Promise.all([ send1, send2 ])
assert.equal(yield this.plugin.getBalance(), '-5',
'only one of the transfers should be applied to the balance')
})
it('should not race when two different transfers come in with the same id', function * () {
this.mockSocket
.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
return btpPacket.serializeResponse(requestId, [])
})
const transfer2 = Object.assign({}, this.transfer, { amount: '10' })
const send1 = this.plugin.sendTransfer(this.transfer)
const send2 = this.plugin.sendTransfer(transfer2)
// one of these should be rejected because they are two transfer with the
// same ID but different data
yield expect(Promise.all([ send1, send2 ]))
.to.eventually.be.rejectedWith(/transfer .* matches the id of .* but not the contents/)
})
it('should not send a transfer without id', function () {
this.transfer.id = undefined
return expect(this.plugin.sendTransfer(this.transfer)).to.eventually.be.rejected
})
it('should not send a transfer with an invalid id', function () {
this.transfer.id = 666
return expect(this.plugin.sendTransfer(this.transfer)).to.eventually.be.rejected
})
it('should not send a transfer without to', function () {
delete this.transfer.to
return expect(this.plugin.sendTransfer(this.transfer)).to.eventually.be.rejected
})
it('should not send a transfer with an invalid to', function () {
this.transfer.to = '$$$ cawiomdaAW ($Q@@)$@$'
return expect(this.plugin.sendTransfer(this.transfer)).to.eventually.be.rejected
})
it('should not send a transfer with a non-string to', function () {
this.transfer.to = 42
return expect(this.plugin.sendTransfer(this.transfer)).to.eventually.be.rejected
})
it('should not send a transfer with non-object data', function () {
this.transfer.data = 9000
return expect(this.plugin.sendTransfer(this.transfer)).to.eventually.be.rejected
})
it('should not send a transfer with no amount', function () {
this.transfer.amount = undefined
return expect(this.plugin.sendTransfer(this.transfer)).to.eventually.be.rejected
})
it('should not send a transfer with non-number amount', function () {
this.transfer.amount = 'bogus'
return expect(this.plugin.sendTransfer(this.transfer)).to.eventually.be.rejected
})
it('should not send a transfer with amount over limit', function () {
this.transfer.amount = '50.0'
return expect(this.plugin.sendTransfer(this.transfer)).to.eventually.be.rejected
})
it('should not send a transfer with negative amount', function () {
this.transfer.amount = '-5.0'
return expect(this.plugin.sendTransfer(this.transfer)).to.eventually.be.rejected
})
})
})