ilp-plugin-payment-channel-framework
Version:
ILP virtual ledger plugin for directly transacting connectors
345 lines (286 loc) • 11.6 kB
JavaScript
'use strict'
const crypto = require('crypto')
const uuid = require('uuid4')
const base64url = require('base64url')
const btpPacket = require('btp-packet')
const sinon = require('sinon')
const chai = require('chai')
chai.use(require('chai-as-promised'))
const assert = chai.assert
const ObjStore = require('./helpers/objStore')
const MockSocket = require('./helpers/mockSocket')
const makePaymentChannelPlugin = require('..').makePaymentChannelPlugin
const { protocolDataToIlpAndCustom, ilpAndCustomToProtocolData } =
require('../src/util/protocolDataConverter')
describe('makePaymentChannelPlugin', function () {
beforeEach(async function () {
this.prefix = 'example.red.'
this.account = 'example.red.alice'
this.peerAccount = 'example.red.bob'
this.info = {
prefix: 'example.red.',
currencyCode: 'USD',
currencyScale: 2,
connectors: [ { id: 'other', name: 'other', connector: 'peer.usd.other' } ]
}
this.opts = {
maxBalance: '1000000',
minBalance: '-40',
server: 'btp+wss://user:seeecret@example.com/rpc',
prefix: 'example.red.',
info: this.info,
_store: new ObjStore()
}
this.channel = {
pluginName: 'dummy',
constructor: (ctx, opts) => {
ctx.rpc.addMethod('echo-protocol', function (str) {
return str + ' back'
})
},
connect: () => Promise.resolve(),
disconnect: () => Promise.resolve(),
handleIncomingPrepare: () => Promise.resolve(),
createOutgoingClaim: () => Promise.resolve(),
handleIncomingClaim: () => Promise.resolve(),
getInfo: () => this.info,
getAccount: () => this.account,
getPeerAccount: () => this.peerAccount,
getAuthToken: () => 'placeholder'
}
this.PluginClass = makePaymentChannelPlugin(this.channel)
this.plugin = new (this.PluginClass)(this.opts)
this.dumyPaychanContext = {
state: {},
rpc: {},
btpRpc: {},
transferLog: {},
backend: {},
plugin: {}
}
this.fulfillment = Buffer.from('zKTdOSh9Fco8r0UcRyFtGsxk8edf2ZpERJPRFb8cCVo', 'base64')
this.transferJson = {
id: uuid(),
ledger: this.plugin.getInfo().prefix,
from: this.plugin.getAccount(),
to: this.plugin.getPeerAccount(),
expiresAt: new Date(Date.now() + 10000).toISOString(),
amount: '5',
custom: {
field: 'some stuff'
},
executionCondition: base64url(crypto
.createHash('sha256')
.update(this.fulfillment)
.digest())
}
const requestId = 12345
this.transfer = btpPacket.serializePrepare(
Object.assign({}, this.transferJson, {transferId: this.transferJson.id}),
requestId,
ilpAndCustomToProtocolData(this.transferJson))
this.btpFulfillment = btpPacket.serializeFulfill({
transferId: this.transferJson.id,
fulfillment: base64url(this.fulfillment)
}, requestId + 1, [])
this.mockSocketIndex = 0
this.mockSocket = new MockSocket()
this.mockSocket
.reply(btpPacket.TYPE_MESSAGE, ({ requestId }) => btpPacket.serializeResponse(requestId, []))
await this.plugin.addSocket(this.mockSocket, { username: 'user', token: 'password' })
await this.plugin.connect()
})
afterEach(async function () {
assert(await this.mockSocket.isDone(), 'request handlers must have been called')
})
describe('constructor', function () {
it('should be called at construct time', function () {
let called = false
this.channel.constructor = (ctx, opts) => {
called = true
assert.deepEqual(ctx.state, {})
assert.equal(opts, this.opts)
}
const res = new (this.PluginClass)(this.opts)
assert.isObject(res)
assert.equal(called, true)
})
it('should give the right name to the class', function () {
assert.equal(this.PluginClass.name, 'PluginDummy')
})
})
describe('connect', function () {
it('is called once when the plugin connects', async function () {
const connectSpy = sinon.spy(this.channel, 'connect')
const plugin = new (this.PluginClass)(this.opts)
await plugin.connect()
await plugin.connect()
assert(connectSpy.calledOnce, 'expected connect() to be called once')
assert(connectSpy.calledWithMatch(this.dumyPaychanContext),
'expected to be called with paychan context object')
})
it('causes connect to fail if it throws', async function () {
let called = false
this.channel.connect = (ctx, opts) => {
called = true
throw new Error('no')
}
const plugin = new (this.PluginClass)(this.opts)
await assert.isRejected(plugin.connect(), /^no$/)
assert.equal(called, true)
})
})
describe('disconnect', function () {
it('is called once when the plugin disconnects', async function () {
const disconnectSpy = sinon.spy(this.channel, 'disconnect')
await this.plugin.disconnect()
await this.plugin.disconnect()
assert(disconnectSpy.calledOnce, 'expected disconnect() to be called once')
assert(disconnectSpy.calledWithMatch(this.dumyPaychanContext),
'expected to be called with paychan context object')
})
})
describe('handleIncomingPrepare', function () {
it('should be called when a transfer is prepared', async function () {
let called = false
this.channel.handleIncomingPrepare = (ctx, transfer) => {
called = true
assert.deepEqual(ctx.state, {})
assert.equal(ctx.plugin, this.plugin)
assert.deepEqual(transfer, this.transferJson)
}
this.transferJson.from = this.transferJson.to
this.transferJson.to = this.plugin.getAccount()
const emitted = new Promise((resolve) => this.plugin.on('incoming_prepare', resolve))
this.plugin._rpc.handleMessage(this.mockSocketIndex, this.transfer)
await emitted
assert.equal(called, true)
assert.equal(await this.plugin._transfers.getIncomingFulfilledAndPrepared(), '5')
})
it('should make prepare throw if handler throws', async function () {
let called = false
this.channel.handleIncomingPrepare = (ctx, transfer) => {
called = true
throw new Error('no')
}
this.transfer.from = this.transfer.to
this.transfer.to = this.plugin.getAccount()
let emitted = false
this.plugin.on('incoming_prepare', () => {
emitted = true
})
this.mockSocket.reply(btpPacket.TYPE_ERROR)
await assert.isRejected(
this.plugin._rpc.handleMessage(this.mockSocketIndex, this.transfer),
/^no$/)
assert.equal(called, true)
assert.equal(emitted, false, 'should not emit if handleIncoming throws')
// should cancel the payment
assert.equal(await this.plugin._transfers.getIncomingFulfilledAndPrepared(), '0')
})
})
describe('createOutgoingClaim', function () {
it('should be called when an outgoing transfer is fulfilled', async function () {
const called = new Promise((resolve, reject) => {
this.channel.createOutgoingClaim = (ctx, outgoing) => {
try {
assert.deepEqual(ctx.state, {})
assert.equal(ctx.plugin, this.plugin)
assert.equal(outgoing, '5')
} catch (e) {
reject(e)
}
resolve()
return { foo: 'bar' }
}
})
this.mockSocket.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
const expectedPacket = btpPacket.deserialize(this.transfer)
assert.deepEqual(data, expectedPacket.data)
return btpPacket.serializeResponse(requestId, [])
}).reply(btpPacket.TYPE_RESPONSE)
await this.plugin.sendTransfer(this.transferJson)
await this.plugin._rpc.handleMessage(this.mockSocketIndex, this.btpFulfillment)
await called
assert.equal(await this.plugin._transfers.getOutgoingFulfilled(), '5')
})
it('should not fail fulfillCondition if it fails', async function () {
this.channel.createOutgoingClaim = (ctx, outgoing) => {
throw new Error('this will be logged but swallowed')
}
this.mockSocket.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
const expectedPacket = btpPacket.deserialize(this.transfer)
assert.deepEqual(data, expectedPacket.data)
return btpPacket.serializeResponse(requestId, [])
}).reply(btpPacket.TYPE_RESPONSE)
await this.plugin.sendTransfer(this.transferJson)
await this.plugin._rpc.handleMessage(this.mockSocketIndex, this.btpFulfillment)
assert.equal(await this.plugin._transfers.getOutgoingFulfilled(), '5')
})
})
describe('handleIncomingClaim', function () {
it('should be called when an incoming transfer is fulfilled', async function () {
const called = new Promise((resolve, reject) => {
this.channel.handleIncomingClaim = (ctx, claim) => {
try {
assert.deepEqual(ctx.state, {})
assert.equal(ctx.plugin, this.plugin)
assert.deepEqual(claim, { foo: 'bar' })
} catch (e) {
reject(e)
}
resolve()
}
})
this.mockSocket
.reply(btpPacket.TYPE_RESPONSE)
.reply(btpPacket.TYPE_FULFILL, ({requestId, data}) => {
return btpPacket.serializeResponse(requestId, [{
protocolName: 'claim',
contentType: btpPacket.MIME_APPLICATION_JSON,
data: Buffer.from(JSON.stringify({ foo: 'bar' }))
}])
})
this.transfer.from = this.transfer.to
this.transfer.to = this.plugin.getAccount()
await this.plugin._rpc.handleMessage(this.mockSocketIndex, this.transfer)
await this.plugin.fulfillCondition(this.transferJson.id, base64url(this.fulfillment))
await called
})
it('should not fail fulfillCondition if it throws', async function () {
this.channel.handleIncomingClaim = (ctx, claim) => {
throw new Error('will be logged but swallowed')
}
this.mockSocket
.reply(btpPacket.TYPE_RESPONSE)
.reply(btpPacket.TYPE_FULFILL, ({requestId, data}) => {
assert.equal(data.transferId, this.transferJson.id)
assert.equal(data.fulfillment, base64url(this.fulfillment))
return btpPacket.serializeResponse(requestId, [{
protocolName: 'claim',
contentType: btpPacket.MIME_APPLICATION_JSON,
data: Buffer.from(JSON.stringify({ foo: 'bar' }))
}])
})
this.transfer.from = this.transfer.to
this.transfer.to = this.plugin.getAccount()
await this.plugin._rpc.handleMessage(this.mockSocketIndex, this.transfer)
await this.plugin.fulfillCondition(this.transferJson.id, base64url(this.fulfillment))
})
})
describe('side-protocols', function () {
it('should handle custom side-protocols in a BTP message', function * () {
this.mockSocket.reply(btpPacket.TYPE_RESPONSE, ({data}) => {
const { protocolMap } = protocolDataToIlpAndCustom(data)
assert(protocolMap)
assert.equal(protocolMap['echo-protocol'], 'hello there back')
})
const btpMessage = btpPacket.serializeMessage(12345, [{
protocolName: 'echo-protocol',
contentType: btpPacket.MIME_APPLICATION_JSON,
data: Buffer.from(JSON.stringify('hello there'))
}])
this.plugin._rpc.handleMessage(this.mockSocketIndex, btpMessage)
})
})
})