Artifact Content
Not logged in

Artifact 011c62ff712f11187f946676c0e9054defb5e76a:


'use strict'

const uuid = require('uuid4')
const crypto = require('crypto')
const base64url = require('base64url')

const chai = require('chai')
chai.use(require('chai-as-promised'))
const assert = chai.assert
const expect = chai.expect
const btpPacket = require('btp-packet')
const ilpPacket = require('ilp-packet')

const { ilpAndCustomToProtocolData } =
  require('../src/util/protocolDataConverter')
const ObjStore = require('./helpers/objStore')
const MockSocket = require('./helpers/mockSocket')
const PluginPaymentChannel = require('..')

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.',
  maxBalance: '10',
  server: 'btp+wss://user:placeholder@example.com/rpc',
  info: info
}

describe('Conditional Transfers', () => {
  beforeEach(function * () {
    options._store = new ObjStore()
    this.plugin = new PluginPaymentChannel(options)

    this.fulfillment = 'gHJ2QeIZpstXaGZVCSq4d3vkrMSChNYKriefys3KMtI'
    const hash = crypto.createHash('sha256')
    hash.update(this.fulfillment, 'base64')
    this.condition = base64url(hash.digest())

    const expiry = new Date()
    expiry.setSeconds(expiry.getSeconds() + 5)

    this.transferJson = {
      id: uuid(),
      ledger: this.plugin.getInfo().prefix,
      from: this.plugin.getAccount(),
      to: peerAddress,
      amount: '5.0',
      data: {
        field: 'some stuff'
      },
      executionCondition: this.condition,
      expiresAt: expiry.toISOString()
    }
    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: this.fulfillment
    }, requestId + 1, [])

    this.incomingTransferJson = Object.assign({}, this.transferJson, {
      from: peerAddress,
      to: this.plugin.getAccount()
    })
    this.incomingTransfer = btpPacket.serializePrepare(
      Object.assign({}, this.incomingTransferJson, {transferId: this.incomingTransferJson.id}),
      requestId + 2,
      ilpAndCustomToProtocolData(this.incomingTransferJson)
    )

    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()
  })

  afterEach(function * () {
    assert(yield this.mockSocket.isDone(), 'response handlers must be called')
  })

  describe('sendTransfer (conditional)', () => {
    it('allows an outgoing transfer to be fulfilled', function * () {
      this.mockSocket.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
        const expectedPacket = btpPacket.deserialize(this.transfer)
        assert.deepEqual(data, expectedPacket.data)
        return btpPacket.serializeResponse(requestId, [])
      })

      const sent = new Promise((resolve) => this.plugin.on('outgoing_prepare', resolve))
      const fulfilled = new Promise((resolve) => this.plugin.on('outgoing_fulfill', resolve))

      yield this.plugin.sendTransfer(this.transferJson)
      yield sent

      yield this.plugin._rpc.handleMessage(this.mockSocketIndex, this.btpFulfillment)
      yield fulfilled

      assert.equal((yield this.plugin.getBalance()), '-5', 'balance should decrease by amount')
    })

    it('fulfills an incoming transfer', function * () {
      this.mockSocket
        .reply(btpPacket.TYPE_RESPONSE)
        .reply(btpPacket.TYPE_FULFILL, ({requestId, data}) => {
          assert.equal(data.transferId, this.transferJson.id)
          assert.equal(data.fulfillment, this.fulfillment)
          return btpPacket.serializeResponse(requestId, [])
        })

      const fulfilled = new Promise((resolve) => this.plugin.on('incoming_fulfill', resolve))

      yield this.plugin._rpc.handleMessage(this.mockSocketIndex, this.incomingTransfer)
      yield this.plugin.fulfillCondition(this.transferJson.id, this.fulfillment)
      yield fulfilled

      assert.equal((yield this.plugin.getBalance()), '5', 'balance should increase by amount')
    })

    it('cancels an incoming transfer for too much money', function * () {
      this.incomingTransferJson.amount = 100
      this.incomingTransferJson.transferId = this.incomingTransferJson.id
      const transfer = btpPacket.serializePrepare(
        this.incomingTransferJson,
        12345,
        ilpAndCustomToProtocolData(this.incomingTransferJson)
      )

      let incomingPrepared = false
      this.plugin.on('incoming_prepare', () => (incomingPrepared = true))

      this.mockSocket.reply(btpPacket.TYPE_ERROR, ({requestId, data}) => {
        // TODO: assert data contains the expected rejection reason
      })

      yield expect(this.plugin._rpc.handleMessage(this.mockSocketIndex, transfer))
        .to.eventually.be.rejectedWith(/balanceIncomingFulfilledAndPrepared exceeds greatest allowed value/)

      assert.isFalse(incomingPrepared, 'incoming_prepare should not be emitted')
      assert.equal((yield this.plugin.getBalance()), '0', 'balance should not change')
    })

    it('should fulfill a transfer even if inital RPC failed', function * () {
      this.mockSocket
        .reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
          const expectedPacket = btpPacket.deserialize(this.transfer)
          assert.deepEqual(data, expectedPacket.data)
          return btpPacket.serializeResponse(requestId, [])
        })

      const fulfilled = new Promise((resolve) => this.plugin.on('outgoing_fulfill', resolve))
      const sent = new Promise((resolve) => this.plugin.on('outgoing_prepare', resolve))

      yield this.plugin.sendTransfer(this.transferJson)
      yield sent
      yield this.plugin._rpc.handleMessage(this.mockSocketIndex, this.btpFulfillment)
      yield fulfilled

      assert.equal((yield this.plugin.getBalance()), '-5', 'balance should decrease by amount')
    })

    it('doesn\'t fulfill a transfer with invalid fulfillment', function * () {
      this.mockSocket
        .reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
          const expectedPacket = btpPacket.deserialize(this.transfer)
          assert.deepEqual(data, expectedPacket.data)
          return btpPacket.serializeResponse(requestId, [])
        })

      yield this.plugin.sendTransfer(this.transferJson)
      yield expect(this.plugin.fulfillCondition(this.transferJson.id, 'Garbage'))
        .to.eventually.be.rejected
    })

    it('doesn\'t fulfill an outgoing transfer', function * () {
      this.mockSocket
        .reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
          const expectedPacket = btpPacket.deserialize(this.transfer)
          assert.deepEqual(data, expectedPacket.data)
          return btpPacket.serializeResponse(requestId, [])
        })

      yield this.plugin.sendTransfer(this.transferJson)
      yield expect(this.plugin.fulfillCondition(this.transferJson.id, this.fulfillment))
        .to.eventually.be.rejected
    })

    it('should not send a transfer with condition and no expiry', function () {
      this.transferJson.executionCondition = undefined
      return expect(this.plugin.sendTransfer(this.transferJson)).to.eventually.be.rejected
    })

    it('should not send a transfer with expiry and no condition', function () {
      this.transferJson.expiresAt = undefined
      return expect(this.plugin.sendTransfer(this.transferJson)).to.eventually.be.rejected
    })

    it('should resolve even if the event notification handler takes forever', function * () {
      this.mockSocket
        .reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
          const expectedPacket = btpPacket.deserialize(this.transfer)
          assert.deepEqual(data, expectedPacket.data)
          return btpPacket.serializeResponse(requestId, [])
        })

      this.plugin.on('outgoing_prepare', () => new Promise((resolve, reject) => {}))

      yield this.plugin.sendTransfer(this.transferJson)
    })

    it('should resolve even if the event notification handler throws an error', function * () {
      this.mockSocket
        .reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
          const expectedPacket = btpPacket.deserialize(this.transfer)
          assert.deepEqual(data, expectedPacket.data)
          return btpPacket.serializeResponse(requestId, [])
        })

      this.plugin.on('outgoing_prepare', () => {
        throw new Error('blah')
      })

      yield this.plugin.sendTransfer(this.transferJson)
    })

    it('should resolve even if the event notification handler rejects', function * () {
      this.mockSocket
        .reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
          const expectedPacket = btpPacket.deserialize(this.transfer)
          assert.deepEqual(data, expectedPacket.data)
          return btpPacket.serializeResponse(requestId, [])
        })

      this.plugin.on('outgoing_prepare', function * () {
        throw new Error('blah')
      })

      yield this.plugin.sendTransfer(this.transferJson)
    })
  })

  describe('expireTransfer', () => {
    it('expires a transfer', function * () {
      this.mockSocket
        .reply(btpPacket.TYPE_RESPONSE)
        .reply(btpPacket.TYPE_REJECT, ({requestId, data}) => {
          assert.equal(data.transferId, this.incomingTransferJson.transferId)
          const [ ilp ] = data.protocolData.filter(p => p.protocolName === 'ilp')

          // TODO: check that the correct BTP error is returned once they are defined
          const reasonBuffer = ilp.data
          const reason = ilpPacket.deserializeIlpPacket(reasonBuffer).data
          delete reason.triggeredAt
          assert.deepEqual(reason, {
            code: 'R00',
            name: 'Transfer Timed Out',
            triggeredBy: 'example.red.server',
            forwardedBy: [],
            // triggeredAt: ...,
            data: 'expired'
          })
          return btpPacket.serializeResponse(requestId, [])
        })

      this.incomingTransferJson.expiresAt = (new Date(Date.now() + 10)).toISOString()
      this.incomingTransferJson.transferId = this.incomingTransferJson.id
      const incomingTransfer = btpPacket.serializePrepare(
        this.incomingTransferJson,
        12345,
        ilpAndCustomToProtocolData(this.incomingTransferJson)
      )

      const cancel = new Promise((resolve) => this.plugin.on('incoming_cancel', resolve))

      yield this.plugin._rpc.handleMessage(this.mockSocketIndex, incomingTransfer)
      yield cancel

      assert.equal((yield this.plugin.getBalance()), '0', 'balance should not change')
    })

    it('expires an outgoing transfer', function * () {
      this.transferJson.expiresAt = (new Date()).toISOString()
      const expectedTransfer = btpPacket.serializePrepare(
        Object.assign({}, this.transferJson, {transferId: this.transferJson.id}),
        12345,
        ilpAndCustomToProtocolData(this.transferJson)
      )

      this.mockSocket
        .reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
          const expectedPacket = btpPacket.deserialize(expectedTransfer)
          assert.deepEqual(data, expectedPacket.data)
          return btpPacket.serializeResponse(requestId, [])
        })
        .reply(btpPacket.TYPE_REJECT, ({requestId, data}) => {
          assert.equal(data.transferId, this.transferJson.id)
          const [ ilp ] = data.protocolData.filter(p => p.protocolName === 'ilp')

          // TODO: check that the correct BTP error is returned once they are defined
          const reasonBuffer = ilp.data
          const reason = ilpPacket.deserializeIlpPacket(reasonBuffer).data
          delete reason.triggeredAt
          assert.deepEqual(reason, {
            code: 'R00',
            name: 'Transfer Timed Out',
            triggeredBy: this.plugin.getAccount(),
            forwardedBy: [],
            // triggeredAt: ...,
            data: 'expired'
          })
          return btpPacket.serializeResponse(requestId, [])
        })

      const cancel = new Promise((resolve) => this.plugin.on('outgoing_cancel', resolve))

      yield this.plugin.sendTransfer(this.transferJson)
      yield cancel

      assert.equal((yield this.plugin.getBalance()), '0', 'balance should not change')
    })

    it('doesn\'t expire an executed transfer', function * () {
      this.mockSocket
        .reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
          const expectedPacket = btpPacket.deserialize(this.transfer)
          assert.deepEqual(data, expectedPacket.data)
          return btpPacket.serializeResponse(requestId, [])
        })

      const sent = new Promise((resolve) => this.plugin.on('outgoing_prepare', resolve))
      const fulfilled = new Promise((resolve) => this.plugin.on('outgoing_fulfill', resolve))

      yield this.plugin.sendTransfer(this.transferJson)
      yield sent

      yield this.plugin._rpc.handleMessage(this.mockSocketIndex, this.btpFulfillment)
      yield fulfilled
      yield this.plugin._expireTransfer(this.transferJson.id)

      assert.equal((yield this.plugin.getBalance()), '-5', 'balance should not be rolled back')
    })
  })

  describe('rejectIncomingTransfer', () => {
    it('rejects an incoming transfer', function * () {
      const expectedIlpError = {
        code: 'F00',
        name: 'Bad Request',
        triggeredBy: 'example.red.server',
        forwardedBy: ['test.some.connector'],
        triggeredAt: new Date(),
        data: JSON.stringify({ extra: 'data' })
      }

      const expectedRejectionReason = {
        code: 'F00',
        name: 'Bad Request',
        triggered_by: 'example.red.server',
        forwarded_by: 'test.some.connector',
        triggered_at: new Date(),
        additional_info: { extra: 'data' }
      }

      this.mockSocket
        .reply(btpPacket.TYPE_RESPONSE)
        .reply(btpPacket.TYPE_REJECT, ({requestId, data}) => {
          const [ ilp ] = data.protocolData.filter(p => p.protocolName === 'ilp')
          const ilpError = ilpPacket.deserializeIlpError(ilp.data)
          assert.equal(data.transferId, this.transferJson.id)
          assert.deepEqual(ilpError, expectedIlpError)
          return btpPacket.serializeResponse(requestId, [])
        })

      const rejected = new Promise((resolve) => this.plugin.on('incoming_reject', resolve))

      yield this.plugin._rpc.handleMessage(this.mockSocketIndex, this.incomingTransfer)
      yield this.plugin.rejectIncomingTransfer(this.transferJson.id, expectedRejectionReason)
      yield rejected

      assert.equal((yield this.plugin.getBalance()), '0', 'balance should not change')
    })

    it('should allow an outgoing transfer to be rejected', function * () {
      this.mockSocket.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
        const expectedPacket = btpPacket.deserialize(this.transfer)
        assert.deepEqual(data, expectedPacket.data)
        return btpPacket.serializeResponse(requestId, [])
      })

      const triggeredAt = new Date()

      const ilpError = {
        code: 'F00',
        name: 'Bad Request',
        triggeredBy: 'test.your.friendly.peer',
        forwardedBy: ['test.some.connector', 'test.some.other.connector'],
        triggeredAt,
        data: JSON.stringify({ extra: 'data' })
      }
      const reason = {
        code: 'F00',
        name: 'Bad Request',
        triggered_by: 'test.your.friendly.peer',
        forwarded_by: 'test.some.other.connector',
        triggered_at: triggeredAt,
        additional_info: { extra: 'data' }
      }

      const btpRejection = btpPacket.serializeReject({
        transferId: this.transferJson.id
      }, 1111, [{
        protocolName: 'ilp',
        contentType: btpPacket.MIME_APPLICATION_OCTET_STREAM,
        data: ilpPacket.serializeIlpError(ilpError)
      }])

      const rejected = new Promise((resolve) =>
        this.plugin.on('outgoing_reject', (transfer, reason) => resolve(reason)))

      yield this.plugin.sendTransfer(this.transferJson)

      yield this.plugin._rpc.handleMessage(this.mockSocketIndex, btpRejection)
      const gotReason = yield rejected
      assert.deepEqual(gotReason, reason)
    })

    it('should not reject an outgoing transfer', function * () {
      this.mockSocket.reply(btpPacket.TYPE_PREPARE, ({requestId, data}) => {
        const expectedPacket = btpPacket.deserialize(this.transfer)
        assert.deepEqual(data, expectedPacket.data)
        return btpPacket.serializeResponse(requestId, [])
      })

      yield this.plugin.sendTransfer(this.transferJson)
      yield expect(this.plugin.rejectIncomingTransfer(this.transferJson.id, 'reason'))
        .to.eventually.be.rejected
    })

    it('should not allow an incoming transfer to be rejected by sender', function * () {
      this.mockSocket
        .reply(btpPacket.TYPE_RESPONSE)
        .reply(btpPacket.TYPE_ERROR, (requestId, data) => {
          // TODO: Once BTP Erros are defined, check the contents of the returned error (cc: sharafian)
          // ...
        })

      yield this.plugin._rpc.handleMessage(this.mockSocketIndex, this.incomingTransfer)

      const btpRejection = btpPacket.serializeReject({
        transferId: this.transferJson.id,
        rejectionReason: ilpPacket.serializeIlpError({
          code: 'F00',
          name: 'Bad Request',
          triggeredBy: 'g.your.friendly.peer',
          forwardedBy: [],
          triggeredAt: new Date(),
          data: 'reason'
        })
      }, 1111, [])
      yield expect(this.plugin._rpc.handleMessage(this.mockSocketIndex, btpRejection))
        .to.eventually.be.rejected
    })
  })
})