├── npmrc-env ├── docs └── assets │ ├── sendmax.png │ ├── receivemax.png │ └── stream-in-protocol-suite.svg ├── tsconfig.test.json ├── src ├── util │ ├── formatters.ts │ ├── plugin-interface.ts │ ├── stoppable-timeout.ts │ ├── data-offset-sorter.ts │ ├── data-queue.ts │ ├── receipt.ts │ ├── crypto-node.ts │ ├── long.ts │ ├── rational.ts │ ├── congestion.ts │ └── crypto-browser.ts ├── crypto.ts ├── index.ts ├── pool.ts ├── server.ts └── packet.ts ├── tslint.json ├── typedoc.js ├── .mocharc.yaml ├── .gitignore ├── wallaby.js ├── tsconfig.json ├── scripts ├── publish-docs.js ├── example-web.js └── generate-fixtures.ts ├── test ├── browser │ ├── main.js │ └── webpack.config.js ├── benchmarks │ └── packet.ts ├── util │ ├── data-queue.test.ts │ ├── data-offset-sorter.test.ts │ ├── receipt.test.ts │ ├── rational.test.ts │ └── long.test.ts ├── crypto.test.ts ├── mocks │ └── plugin.ts ├── browser.test.ts ├── packet.test.ts ├── index.test.ts └── fixtures │ └── packets.json ├── example.js ├── package.json ├── .circleci └── config.yml └── README.md /npmrc-env: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} 2 | -------------------------------------------------------------------------------- /docs/assets/sendmax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interledgerjs/ilp-protocol-stream/HEAD/docs/assets/sendmax.png -------------------------------------------------------------------------------- /docs/assets/receivemax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interledgerjs/ilp-protocol-stream/HEAD/docs/assets/receivemax.png -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2018" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/util/formatters.ts: -------------------------------------------------------------------------------- 1 | import { formatters } from 'ilp-logger' 2 | 3 | formatters.h = (v: Buffer): string => v.toString('hex') 4 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-standard", 3 | "linterOptions": { 4 | "exclude": [ 5 | "test/**/*" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | out: 'doc', 3 | excludePrivate: true, 4 | excludeProtected: true, 5 | excludeExternals: true, 6 | mode: 'modules' 7 | } 8 | -------------------------------------------------------------------------------- /.mocharc.yaml: -------------------------------------------------------------------------------- 1 | exit: true 2 | require: 3 | - ts-node/register 4 | - source-map-support/register 5 | recursive: true 6 | spec: 7 | - test/*.test.ts 8 | - test/util/*.test.ts 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | **/*.js 4 | **/*.js.map 5 | **/*.d.ts 6 | !example.js 7 | !typedoc.js 8 | !scripts/example-web.js 9 | !scripts/publish-docs.js 10 | !test/browser/*.js 11 | coverage 12 | .nyc_output 13 | doc 14 | .idea 15 | yarn.lock 16 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function (w) { 2 | return { 3 | files: [ 4 | 'src/**/*.ts', 5 | 'test/mocks/*.ts' 6 | ], 7 | 8 | tests: [ 9 | 'test/**/*.test.ts' 10 | ], 11 | 12 | env: { 13 | type: 'node' 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/util/plugin-interface.ts: -------------------------------------------------------------------------------- 1 | /** @private */ 2 | export interface Plugin { 3 | connect: () => Promise, 4 | disconnect: () => Promise, 5 | isConnected: () => boolean, 6 | sendData: (data: Buffer) => Promise, 7 | registerDataHandler: (handler: (data: Buffer) => Promise) => void, 8 | deregisterDataHandler: () => void 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "outDir": "dist", 5 | "module": "commonjs", 6 | "declaration": true, 7 | "noImplicitAny": true, 8 | "removeComments": true, 9 | "moduleResolution": "node", 10 | "sourceMap": true, 11 | "inlineSources": true, 12 | "strictNullChecks": true, 13 | "suppressImplicitAnyIndexErrors": true 14 | }, 15 | "exclude": [ 16 | "dist", 17 | "node_modules" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /scripts/publish-docs.js: -------------------------------------------------------------------------------- 1 | const ghpages = require('gh-pages') 2 | const fs = require('fs') 3 | 4 | console.log('Publishing docs to Github Pages...') 5 | 6 | // Disable Jekyll from parsing docs (because it doesn't like files that start with '_') 7 | fs.writeFileSync('doc/.nojekyll', 'Disable Jekyll\n') 8 | 9 | ghpages.publish('doc', { 10 | src: ['**/*', '.nojekyll'], 11 | message: 'docs: [skip ci] Publish docs', 12 | repo: 'git@github.com:interledgerjs/ilp-protocol-stream.git', 13 | user: { 14 | name: 'CircleCI', 15 | email: 'none' 16 | } 17 | }, function (err) { 18 | if (err) { 19 | console.log(err) 20 | process.exit(1) 21 | } 22 | 23 | console.log('Published docs') 24 | }) 25 | -------------------------------------------------------------------------------- /test/browser/main.js: -------------------------------------------------------------------------------- 1 | // This code is executed within Chromium by Puppeteer. 2 | 3 | const IlpPluginBtp = require('ilp-plugin-btp') 4 | const { createConnection, Connection } = require('../..') 5 | 6 | // Use a wrapper function, because for some reason attaching `Client` to `window` 7 | // loses the constructor so the test can't use it. 8 | async function makeStreamClient (btpOpts, opts) { 9 | const clientPlugin = new IlpPluginBtp(btpOpts) 10 | return await createConnection({ 11 | plugin: clientPlugin, 12 | destinationAccount: opts.destinationAccount, 13 | sharedSecret: Buffer.from(opts.sharedSecret, 'base64'), 14 | slippage: 0 15 | }) 16 | } 17 | 18 | window.makeStreamClient = makeStreamClient 19 | window.runCryptoTests = require('../crypto.test').runCryptoTests 20 | -------------------------------------------------------------------------------- /test/browser/webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | 4 | module.exports = { 5 | mode: 'development', 6 | entry: './test/browser/main.js', 7 | resolve: { 8 | aliasFields: ['browser'], 9 | extensions: ['.tsx', '.ts', '.js', '.json'] 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.tsx?$/, 15 | loader: 'ts-loader', 16 | options: { onlyCompileBundledFiles: true }, 17 | } 18 | ] 19 | }, 20 | output: { 21 | filename: 'dist/test/browser/bundle.js', 22 | path: path.resolve(__dirname, '../..'), 23 | }, 24 | optimization: { usedExports: true }, 25 | 26 | node: { 27 | console: true, 28 | fs: 'empty', 29 | net: 'empty', 30 | tls: 'empty', 31 | crypto: 'empty' 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/util/stoppable-timeout.ts: -------------------------------------------------------------------------------- 1 | // When `stop` is been called: 2 | // - The current `wait` promise (if any) will reject. 3 | // - All future `wait` calls will reject. 4 | export class StoppableTimeout { 5 | private stopped: boolean = false 6 | private timer: NodeJS.Timer 7 | private reject?: (err: Error) => void 8 | 9 | wait (delay: number): Promise { 10 | if (this.stopped) { 11 | return Promise.reject(new Error('timer stopped')) 12 | } 13 | 14 | return new Promise((resolve, reject) => { 15 | this.timer = setTimeout(resolve, delay) 16 | this.reject = reject 17 | }) 18 | } 19 | 20 | stop (): void { 21 | clearTimeout(this.timer) 22 | this.stopped = true 23 | if (this.reject) { 24 | this.reject(new Error('timer stopped')) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/benchmarks/packet.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto' 2 | const Benchmark = require('benchmark') 3 | import * as PacketV1 from '../../src/packet' 4 | const packageV0 = process.argv[2] 5 | if (!packageV0) { 6 | console.error('usage: node ' + process.argv.slice(0, 2).join(' ') + ' ') 7 | process.exit(1) 8 | } 9 | const PacketV0 = require(packageV0) 10 | 11 | const moneyPacketV0 = new PacketV0.Packet(0, 14, 5, [ 12 | new PacketV0.StreamMoneyFrame(1, 1), 13 | new PacketV0.StreamMoneyFrame(2, 2) 14 | ]) 15 | 16 | const moneyPacketV1 = new PacketV1.Packet(0, 14, 5, [ 17 | new PacketV1.StreamMoneyFrame(1, 1), 18 | new PacketV1.StreamMoneyFrame(2, 2) 19 | ]) 20 | 21 | // TODO test data & control frames 22 | 23 | const encryptionKey = crypto.randomBytes(32) 24 | const packetBuffer = moneyPacketV0._serialize() 25 | 26 | ;(new Benchmark.Suite('serialize: MoneyFrame')) 27 | .add('v0', function () { moneyPacketV0._serialize() }) 28 | .add('v1', function () { moneyPacketV1._serialize() }) 29 | .on('cycle', function(event: any) { 30 | console.log(this.name, '\t', String(event.target)); 31 | }) 32 | .run({}) 33 | 34 | ;(new Benchmark.Suite('deserialize: MoneyFrame')) 35 | .add('v0', function () { PacketV0.Packet._deserializeUnencrypted(packetBuffer) }) 36 | .add('v1', function () { PacketV1.Packet._deserializeUnencrypted(packetBuffer) }) 37 | .on('cycle', function(event: any) { 38 | console.log(this.name, '\t', String(event.target)); 39 | }) 40 | .run({}) 41 | -------------------------------------------------------------------------------- /src/crypto.ts: -------------------------------------------------------------------------------- 1 | // When webpacked, "crypto-node" is replaced with "crypto-browser". 2 | import { hmac, randomBytes } from './util/crypto-node' 3 | export { 4 | decrypt, 5 | decryptConnectionAddressToken, // only in node, not browser 6 | encrypt, 7 | encryptConnectionAddressToken, // only in node, not browser 8 | generateSharedSecretFromToken, // only in node, not browser 9 | generateReceiptHMAC, // only in node, not browser 10 | hash, 11 | hmac, 12 | randomBytes 13 | } from './util/crypto-node' 14 | 15 | export const TOKEN_NONCE_LENGTH = 18 16 | const ENCRYPTION_KEY_STRING = Buffer.from('ilp_stream_encryption', 'utf8') 17 | const FULFILLMENT_GENERATION_STRING = Buffer.from('ilp_stream_fulfillment', 'utf8') 18 | const PACKET_ID_STRING = Buffer.from('ilp_stream_packet_id', 'utf8') 19 | export const ENCRYPTION_OVERHEAD = 28 20 | 21 | export function generateTokenNonce (): Buffer { 22 | return randomBytes(TOKEN_NONCE_LENGTH) 23 | } 24 | 25 | export function generateRandomCondition (): Buffer { 26 | return randomBytes(32) 27 | } 28 | 29 | export function generatePskEncryptionKey (sharedSecret: Buffer): Promise { 30 | return hmac(sharedSecret, ENCRYPTION_KEY_STRING) 31 | } 32 | 33 | export function generateFulfillmentKey (sharedSecret: Buffer): Promise { 34 | return hmac(sharedSecret, FULFILLMENT_GENERATION_STRING) 35 | } 36 | 37 | export function generateFulfillment (fulfillmentKey: Buffer, data: Buffer): Promise { 38 | return hmac(fulfillmentKey, data) 39 | } 40 | 41 | export function generateIncomingPacketId (sharedSecret: Buffer, sequence: Long): Promise { 42 | return hmac(sharedSecret, Buffer.concat([PACKET_ID_STRING, Buffer.from(sequence.toBytes())])) 43 | } 44 | -------------------------------------------------------------------------------- /src/util/data-offset-sorter.ts: -------------------------------------------------------------------------------- 1 | // Inspired by https://github.com/toajs/quic/blob/master/src/stream.ts 2 | 3 | class OffsetDataEntry { 4 | data: Buffer 5 | offset: number 6 | next?: OffsetDataEntry 7 | 8 | constructor (data: Buffer, offset: number, next?: OffsetDataEntry) { 9 | this.data = data 10 | this.offset = offset 11 | this.next = next 12 | } 13 | } 14 | 15 | /** @private */ 16 | export class OffsetSorter { 17 | private head?: OffsetDataEntry 18 | readOffset: number 19 | maxOffset: number 20 | 21 | constructor () { 22 | this.readOffset = 0 23 | this.maxOffset = 0 24 | } 25 | 26 | push (data: Buffer, offset: number) { 27 | const entry = new OffsetDataEntry(data, offset) 28 | 29 | this.maxOffset = Math.max(offset + data.length, this.maxOffset) 30 | 31 | if (!this.head) { 32 | this.head = entry 33 | } else if (this.head.offset > offset) { 34 | entry.next = this.head 35 | this.head = entry 36 | } else { 37 | let prev = this.head 38 | while (true) { 39 | if (!prev.next) { 40 | prev.next = entry 41 | break 42 | } 43 | if (prev.next.offset > offset) { 44 | entry.next = prev.next 45 | prev.next = entry 46 | break 47 | } 48 | prev = prev.next 49 | } 50 | } 51 | } 52 | 53 | read (): Buffer | undefined { 54 | let data 55 | if (this.head && this.readOffset === this.head.offset) { 56 | data = this.head.data 57 | this.readOffset = this.head.offset + (data ? data.length : 0) 58 | this.head = this.head.next 59 | } 60 | return data 61 | } 62 | 63 | // Only returns contiguous data 64 | byteLength (): number { 65 | let length = 0 66 | let entry = this.head 67 | let offset = this.readOffset 68 | while (entry && entry.offset === offset) { 69 | length += entry.data.length 70 | offset += entry.data.length 71 | entry = entry.next 72 | } 73 | return length 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/util/data-queue.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import { DataQueue } from '../../src/util/data-queue' 3 | 4 | describe('DataQueue', function () { 5 | beforeEach(function () { 6 | this.queue = new DataQueue() 7 | }) 8 | 9 | describe('read', function () { 10 | it('returns undefined when there is no data', function () { 11 | assert.equal(this.queue.read(1), undefined) 12 | }) 13 | 14 | it('returns a partial chunk', function () { 15 | this.queue.push(Buffer.from("abc")) 16 | assert.deepEqual(this.queue.read(1), Buffer.from("a")) 17 | }) 18 | 19 | it('returns avalable data', function () { 20 | this.queue.push(Buffer.from("abc")) 21 | assert.deepEqual(this.queue.read(9), Buffer.from("abc")) 22 | }) 23 | 24 | it('returns sequential data', function () { 25 | this.queue.push(Buffer.from("abc")) 26 | this.queue.push(Buffer.from("def")) 27 | assert.deepEqual(this.queue.read(2), Buffer.from("ab")) 28 | assert.deepEqual(this.queue.read(2), Buffer.from("cd")) 29 | assert.deepEqual(this.queue.read(2), Buffer.from("ef")) 30 | assert.equal(this.queue.read(1), undefined) 31 | }) 32 | 33 | it('calls the callback when a chunk is consumed', function () { 34 | let c1 = 0 35 | let c2 = 0 36 | this.queue.push(Buffer.from("abc"), () => c1++) 37 | this.queue.push(Buffer.from("def"), () => c2++) 38 | 39 | this.queue.read(2) 40 | assert.equal(c1, 0) 41 | assert.equal(c2, 0) 42 | 43 | this.queue.read(2) 44 | assert.equal(c1, 1) 45 | assert.equal(c2, 0) 46 | 47 | this.queue.read(2) 48 | assert.equal(c1, 1) 49 | assert.equal(c2, 1) 50 | }) 51 | }) 52 | 53 | describe('isEmpty', function () { 54 | it('is initially empty', function () { 55 | assert(this.queue.isEmpty()) 56 | }) 57 | 58 | it('returns whether or not the queue is empty', function () { 59 | this.queue.push(Buffer.from("abc")) 60 | this.queue.read(2) 61 | assert(!this.queue.isEmpty()) 62 | this.queue.read(1) 63 | assert(this.queue.isEmpty()) 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /test/util/data-offset-sorter.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import { OffsetSorter } from '../../src/util/data-offset-sorter' 3 | 4 | describe('OffsetSorter', function () { 5 | beforeEach(function () { 6 | this.sorter = new OffsetSorter() 7 | }) 8 | 9 | describe('constructor', function () { 10 | it('begins empty', function () { 11 | assert.equal(this.sorter.readOffset, 0) 12 | assert.equal(this.sorter.maxOffset, 0) 13 | }) 14 | }) 15 | 16 | describe('read', function () { 17 | it('returns undefined when there is no data', function () { 18 | assert.equal(this.sorter.read(), undefined) 19 | assert.equal(this.sorter.readOffset, 0) 20 | }) 21 | 22 | it('returns undefined when there is no data at the current offset', function () { 23 | this.sorter.push(Buffer.from("foo"), 1) 24 | assert.equal(this.sorter.read(), undefined) 25 | assert.equal(this.sorter.readOffset, 0) 26 | assert.equal(this.sorter.maxOffset, 4) 27 | }) 28 | 29 | it('returns out-of-order data in order', function () { 30 | this.sorter.push(Buffer.from("BC"), 1) 31 | this.sorter.push(Buffer.from("A"), 0) 32 | assert.equal(this.sorter.maxOffset, 3) 33 | 34 | assert.deepEqual(this.sorter.read(), Buffer.from("A")) 35 | assert.equal(this.sorter.readOffset, 1) 36 | 37 | assert.deepEqual(this.sorter.read(), Buffer.from("BC")) 38 | assert.equal(this.sorter.readOffset, 3) 39 | 40 | assert.equal(this.sorter.read(), undefined) 41 | assert.equal(this.sorter.readOffset, 3) 42 | }) 43 | }) 44 | 45 | describe('byteLength', function () { 46 | it('returns zero when there is no data', function () { 47 | assert.equal(this.sorter.byteLength(), 0) 48 | }) 49 | 50 | it('returns zero when there is no data at the current offset', function () { 51 | this.sorter.push(Buffer.from("foo"), 1) 52 | assert.equal(this.sorter.byteLength(), 0) 53 | }) 54 | 55 | it('returns the length of the available data', function () { 56 | this.sorter.push(Buffer.from("foo"), 0) 57 | assert.equal(this.sorter.byteLength(), 3) 58 | 59 | this.sorter.push(Buffer.from("bar"), 3) 60 | assert.equal(this.sorter.byteLength(), 6) 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/util/data-queue.ts: -------------------------------------------------------------------------------- 1 | // Inspired by https://github.com/toajs/quic/blob/master/src/stream.ts 2 | 3 | class DataQueueEntry { 4 | data: Buffer 5 | next?: DataQueueEntry 6 | callback?: () => void 7 | 8 | constructor (buf: Buffer, callback?: () => void, entry?: DataQueueEntry) { 9 | this.data = buf 10 | this.callback = callback 11 | this.next = entry 12 | } 13 | } 14 | 15 | /** @private */ 16 | export class DataQueue { 17 | private head?: DataQueueEntry 18 | private tail?: DataQueueEntry 19 | private length: number 20 | 21 | constructor () { 22 | this.length = 0 23 | } 24 | 25 | push (buf: Buffer, callback?: () => void): void { 26 | const entry = new DataQueueEntry(buf, callback) 27 | 28 | if (this.tail) { 29 | this.tail.next = entry 30 | } else { 31 | this.head = entry 32 | } 33 | this.tail = entry 34 | this.length += 1 35 | } 36 | 37 | private shift () { 38 | if (!this.head) { 39 | return null 40 | } 41 | const ret = this.head.data 42 | if (this.length === 1) { 43 | this.head = this.tail = undefined 44 | } else { 45 | this.head = this.head.next 46 | } 47 | this.length -= 1 48 | return ret 49 | } 50 | 51 | read (n: number): Buffer | undefined { 52 | if (!this.head) { 53 | return undefined 54 | } 55 | 56 | let bytesLeft = n 57 | const chunks: Buffer[] = [] 58 | while (bytesLeft > 0 && this.length > 0) { 59 | let chunk = this.head.data 60 | if (chunk.length > bytesLeft) { 61 | this.head.data = chunk.slice(bytesLeft) 62 | chunk = chunk.slice(0, bytesLeft) 63 | chunks.push(chunk) 64 | bytesLeft -= chunk.length 65 | } else { 66 | chunks.push(chunk) // ret.length <= n 67 | bytesLeft -= chunk.length 68 | if (this.head && this.head.callback) { 69 | this.head.callback() 70 | } 71 | this.shift() 72 | } 73 | } 74 | 75 | return Buffer.concat(chunks) 76 | } 77 | 78 | isEmpty (): boolean { 79 | return this.length === 0 80 | } 81 | 82 | byteLength (): number { 83 | let length = 0 84 | let entry = this.head 85 | while (entry) { 86 | length += entry.data.length 87 | entry = entry.next 88 | } 89 | return length 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/util/receipt.ts: -------------------------------------------------------------------------------- 1 | import { Reader, Writer } from 'oer-utils' 2 | import { longFromValue, LongValue } from './long' 3 | import * as Long from 'long' 4 | import { generateReceiptHMAC } from '../crypto' 5 | 6 | export const RECEIPT_VERSION = 1 7 | 8 | export interface ReceiptOpts { 9 | nonce: Buffer 10 | streamId: LongValue 11 | totalReceived: LongValue 12 | secret: Buffer 13 | } 14 | 15 | export interface Receipt { 16 | version: number 17 | nonce: Buffer 18 | streamId: string 19 | totalReceived: Long 20 | } 21 | 22 | export interface ReceiptWithHMAC extends Receipt { 23 | hmac: Buffer 24 | } 25 | 26 | export function createReceipt (opts: ReceiptOpts): Buffer { 27 | if (opts.nonce.length !== 16) { 28 | throw new Error('receipt nonce must be 16 bytes') 29 | } 30 | if (opts.secret.length !== 32) { 31 | throw new Error('receipt secret must be 32 bytes') 32 | } 33 | const receipt = new Writer(58) 34 | receipt.writeUInt8(RECEIPT_VERSION) 35 | receipt.writeOctetString(opts.nonce, 16) 36 | receipt.writeUInt8(opts.streamId) 37 | receipt.writeUInt64(longFromValue(opts.totalReceived, true)) 38 | receipt.writeOctetString(generateReceiptHMAC(opts.secret, receipt.getBuffer()), 32) 39 | return receipt.getBuffer() 40 | } 41 | 42 | function decode (receipt: Buffer): ReceiptWithHMAC { 43 | if (receipt.length !== 58) { 44 | throw new Error('receipt malformed') 45 | } 46 | const reader = Reader.from(receipt) 47 | const version = reader.readUInt8Number() 48 | const nonce = reader.readOctetString(16) 49 | const streamId = reader.readUInt8() 50 | const totalReceived = reader.readUInt64Long() 51 | const hmac = reader.readOctetString(32) 52 | return { 53 | version, 54 | nonce, 55 | streamId, 56 | totalReceived, 57 | hmac 58 | } 59 | } 60 | 61 | export function decodeReceipt (receipt: Buffer): Receipt { 62 | return decode(receipt) 63 | } 64 | 65 | export function verifyReceipt (receipt: Buffer, secret: Buffer | ((decoded: ReceiptWithHMAC) => Buffer)): Receipt { 66 | const decoded = decode(receipt) 67 | if (decoded.version !== RECEIPT_VERSION) { 68 | throw new Error('invalid version') 69 | } 70 | if (typeof secret === 'function') { 71 | secret = secret(decoded) 72 | } 73 | const message = receipt.slice(0, 26) 74 | if (!decoded.hmac.equals(generateReceiptHMAC(secret, message))) { 75 | throw new Error('invalid hmac') 76 | } 77 | return decoded 78 | } 79 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | const IlpStream = require('.') 2 | const createPlugin = require('ilp-plugin') 3 | 4 | // Note this requires a local moneyd instance to work 5 | // See https://github.com/interledgerjs/moneyd for instructions 6 | 7 | async function run () { 8 | const serverPlugin = createPlugin() 9 | const server = await IlpStream.createServer({ 10 | plugin: serverPlugin 11 | }) 12 | 13 | server.on('connection', (connection) => { 14 | console.log('server got connection') 15 | 16 | connection.on('stream', (stream) => { 17 | console.log(`server got a new stream: ${stream.id}`) 18 | 19 | // Set the maximum amount of money this stream can receive 20 | stream.setReceiveMax(10000) 21 | 22 | // Handle incoming money 23 | stream.on('money', (amount) => { 24 | console.log(`server stream ${stream.id} got incoming payment for: ${amount}`) 25 | }) 26 | 27 | // Handle incoming data 28 | stream.on('data', (chunk) => { 29 | console.log(`server stream ${stream.id} got data: ${chunk.toString('utf8')}`) 30 | }) 31 | }) 32 | }) 33 | 34 | // These would need to be passed from the server to the client using 35 | // some encrypted communication channel (not provided by STREAM) 36 | const { destinationAccount, sharedSecret } = server.generateAddressAndSecret() 37 | console.log(`server generated ILP address (${destinationAccount}) and shared secret (${sharedSecret.toString('hex')}) for client`) 38 | 39 | const clientPlugin = createPlugin() 40 | const clientConn = await IlpStream.createConnection({ 41 | plugin: clientPlugin, 42 | destinationAccount, 43 | sharedSecret 44 | }) 45 | 46 | // Streams are automatically given ids (client-initiated ones are odd, server-initiated are even) 47 | const streamA = clientConn.createStream() 48 | const streamB = clientConn.createStream() 49 | 50 | console.log(`sending data to server on stream ${streamA.id}`) 51 | streamA.write('hello there!') 52 | 53 | console.log(`sending data to server on stream ${streamB.id}`) 54 | streamB.write('hello there!') 55 | 56 | console.log(`sending money to server on stream ${streamA.id}`) 57 | await streamA.sendTotal(100) 58 | console.log('sent 100 units') 59 | 60 | console.log(`sending money to server on stream ${streamB.id}`) 61 | await streamB.sendTotal(200) 62 | console.log('sent 200 units') 63 | 64 | await clientConn.end() 65 | await server.close() 66 | } 67 | 68 | run().catch((err) => console.log(err)) 69 | -------------------------------------------------------------------------------- /src/util/crypto-node.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto' 2 | import * as assert from 'assert' 3 | 4 | const HASH_ALGORITHM = 'sha256' 5 | const ENCRYPTION_ALGORITHM = 'aes-256-gcm' 6 | const IV_LENGTH = 12 7 | const AUTH_TAG_LENGTH = 16 8 | const SHARED_SECRET_GENERATION_STRING = Buffer.from('ilp_stream_shared_secret', 'utf8') 9 | 10 | export const randomBytes = crypto.randomBytes 11 | 12 | export async function hash (preimage: Buffer): Promise { 13 | const h = crypto.createHash(HASH_ALGORITHM) 14 | h.update(preimage) 15 | return Promise.resolve(h.digest()) 16 | } 17 | 18 | export async function encrypt (pskEncryptionKey: Buffer, ...buffers: Buffer[]): Promise { 19 | return Promise.resolve(encryptSync(pskEncryptionKey, ...buffers)) 20 | } 21 | 22 | function encryptSync (pskEncryptionKey: Buffer, ...buffers: Buffer[]): Buffer { 23 | const iv = crypto.randomBytes(IV_LENGTH) 24 | const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, pskEncryptionKey, iv) 25 | 26 | const ciphertext = [] 27 | for (let buffer of buffers) { 28 | ciphertext.push(cipher.update(buffer)) 29 | } 30 | ciphertext.push(cipher.final()) 31 | const tag = cipher.getAuthTag() 32 | ciphertext.unshift(iv, tag) 33 | return Buffer.concat(ciphertext) 34 | } 35 | 36 | export async function decrypt (pskEncryptionKey: Buffer, data: Buffer): Promise { 37 | return Promise.resolve(decryptSync(pskEncryptionKey, data)) 38 | } 39 | 40 | function decryptSync (pskEncryptionKey: Buffer, data: Buffer): Buffer { 41 | assert(data.length > 0, 'cannot decrypt empty buffer') 42 | const nonce = data.slice(0, IV_LENGTH) 43 | const tag = data.slice(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH) 44 | const encrypted = data.slice(IV_LENGTH + AUTH_TAG_LENGTH) 45 | const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, pskEncryptionKey, nonce) 46 | decipher.setAuthTag(tag) 47 | 48 | return Buffer.concat([ 49 | decipher.update(encrypted), 50 | decipher.final() 51 | ]) 52 | } 53 | 54 | export async function hmac (key: Buffer, message: Buffer): Promise { 55 | return Promise.resolve(hmacSync(key, message)) 56 | } 57 | 58 | function hmacSync (key: Buffer, message: Buffer): Buffer { 59 | const h = crypto.createHmac(HASH_ALGORITHM, key) 60 | h.update(message) 61 | return h.digest() 62 | } 63 | 64 | export function generateSharedSecretFromToken (seed: Buffer, token: Buffer): Buffer { 65 | const keygen = hmacSync(seed, SHARED_SECRET_GENERATION_STRING) 66 | const sharedSecret = hmacSync(keygen, token) 67 | return sharedSecret 68 | } 69 | 70 | export function generateReceiptHMAC (secret: Buffer, message: Buffer): Buffer { 71 | return hmacSync(secret, message) 72 | } 73 | 74 | export function encryptConnectionAddressToken (seed: Buffer, token: Buffer): Buffer { 75 | return encryptSync(seed, token) 76 | } 77 | 78 | export function decryptConnectionAddressToken (seed: Buffer, token: Buffer): Buffer { 79 | return decryptSync(seed, token) 80 | } 81 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as ILDCP from 'ilp-protocol-ildcp' 2 | import * as IlpPacket from 'ilp-packet' 3 | import createLogger from 'ilp-logger' 4 | import './util/formatters' 5 | import { Connection, ConnectionOpts } from './connection' 6 | 7 | export { Connection } from './connection' 8 | export { DataAndMoneyStream } from './stream' 9 | export { Server, ServerOpts, createServer, GenerateAddressSecretOpts } from './server' 10 | export { 11 | createReceipt, 12 | decodeReceipt, 13 | verifyReceipt, 14 | Receipt, 15 | ReceiptOpts, 16 | ReceiptWithHMAC, 17 | RECEIPT_VERSION 18 | } from './util/receipt' 19 | 20 | export interface CreateConnectionOpts extends ConnectionOpts { 21 | /** ILP Address of the server */ 22 | destinationAccount: string, 23 | /** Shared secret generated by the server */ 24 | sharedSecret: Buffer 25 | } 26 | 27 | /** 28 | * Create a [`Connection`]{@link Connection} to a [`Server`]{@link Server} using the `destinationAccount` and `sharedSecret` provided. 29 | */ 30 | export async function createConnection (opts: CreateConnectionOpts): Promise { 31 | const plugin = opts.plugin 32 | await plugin.connect() 33 | const log = createLogger('ilp-protocol-stream:Client') 34 | const { clientAddress, assetCode, assetScale } = 35 | await ILDCP.fetch(plugin.sendData.bind(plugin), { 36 | expiresAt: opts.getExpiry && opts.getExpiry('peer.config') 37 | }) 38 | const connection = await Connection.build({ 39 | ...opts, 40 | sourceAccount: clientAddress, 41 | assetCode, 42 | assetScale, 43 | isServer: false, 44 | plugin 45 | }) 46 | plugin.registerDataHandler(async (data: Buffer): Promise => { 47 | let prepare: IlpPacket.IlpPrepare 48 | try { 49 | prepare = IlpPacket.deserializeIlpPrepare(data) 50 | } catch (err) { 51 | log.error('got data that is not an ILP Prepare packet: %h', data) 52 | return IlpPacket.serializeIlpReject({ 53 | code: 'F00', 54 | message: `Expected an ILP Prepare packet (type 12), but got packet with type: ${data[0]}`, 55 | data: Buffer.alloc(0), 56 | triggeredBy: clientAddress 57 | }) 58 | } 59 | 60 | try { 61 | const fulfill = await connection.handlePrepare(prepare) 62 | return IlpPacket.serializeIlpFulfill(fulfill) 63 | } catch (err) { 64 | if (!err.ilpErrorCode) { 65 | log.error('error handling prepare:', err) 66 | } 67 | // TODO should the default be F00 or T00? 68 | return IlpPacket.serializeIlpReject({ 69 | code: err.ilpErrorCode || 'F00', 70 | message: err.ilpErrorMessage || '', 71 | data: err.ilpErrorData || Buffer.alloc(0), 72 | triggeredBy: clientAddress 73 | }) 74 | } 75 | }) 76 | connection.once('close', () => { 77 | plugin.deregisterDataHandler() 78 | plugin.disconnect() 79 | .then(() => log.info('plugin disconnected')) 80 | .catch((err: Error) => log.error('error disconnecting plugin:', err)) 81 | }) 82 | await connection.connect() 83 | // TODO resolve only when it is connected 84 | return connection 85 | } 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ilp-protocol-stream", 3 | "version": "2.7.1", 4 | "description": "Interledger Transport Protocol for sending multiple streams of money and data over ILP.", 5 | "main": "dist/src/index.js", 6 | "browser": { 7 | "./dist/src/util/crypto-node.js": "./dist/src/util/crypto-browser.js", 8 | "./src/util/crypto-node.ts": "./src/util/crypto-browser.ts", 9 | "./dist/src/pool.js": false, 10 | "./src/pool.ts": false, 11 | "./dist/src/server.js": false, 12 | "./src/server.ts": false 13 | }, 14 | "types": "dist/src/index.d.ts", 15 | "files": [ 16 | "dist/src/**/*.js", 17 | "dist/src/**/*.js.map", 18 | "dist/src/**/*.d.ts" 19 | ], 20 | "scripts": { 21 | "build": "tsc", 22 | "prepare": "npm run build", 23 | "lint": "tslint --project .", 24 | "test": "TS_NODE_PROJECT='./tsconfig.test.json' mocha", 25 | "test-cover": "TS_NODE_PROJECT='./tsconfig.test.json' nyc --extension .ts mocha", 26 | "doc": "typedoc --options typedoc.js src/index.ts src/connection.ts src/stream.ts --theme node_modules/typedoc-neo-theme/bin/default", 27 | "publish-docs": "npm run doc && node scripts/publish-docs.js", 28 | "codecov": "codecov" 29 | }, 30 | "keywords": [ 31 | "interledger", 32 | "ilp", 33 | "streaming", 34 | "payments", 35 | "micropayments", 36 | "chunked" 37 | ], 38 | "author": "Evan Schwartz ", 39 | "license": "Apache-2.0", 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/interledgerjs/ilp-protocol-stream.git" 43 | }, 44 | "dependencies": { 45 | "@types/node": "^10.14.22", 46 | "ilp-logger": "^1.3.1", 47 | "ilp-packet": "^3.0.9", 48 | "ilp-protocol-ildcp": "^2.1.4", 49 | "long": "^4.0.0", 50 | "oer-utils": "^5.0.1", 51 | "uuid": "^3.4.0" 52 | }, 53 | "devDependencies": { 54 | "@types/chai": "^4.1.3", 55 | "@types/chai-as-promised": "^7.1.0", 56 | "@types/long": "^4.0.0", 57 | "@types/mocha": "^7.0.2", 58 | "@types/puppeteer": "^1.19.1", 59 | "@types/sinon": "^5.0.1", 60 | "@types/uuid": "^3.4.6", 61 | "@types/webpack": "^4.41.12", 62 | "benchmark": "^2.1.4", 63 | "bignumber.js": "^7.2.1", 64 | "chai": "^4.1.2", 65 | "chai-as-promised": "^7.1.1", 66 | "codecov": "^3.0.2", 67 | "gh-pages": "^2.0.0", 68 | "ilp-plugin": "^3.2.1", 69 | "ilp-plugin-btp": "^1.4.1", 70 | "ilp-plugin-mini-accounts": "^4.2.0", 71 | "mocha": "^7.1.2", 72 | "nyc": "^15.0.0", 73 | "puppeteer": "^1.19.0", 74 | "sinon": "^6.0.1", 75 | "source-map-support": "^0.5.6", 76 | "ts-loader": "^6.1.0", 77 | "ts-node": "^7.0.1", 78 | "tslint": "^5.10.0", 79 | "tslint-config-standard": "^8.0.0", 80 | "typedoc": "^0.15.0", 81 | "typedoc-neo-theme": "^1.0.7", 82 | "typescript": "^3.6.0", 83 | "webpack": "^4.43.0", 84 | "webpack-cli": "^3.3.7" 85 | }, 86 | "nyc": { 87 | "check-coverage": true, 88 | "lines": 80, 89 | "statements": 80, 90 | "functions": 80, 91 | "branches": 80, 92 | "reporter": [ 93 | "lcov", 94 | "text-summary" 95 | ] 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /test/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import * as assert from 'assert' 3 | import * as helpers from '../src/crypto' 4 | 5 | if (typeof describe === 'function') { 6 | describe('crypto helpers (node)', function () { 7 | runCryptoTests({ describe, it }) 8 | }) 9 | } 10 | 11 | export function runCryptoTests (args: {describe: Mocha.SuiteFunction, it: Mocha.TestFunction}) { 12 | const { describe, it } = args 13 | 14 | describe('generateTokenNonce', function () { 15 | it('generates a random 18-byte token nonce', function () { 16 | assert.equal(helpers.generateTokenNonce().length, 18) 17 | }) 18 | }) 19 | 20 | describe('generateRandomCondition', function () { 21 | it('generates a random 32-byte condition', function () { 22 | assert.equal(helpers.generateRandomCondition().length, 32) 23 | }) 24 | }) 25 | 26 | describe('generatePskEncryptionKey', function () { 27 | it('generates the expected key', async function () { 28 | const secret = Buffer.from('foo') 29 | const gotKey = await helpers.generatePskEncryptionKey(secret) 30 | const wantKey = Buffer.from('zfOGMU/uY+EOUCE6tN3WhE77bF/N6pS0wSOmMgEAMEA=', 'base64') 31 | assert.deepEqual(gotKey, wantKey) 32 | }) 33 | }) 34 | 35 | describe('generateFulfillmentKey', function () { 36 | it('generates the expected key', async function () { 37 | const secret = Buffer.from('foo') 38 | const gotKey = await helpers.generateFulfillmentKey(secret) 39 | const wantKey = Buffer.from('lh8nGJCJosvfUePaU0uxxXK3jNvV2Y+5ivt1GH1Muhs=', 'base64') 40 | assert.deepEqual(gotKey, wantKey) 41 | }) 42 | }) 43 | 44 | describe('generateFulfillment', function () { 45 | it('generates the expected fulfillment', async function () { 46 | const key = Buffer.alloc(32) 47 | const gotFulfillment = await helpers.generateFulfillment(key, Buffer.from('foo')) 48 | const wantFulfillment = Buffer.from('DA2Y9+PZ1F5y6Id7wbEEMn77nAexjy/+ztdtgTB/H/8=', 'base64') 49 | assert.deepEqual(gotFulfillment, wantFulfillment) 50 | }) 51 | }) 52 | 53 | describe('hash', function () { 54 | it('generates the expected condition', async function () { 55 | const fulfillment = Buffer.alloc(32) 56 | const wantCondition = Buffer.from('Zmh6rfhivXdsj8GLjp+OIAiXFIVu4jOzkCpZHQ1fKSU=', 'base64') 57 | const gotCondition = await helpers.hash(fulfillment) 58 | assert.deepEqual(gotCondition, wantCondition) 59 | }) 60 | }) 61 | 62 | describe('decrypt', function () { 63 | it('decrypts encrypted data', async function () { 64 | const cleartext = Buffer.from('foo bar') 65 | const key = await helpers.generatePskEncryptionKey(Buffer.from('secret')) 66 | const ciphertext = await helpers.encrypt(key, cleartext) 67 | assert.deepEqual(await helpers.decrypt(key, ciphertext), cleartext) 68 | }) 69 | 70 | it('decrypts known data', async function () { 71 | const cleartext = Buffer.from('foo bar') 72 | const key = Buffer.from('AOStyoBvoK9/OFhdmf2TzQRrJsCkxH/cj49Ya7RFOEc=', 'base64') 73 | const ciphertext = Buffer.from('Y1UiXpDA1GwAv+h95CEv67O49MOAJQrnYEQMrOFsbv6rrlE=', 'base64') 74 | assert.deepEqual(await helpers.decrypt(key, ciphertext), cleartext) 75 | }) 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build_node_v10: 4 | docker: 5 | - image: circleci/node:10-browsers 6 | working_directory: ~/repo 7 | steps: 8 | - checkout 9 | # Download and cache dependencies 10 | - restore_cache: 11 | keys: 12 | - v10-dependencies-{{ checksum "package.json" }} 13 | # fallback to using the latest cache if no exact match is found 14 | - v10-dependencies- 15 | - run: npm install 16 | - save_cache: 17 | paths: 18 | - node_modules 19 | key: v10-dependencies-{{ checksum "package.json" }} 20 | - run: npm run test-cover 21 | - run: npm run lint 22 | - run: npm run codecov 23 | 24 | build_node_v12: 25 | docker: 26 | - image: circleci/node:12-browsers 27 | working_directory: ~/repo_v12 28 | steps: 29 | - checkout 30 | # Download and cache dependencies 31 | - restore_cache: 32 | keys: 33 | - v12-dependencies-{{ checksum "package.json" }} 34 | # fallback to using the latest cache if no exact match is found 35 | - v12-dependencies- 36 | - run: npm install 37 | - save_cache: 38 | paths: 39 | - node_modules 40 | key: v12-dependencies-{{ checksum "package.json" }} 41 | - run: npm run test-cover 42 | 43 | publish_docs: 44 | docker: 45 | - image: circleci/node:10 46 | working_directory: ~/repo 47 | steps: 48 | - checkout 49 | # Download and cache dependencies 50 | - restore_cache: 51 | keys: 52 | - v10-dependencies-{{ checksum "package.json" }} 53 | # fallback to using the latest cache if no exact match is found 54 | - v10-dependencies- 55 | - add_ssh_keys 56 | - run: npm run publish-docs 57 | 58 | publish_to_npm: 59 | docker: 60 | - image: circleci/node:10 61 | working_directory: ~/repo 62 | steps: 63 | - checkout 64 | # Download and cache dependencies 65 | - restore_cache: 66 | keys: 67 | - v10-dependencies-{{ checksum "package.json" }} 68 | # fallback to using the latest cache if no exact match is found 69 | - v10-dependencies- 70 | - run: mv npmrc-env .npmrc 71 | - run: 72 | name: Publish to NPM (only if a new version has been tagged) 73 | command: if [ "$(npm show ilp-protocol-stream version)" != "$(npm ls --depth=-1 2>/dev/null | head -1 | cut -f 1 -d " " | cut -f 2 -d @)" ] ; then npm publish ; fi 74 | 75 | workflows: 76 | version: 2 77 | build_and_publish: 78 | jobs: 79 | - build_node_v10: 80 | filters: 81 | branches: 82 | ignore: 83 | - gh-pages 84 | - build_node_v12: 85 | filters: 86 | branches: 87 | ignore: 88 | - gh-pages 89 | - publish_docs: 90 | requires: 91 | - build_node_v10 92 | - build_node_v12 93 | filters: 94 | branches: 95 | only: 96 | - master 97 | - publish_to_npm: 98 | requires: 99 | - build_node_v10 100 | - build_node_v12 101 | filters: 102 | branches: 103 | only: 104 | - master 105 | -------------------------------------------------------------------------------- /test/util/receipt.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import { 3 | createReceipt, 4 | decodeReceipt, 5 | verifyReceipt, 6 | RECEIPT_VERSION 7 | } from '../../src/util/receipt' 8 | import * as sinon from 'sinon' 9 | import * as Chai from 'chai' 10 | import { Writer } from 'oer-utils' 11 | import * as chaiAsPromised from 'chai-as-promised' 12 | import * as Long from 'long' 13 | import { longFromValue } from '../../src/util/long' 14 | import { randomBytes } from '../../src/crypto' 15 | Chai.use(chaiAsPromised) 16 | const assert = Object.assign(Chai.assert, sinon.assert) 17 | 18 | describe('Receipt', function () { 19 | const receiptFixture = require('../fixtures/packets.json').find(({ name }: { name: string}) => name === 'frame:stream_receipt' ).packet.frames[0].receipt 20 | 21 | describe('createReceipt', function () { 22 | it('should create a receipt', function () { 23 | const receipt = createReceipt({ 24 | nonce: Buffer.alloc(16), 25 | streamId: '1', 26 | totalReceived: '500', 27 | secret: Buffer.alloc(32) 28 | }) 29 | assert(receipt.equals(receiptFixture)) 30 | }) 31 | it('should require 16 byte nonce', function () { 32 | assert.throws(() => createReceipt({ 33 | nonce: Buffer.alloc(8), 34 | streamId: 'id', 35 | totalReceived: '1', 36 | secret: Buffer.alloc(32) 37 | }), 'receipt nonce must be 16 bytes') 38 | }) 39 | it('should require 32 byte secret', function () { 40 | assert.throws(() => createReceipt({ 41 | nonce: Buffer.alloc(16), 42 | streamId: 'id', 43 | totalReceived: '1', 44 | secret: Buffer.alloc(31) 45 | }), 'receipt secret must be 32 bytes') 46 | }) 47 | }) 48 | 49 | describe('decodeReceipt', function () { 50 | it('should decode receipt', function () { 51 | const receipt = decodeReceipt(receiptFixture) 52 | assert.strictEqual(receipt.version, RECEIPT_VERSION) 53 | assert(receipt.nonce.equals(Buffer.alloc(16))) 54 | assert.strictEqual(receipt.streamId, '1') 55 | assert(receipt.totalReceived.equals(500)) 56 | }) 57 | it('should require 58 byte receipt', function () { 58 | assert.throws(() => decodeReceipt(Buffer.alloc(32)), 'receipt malformed') 59 | }) 60 | }) 61 | 62 | describe('verifyReceipt', function () { 63 | it('should be able to take a function as secret ', function () { 64 | verifyReceipt(receiptFixture, (decoded) => { 65 | // we may want to compute the secret based on the decoded nonce 66 | assert.isDefined(decoded.nonce) 67 | return Buffer.alloc(32) 68 | }) 69 | }) 70 | it('should return true for valid receipt', function () { 71 | const secret = Buffer.alloc(32) 72 | const receipt = verifyReceipt(receiptFixture, secret) 73 | assert.strictEqual(receipt.version, RECEIPT_VERSION) 74 | assert(receipt.nonce.equals(Buffer.alloc(16))) 75 | assert.strictEqual(receipt.streamId, '1') 76 | assert(receipt.totalReceived.equals(500)) 77 | }) 78 | it('should throw for invalid receipt length', function () { 79 | const secret = Buffer.alloc(32) 80 | assert.throws(() => verifyReceipt(Buffer.alloc(57), secret), 'receipt malformed') 81 | }) 82 | it('should throw for invalid receipt version', function () { 83 | const secret = Buffer.alloc(32) 84 | assert.throws(() => verifyReceipt(Buffer.alloc(58), secret), 'invalid version') 85 | }) 86 | it('should throw for invalid receipt hmac', function () { 87 | const secret = randomBytes(32) 88 | assert.throws(() => verifyReceipt(receiptFixture, secret), 'invalid hmac') 89 | }) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /scripts/example-web.js: -------------------------------------------------------------------------------- 1 | // This script runs a HTTP & stream server (over mini-accounts and btp) and serves 2 | // a page that connects to the server via stream and constantly sends money. 3 | 4 | 'use strict' 5 | 6 | const fs = require('fs') 7 | const http = require('http') 8 | const path = require('path') 9 | const PluginMiniAccounts = require('ilp-plugin-mini-accounts') 10 | const { createServer, Connection } = require('../') 11 | 12 | const STREAM_PORT = 9001 13 | const HTTP_PORT = 9002 14 | const BUNDLE_FILE = path.resolve(__dirname, '../dist/test/browser/bundle.js') 15 | 16 | runStreamServer() 17 | .then((info) => { 18 | const httpServer = http.createServer(makeRequestHandler({ 19 | streamPort: STREAM_PORT, 20 | destinationAccount: info.destinationAccount, 21 | sharedSecret: info.sharedSecret.toString('base64') 22 | })) 23 | httpServer.listen(HTTP_PORT, '127.0.0.1') 24 | console.log(`http://127.0.0.1:${HTTP_PORT}`) 25 | }) 26 | .catch((err) => { 27 | console.error(err.stack) 28 | process.exit(1) 29 | }) 30 | 31 | async function runStreamServer () { 32 | const serverPlugin = new PluginMiniAccounts({ 33 | port: STREAM_PORT, 34 | allowedOrigins: ['.*'], 35 | debugHostIldcpInfo: { 36 | clientAddress: 'test.example', 37 | assetScale: 9, 38 | assetCode: '___' 39 | } 40 | }) 41 | const server = await createServer({ plugin: serverPlugin }) 42 | server.on('connection', (connection) => { 43 | console.log('new connection') 44 | connection.on('stream', (stream) => { 45 | console.log('new stream') 46 | stream.setReceiveMax(10000) 47 | stream.on('money', (amount) => { process.stdout.write(amount + ',') }) 48 | }) 49 | }) 50 | return server.generateAddressAndSecret() 51 | } 52 | 53 | function makeRequestHandler (streamInfo) { 54 | return function (req, res) { 55 | switch (req.url) { 56 | case '/': 57 | res.setHeader('Content-Type', 'text/html') 58 | res.write(makeHTML(streamInfo)) 59 | break 60 | case '/bundle.js': 61 | res.setHeader('Content-Type', 'text/javascript') 62 | res.write(fs.readFileSync(BUNDLE_FILE)) 63 | break 64 | default: 65 | res.statusCode = 404 66 | } 67 | res.end() 68 | } 69 | } 70 | 71 | function makeHTML (info) { 72 | return ` 73 | 74 | 75 | 76 | ilp-protocol-stream test page 77 | 78 | 83 | 84 | ilp-protocol-stream test page 85 | ` 86 | } 87 | 88 | async function clientCode (info) { 89 | const BATCH_SIZE = 1000 90 | let totalAmount = 0 91 | 92 | const client = await window.makeStreamClient({ 93 | server: `btp+ws://127.0.0.1:${info.streamPort}`, 94 | btpToken: 'secret' 95 | }, info) 96 | const stream = await client.createStream() 97 | 98 | sendBatch() 99 | 100 | function sendBatch () { 101 | _sendBatch() 102 | .then((elapsed) => { 103 | console.log('elapsed:', elapsed, 'ms') 104 | setTimeout(sendBatch, 100) 105 | }) 106 | .catch((err) => console.error('sendTotal error:', err.stack)) 107 | } 108 | 109 | async function _sendBatch () { 110 | const start = performance.now() 111 | for (let i = 0; i < BATCH_SIZE; i++) { 112 | await stream.sendTotal(++totalAmount) 113 | } 114 | return (performance.now() - start) / BATCH_SIZE 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /test/mocks/plugin.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import * as IlpPacket from 'ilp-packet' 3 | import * as Long from 'long' 4 | import * as ILDCP from 'ilp-protocol-ildcp' 5 | import { Writer } from 'oer-utils' 6 | import Rational from '../../src/util/rational' 7 | 8 | export interface DataHandler { 9 | (data: Buffer): Promise 10 | } 11 | export interface MoneyHandler { 12 | (amount: string): Promise 13 | } 14 | 15 | export default class MockPlugin extends EventEmitter { 16 | static readonly version = 2 17 | public dataHandler: DataHandler 18 | public moneyHandler: MoneyHandler 19 | public exchangeRate: number 20 | public connected: boolean 21 | public mirror: MockPlugin 22 | protected identity: string 23 | protected assetCode: string 24 | public maxAmount?: number | Long 25 | 26 | constructor (exchangeRate: number, mirror?: MockPlugin) { 27 | super() 28 | 29 | this.dataHandler = this.defaultDataHandler 30 | this.moneyHandler = this.defaultMoneyHandler 31 | this.exchangeRate = exchangeRate 32 | this.mirror = mirror || new MockPlugin(1 / exchangeRate, this) 33 | this.identity = (mirror ? 'peerB' : 'peerA') 34 | this.assetCode = (mirror ? 'XYZ' : 'ABC') 35 | this.maxAmount = 1000 36 | } 37 | 38 | async connect () { 39 | this.connected = true 40 | return Promise.resolve() 41 | } 42 | 43 | async disconnect () { 44 | this.emit('disconnect') 45 | this.connected = false 46 | return Promise.resolve() 47 | } 48 | 49 | isConnected () { 50 | return this.connected 51 | } 52 | 53 | async sendData (data: Buffer): Promise { 54 | if (data[0] === IlpPacket.Type.TYPE_ILP_PREPARE) { 55 | const exchangeRate = Rational.fromNumber(this.exchangeRate, true) 56 | const parsed = IlpPacket.deserializeIlpPrepare(data) 57 | if (parsed.destination === 'peer.config') { 58 | return ILDCP.serializeIldcpResponse({ 59 | clientAddress: 'test.' + this.identity, 60 | assetScale: 9, 61 | assetCode: this.assetCode 62 | }) 63 | } 64 | const amount = Long.fromString(parsed.amount, true) 65 | if (this.maxAmount !== undefined && amount.greaterThan(this.maxAmount)) { 66 | const writer = new Writer() 67 | writer.writeUInt64(amount) 68 | writer.writeUInt64(this.maxAmount) 69 | return IlpPacket.serializeIlpReject({ 70 | code: 'F08', 71 | message: 'Packet amount too large', 72 | triggeredBy: 'test.connector', 73 | data: writer.getBuffer() 74 | }) 75 | } 76 | const newPacket = IlpPacket.serializeIlpPrepare({ 77 | ...parsed, 78 | amount: exchangeRate.multiplyByLong(amount).toString(10) 79 | }) 80 | return this.mirror.dataHandler(newPacket) 81 | } else { 82 | return this.mirror.dataHandler(data) 83 | } 84 | } 85 | 86 | async sendMoney (amount: string): Promise { 87 | return this.mirror.moneyHandler(amount) 88 | } 89 | 90 | registerDataHandler (handler: DataHandler): void { 91 | this.dataHandler = async (data: Buffer) => { 92 | const reply = await handler(data) 93 | if (this.connected) { 94 | return reply 95 | } else { 96 | // Emulate a disconnected plugin by failing to return the data after it's disconnected 97 | throw new Error('Not connected') 98 | } 99 | } 100 | } 101 | 102 | deregisterDataHandler (): void { 103 | this.dataHandler = this.defaultDataHandler 104 | } 105 | 106 | registerMoneyHandler (handler: MoneyHandler): void { 107 | this.moneyHandler = handler 108 | } 109 | 110 | deregisterMoneyHandler (): void { 111 | this.moneyHandler = this.defaultMoneyHandler 112 | } 113 | 114 | async defaultDataHandler (data: Buffer): Promise { 115 | return IlpPacket.serializeIlpReject({ 116 | code: 'F02', // Unreachable 117 | triggeredBy: 'example.mock-plugin', 118 | message: 'No data handler registered', 119 | data: Buffer.alloc(0) 120 | }) 121 | } 122 | 123 | async defaultMoneyHandler (amount: string): Promise { 124 | return 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/util/long.ts: -------------------------------------------------------------------------------- 1 | import * as Long from 'long' 2 | 3 | export type LongValue = Long | string | number 4 | 5 | export function longFromValue (value: LongValue, unsigned: boolean): Long { 6 | if (typeof value === 'number') { 7 | if (unsigned && value < 0) { 8 | throw new Error('Expected positive number') 9 | } 10 | return Long.fromNumber(value, unsigned) 11 | } 12 | 13 | if (typeof value === 'string') { 14 | if (unsigned && value[0] === '-') { 15 | throw new Error('Expected positive number') 16 | } 17 | const longValue = Long.fromString(value, unsigned) 18 | if (longValue.toString() !== value) { 19 | // Throw when `Long.fromString` wraps a too-large number. 20 | throw new Error('Value ' + value + ' does not fit in a Long.') 21 | } 22 | return longValue 23 | } 24 | 25 | if (value.unsigned !== unsigned) { 26 | throw new Error('Expected ' + (unsigned ? 'unsigned' : 'signed') + ' Long') 27 | } 28 | 29 | return value 30 | } 31 | 32 | export function maxLong (a: Long, b: Long): Long { 33 | return a.greaterThan(b) ? a : b 34 | } 35 | 36 | export function minLong (a: Long, b: Long): Long { 37 | return a.lessThan(b) ? a : b 38 | } 39 | 40 | export function minLongs (values: Long[]): Long { 41 | let min = values[0] 42 | for (let i = 1; i < values.length; i++) { 43 | min = minLong(min, values[i]) 44 | } 45 | return min 46 | } 47 | 48 | export function countDigits (value: Long): number { 49 | let digits = 0 50 | while (!value.isZero()) { 51 | digits++ 52 | value = value.divide(10) 53 | } 54 | return digits 55 | } 56 | 57 | export function checkedAdd (a: Long, b: Long): { 58 | sum: Long, 59 | overflow: boolean 60 | } { 61 | const sum = a.add(b) 62 | const overflow = sum.lessThan(a) || sum.lessThan(b) 63 | return { 64 | sum: overflow ? Long.MAX_UNSIGNED_VALUE : sum, 65 | overflow 66 | } 67 | } 68 | 69 | export function checkedSubtract (a: Long, b: Long): { 70 | difference: Long, 71 | underflow: boolean 72 | } { 73 | const difference = a.subtract(b) 74 | const underflow = difference.greaterThan(a) && difference.greaterThan(b) 75 | return { 76 | difference: underflow ? Long.UZERO : difference, 77 | underflow 78 | } 79 | } 80 | 81 | export function checkedMultiply (a: Long, b: Long): { 82 | product: Long, 83 | overflow: boolean 84 | } { 85 | const product = a.multiply(b) 86 | const overflow = product.lessThan(a) || product.lessThan(b) 87 | return { 88 | product: overflow ? Long.MAX_UNSIGNED_VALUE : product, 89 | overflow 90 | } 91 | } 92 | 93 | /** 94 | * Algorithm from https://en.wikipedia.org/wiki/Ancient_Egyptian_multiplication 95 | * 96 | * returns a * b / c, floored 97 | */ 98 | export function multiplyDivideFloor (a: Long, b: Long, c: Long): Long { 99 | return multiplyDivide(a, b, c).quo 100 | } 101 | 102 | export function multiplyDivideCeil (a: Long, b: Long, c: Long): Long { 103 | const { quo, rem } = multiplyDivide(a, b, c) 104 | // Never wrap to 0. 105 | if (quo.equals(Long.MAX_UNSIGNED_VALUE)) return quo 106 | return quo.add(rem.isZero() ? 0 : 1) 107 | } 108 | 109 | export function multiplyDivideRound (a: Long, b: Long, c: Long): Long { 110 | const { quo, rem } = multiplyDivide(a, b, c) 111 | // Never wrap to 0. 112 | if (quo.equals(Long.MAX_UNSIGNED_VALUE)) return quo 113 | const roundUp = !rem.isZero() && ( 114 | c.isOdd() 115 | ? rem.greaterThan(c.divide(2)) // 5/2 ≅ 2 116 | : rem.greaterThanOrEqual(c.divide(2)) // 4/2 = 2 117 | ) 118 | return roundUp ? quo.add(Long.UONE) : quo 119 | } 120 | 121 | export function multiplyDivide (a: Long, b: Long, c: Long): { 122 | quo: Long, 123 | rem: Long 124 | } { 125 | let quo = Long.UZERO // quotient 126 | let rem = Long.UZERO // remainder 127 | let qn = b.divide(c) 128 | let rn = b.modulo(c) 129 | 130 | while (!a.isZero()) { 131 | let oldQuo = quo 132 | if (!a.and(Long.UONE).isZero()) { 133 | quo = quo.add(qn) 134 | rem = rem.add(rn) 135 | if (rem.greaterThanOrEqual(c)) { 136 | quo = quo.add(Long.UONE) 137 | rem = rem.subtract(c) 138 | } 139 | } 140 | 141 | // Overflow. 142 | if (quo.lessThan(oldQuo)) { 143 | return { quo: Long.MAX_UNSIGNED_VALUE, rem: Long.UZERO } 144 | } 145 | 146 | a = a.shiftRightUnsigned(1) 147 | qn = qn.shiftLeft(1) 148 | rn = rn.shiftLeft(1) 149 | if (rn.greaterThanOrEqual(c)) { 150 | qn = qn.add(Long.UONE) 151 | rn = rn.subtract(c) 152 | } 153 | } 154 | return { quo, rem } 155 | } 156 | 157 | Long.prototype['toJSON'] = function () { 158 | return this.toString() 159 | } 160 | -------------------------------------------------------------------------------- /test/browser.test.ts: -------------------------------------------------------------------------------- 1 | // This file uses Puppeteer+Webpack to build & test the browser version of 2 | // ilp-protocol-stream. The main point is to exercise `src/util/crypto-browser`, 3 | // since every thing else is the same as in Node. 4 | 5 | import * as assert from 'assert' 6 | import * as path from 'path' 7 | import * as puppeteer from 'puppeteer' 8 | import * as webpack from 'webpack' 9 | import PluginMiniAccounts from 'ilp-plugin-mini-accounts' 10 | import { createServer, Connection } from '../src' 11 | 12 | const BTP_SERVER_OPTS = { 13 | port: 9000, 14 | allowedOrigins: ['.*'], 15 | debugHostIldcpInfo: { 16 | clientAddress: 'test.example', 17 | assetScale: 9, 18 | assetCode: '___' 19 | } 20 | } 21 | 22 | describe('Puppeteer', function () { 23 | before(async function () { 24 | // Webpack can take >2s. 25 | this.timeout(1e4) 26 | await buildClientBundle() 27 | this.browser = await puppeteer.launch() 28 | }) 29 | 30 | after(async function () { 31 | await this.browser.close() 32 | }) 33 | 34 | beforeEach('Set up server', async function () { 35 | this.serverPlugin = new PluginMiniAccounts(BTP_SERVER_OPTS) 36 | this.server = await createServer({ plugin: this.serverPlugin }) 37 | this.server.on('connection', (connection: Connection) => { 38 | this.serverConnection = connection 39 | connection.on('stream', (stream) => { 40 | stream.setReceiveMax(10000) 41 | }) 42 | }) 43 | }) 44 | 45 | beforeEach('Set up client', async function () { 46 | this.timeout(1e4) 47 | this.page = await this.browser.newPage() 48 | this.page.on('error', (err: Error) => { 49 | console.log('puppeteer_error:', err.stack) 50 | }) 51 | this.page.on('console', (message: puppeteer.ConsoleMessage) => { 52 | console.log('puppeteer:', message.text()) 53 | }) 54 | this.page.on('close', () => console.log('puppeteer_close')) 55 | this.page.on('dialog', (dialog: puppeteer.Dialog) => console.log('puppeteer_dialog', dialog)) 56 | this.page.on('pageerror', (err: Error) => console.log('puppeteer_pageerror', err)) 57 | 58 | // Navigate to a dummy page so that `window.crypto.subtle` is available. 59 | // See: https://github.com/GoogleChrome/puppeteer/issues/2301#issuecomment-379622459 60 | await this.page.goto('file:///dev/null') 61 | await this.page.addScriptTag({ 62 | path: path.resolve(__dirname, '../dist/test/browser/bundle.js') 63 | }) 64 | 65 | const { destinationAccount, sharedSecret } = this.server.generateAddressAndSecret() 66 | await this.page.evaluate(async (opts: { 67 | port: number, 68 | destinationAccount: string, 69 | sharedSecret: string 70 | }) => { 71 | try { 72 | window['streamClient'] = await window['makeStreamClient']({ 73 | server: 'btp+ws://127.0.0.1:' + opts.port, 74 | btpToken: 'secret' 75 | }, opts) 76 | } catch (err) { 77 | console.error('uncaught error:', err.stack) 78 | throw err 79 | } 80 | }, { 81 | port: BTP_SERVER_OPTS.port, 82 | destinationAccount, 83 | sharedSecret: sharedSecret.toString('base64') 84 | }) 85 | }) 86 | 87 | afterEach('Tear down client & server', async function () { 88 | await this.page.evaluate(async function () { 89 | await window['streamClient'].end() 90 | }) 91 | await this.serverConnection.destroy() 92 | await this.server.close() 93 | }) 94 | 95 | describe('stream money', function () { 96 | it('sends money', async function () { 97 | await this.page.evaluate(async function () { 98 | const stream = window['streamClient'].createStream() 99 | await stream.sendTotal(100) 100 | }) 101 | assert.equal(this.serverConnection.totalReceived, '100') 102 | }) 103 | }) 104 | 105 | // This test runner is pretty clumsy/fragile. 106 | describe('crypto helpers (browser)', function () { 107 | it('passes', async function () { 108 | await this.page.evaluate(async function () { 109 | const tests: any[] = [] 110 | window['runCryptoTests']({ 111 | describe: function (label: any, run: any) { run() }, 112 | it: function (label: any, run: any) { tests.push(run()) } 113 | }) 114 | await Promise.all(tests) 115 | }) 116 | }) 117 | }) 118 | }) 119 | 120 | function buildClientBundle () { 121 | return new Promise((resolve, reject) => { 122 | webpack(require('./browser/webpack.config.js'), (err, stats) => { 123 | if (err) { 124 | reject(err) 125 | } else if (stats.hasErrors()) { 126 | reject(stats.compilation.errors[0]) 127 | } else { 128 | resolve() 129 | } 130 | }) 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /src/util/rational.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import * as Long from 'long' 3 | import { 4 | multiplyDivideFloor, 5 | multiplyDivideCeil, 6 | multiplyDivideRound 7 | } from './long' 8 | 9 | export default class Rational { 10 | static UZERO = new Rational(Long.UZERO, Long.UONE, true) 11 | 12 | private a: Long 13 | private b: Long 14 | public unsigned: boolean 15 | 16 | constructor (numer: Long, denom: Long, unsigned: boolean) { 17 | if (!unsigned) { 18 | throw new Error('signed rationals are not implemented') 19 | } 20 | assert.strictEqual(numer.unsigned, unsigned, 'numerator is incorrectly signed') 21 | assert.strictEqual(denom.unsigned, unsigned, 'denominator is incorrectly signed') 22 | assert(!denom.isZero(), 'denominator must be non-zero') 23 | this.a = numer 24 | this.b = denom 25 | this.unsigned = unsigned 26 | } 27 | 28 | static isRational (value: any): value is Rational { 29 | return value instanceof Rational 30 | } 31 | 32 | static fromNumbers (numer: number, denom: number, unsigned: boolean): Rational { 33 | return new Rational( 34 | Long.fromNumber(numer, unsigned), 35 | Long.fromNumber(denom, unsigned), 36 | unsigned 37 | ) 38 | } 39 | 40 | static fromNumber (value: number, unsigned: boolean): Rational { 41 | if (!isFinite(value)) { 42 | throw new Error('value must be finite') 43 | } else if (unsigned && value < 0) { 44 | throw new Error('unsigned value must be positive') 45 | } 46 | 47 | // Integers become value/1. 48 | if (value % 1 === 0) { 49 | return Rational.fromNumbers(value, 1, unsigned) 50 | } 51 | 52 | // Really simple float → rational conversion. There's probably a better way 53 | // to do this. That said, creating a Rational from two Longs is always going 54 | // to be more precise. 55 | const mag = Math.floor(Math.log(value) / Math.LN10) 56 | let shift = mag < 0 ? 18 : (18 - mag) 57 | let den = 1 58 | while ( 59 | Math.floor(value * den) !== value * den && 60 | shift > 0 61 | ) { 62 | den *= 10 63 | shift-- 64 | } 65 | 66 | return Rational.fromNumbers(value * den, den, unsigned) 67 | } 68 | 69 | /** 70 | * Multiply a rational by a Long without intermediate overflow. 71 | */ 72 | multiplyByLong (value: Long): Long { 73 | return multiplyDivideFloor(value, this.a, this.b) 74 | } 75 | 76 | multiplyByLongCeil (value: Long): Long { 77 | return multiplyDivideCeil(value, this.a, this.b) 78 | } 79 | 80 | // TODO prevent overflows by reducing fraction when necessary 81 | multiplyByRational (other: Rational): Rational { 82 | return new Rational( 83 | this.a.multiply(other.a), 84 | this.b.multiply(other.b), 85 | this.unsigned 86 | ) 87 | } 88 | 89 | greaterThanOne (): boolean { 90 | return this.a.greaterThan(this.b) 91 | } 92 | 93 | /** 94 | * Returns `1 - this`. 95 | */ 96 | complement (): Rational { 97 | if (this.a.greaterThan(this.b)) { 98 | throw new Error('cannot take complement of rational >1') 99 | } 100 | return new Rational(this.b.subtract(this.a), this.b, this.unsigned) 101 | } 102 | 103 | /** 104 | * Returns `1 / this`. 105 | */ 106 | reciprocal (): Rational { 107 | return new Rational(this.b, this.a, this.unsigned) 108 | } 109 | 110 | toNumber (): number { 111 | return this.a.toNumber() / this.b.toNumber() 112 | } 113 | 114 | toString (): string { 115 | // 19 is the highest precision achievable using this method, since 1e19 is 116 | // the largest power of 10 that fits in a uint64. 117 | const str = trimRight(this.toFixed(19), '0') 118 | return str[str.length - 1] === '.' 119 | ? str.slice(0, -1) 120 | : str 121 | } 122 | 123 | private toFixed (digits?: number): string { 124 | digits = digits || 0 125 | const quotient = this.a.divide(this.b) 126 | if (digits === 0) { 127 | return quotient.toString() 128 | } 129 | 130 | const remainder = this.a.modulo(this.b) 131 | const remainderString = multiplyDivideRound( 132 | remainder, 133 | power10(digits), 134 | this.b 135 | ).toString() 136 | 137 | return quotient.toString() + 138 | '.' + 139 | '0'.repeat(digits - remainderString.length) + 140 | remainderString 141 | } 142 | } 143 | 144 | function trimRight (str: string, ch: string): string { 145 | for (let i = str.length - 1; i >= 0; i--) { 146 | if (str[i] !== ch) { 147 | return str.slice(0, i + 1) 148 | } 149 | } 150 | return '' 151 | } 152 | 153 | function power10 (n: number): Long { 154 | const ten = Long.fromNumber(10, true) 155 | let value = Long.UONE 156 | while (n--) value = value.multiply(ten) 157 | return value 158 | } 159 | -------------------------------------------------------------------------------- /src/pool.ts: -------------------------------------------------------------------------------- 1 | import createLogger from 'ilp-logger' 2 | import * as IlpPacket from 'ilp-packet' 3 | import { Reader } from 'oer-utils' 4 | import { Connection, BuildConnectionOpts } from './connection' 5 | import * as cryptoHelper from './crypto' 6 | 7 | const log = createLogger('ilp-protocol-stream:Pool') 8 | 9 | interface ConnectionEvent { 10 | (connection: Connection): void 11 | } 12 | 13 | type ConnectionOptions = Omit 14 | 15 | export class ServerConnectionPool { 16 | private serverSecret: Buffer 17 | private connectionOpts: ConnectionOptions 18 | private onConnection: ConnectionEvent 19 | private activeConnections: { [id: string]: Connection } 20 | private pendingConnections: { [id: string]: Promise } 21 | 22 | constructor ( 23 | serverSecret: Buffer, 24 | connectionOpts: ConnectionOptions, 25 | onConnection: ConnectionEvent 26 | ) { 27 | this.serverSecret = serverSecret 28 | this.connectionOpts = connectionOpts 29 | this.onConnection = onConnection 30 | this.activeConnections = {} 31 | this.pendingConnections = {} 32 | } 33 | 34 | async close (): Promise { 35 | await Promise.all(Object.keys(this.activeConnections) 36 | .map((id: string) => this.activeConnections[id].end())) 37 | } 38 | 39 | async getConnection ( 40 | id: string, 41 | prepare: IlpPacket.IlpPrepare 42 | ): Promise { 43 | const activeConnection = this.activeConnections[id] 44 | if (activeConnection) return Promise.resolve(activeConnection) 45 | const pendingConnection = this.pendingConnections[id] 46 | if (pendingConnection) return pendingConnection 47 | 48 | const connectionPromise = (async () => { 49 | const token = Buffer.from(id, 'base64') 50 | const sharedSecret = await this.getSharedSecret(token, prepare) 51 | // If we get here, that means it was a token + sharedSecret we created 52 | let connectionTag: string | undefined 53 | let receiptNonce: Buffer | undefined 54 | let receiptSecret: Buffer | undefined 55 | const reader = new Reader(cryptoHelper.decryptConnectionAddressToken(this.serverSecret, token)) 56 | reader.skipOctetString(cryptoHelper.TOKEN_NONCE_LENGTH) 57 | if (reader.peekVarOctetString().length) { 58 | connectionTag = reader.readVarOctetString().toString('ascii') 59 | } else { 60 | reader.skipVarOctetString() 61 | } 62 | switch (reader.peekVarOctetString().length) { 63 | case 0: 64 | reader.skipVarOctetString() 65 | break 66 | case 16: 67 | receiptNonce = reader.readVarOctetString() 68 | break 69 | default: 70 | throw new Error('receiptNonce must be 16 bytes') 71 | } 72 | switch (reader.peekVarOctetString().length) { 73 | case 0: 74 | reader.skipVarOctetString() 75 | break 76 | case 32: 77 | receiptSecret = reader.readVarOctetString() 78 | break 79 | default: 80 | throw new Error('receiptSecret must be 32 bytes') 81 | } 82 | const conn = await Connection.build({ 83 | ...this.connectionOpts, 84 | sharedSecret, 85 | connectionTag, 86 | connectionId: id, 87 | receiptNonce, 88 | receiptSecret 89 | }) 90 | log.debug('got incoming packet for new connection: %s%s', id, (connectionTag ? ' (connectionTag: ' + connectionTag + ')' : '')) 91 | try { 92 | this.onConnection(conn) 93 | } catch (err) { 94 | log.error('error in connection event handler:', err) 95 | } 96 | 97 | conn.once('close', () => { 98 | delete this.pendingConnections[id] 99 | delete this.activeConnections[id] 100 | }) 101 | return conn 102 | })() 103 | 104 | connectionPromise.catch(() => { 105 | delete this.pendingConnections[id] 106 | }) 107 | 108 | this.pendingConnections[id] = connectionPromise 109 | const connection = await connectionPromise 110 | this.activeConnections[id] = connection 111 | delete this.pendingConnections[id] 112 | 113 | // Wait for the next tick of the event loop before handling the prepare 114 | await new Promise((resolve, reject) => setImmediate(resolve)) 115 | return connection 116 | } 117 | 118 | private async getSharedSecret ( 119 | token: Buffer, 120 | prepare: IlpPacket.IlpPrepare 121 | ): Promise { 122 | try { 123 | const sharedSecret = cryptoHelper.generateSharedSecretFromToken( 124 | this.serverSecret, token) 125 | // TODO just pass this into the connection? 126 | const pskKey = await cryptoHelper.generatePskEncryptionKey(sharedSecret) 127 | await cryptoHelper.decrypt(pskKey, prepare.data) 128 | return sharedSecret 129 | } catch (err) { 130 | log.error('got prepare for an address and token that we did not generate: %s', prepare.destination) 131 | throw err 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/util/congestion.ts: -------------------------------------------------------------------------------- 1 | import * as Long from 'long' 2 | import createLogger from 'ilp-logger' 3 | import * as IlpPacket from 'ilp-packet' 4 | import { Reader } from 'oer-utils' 5 | import { 6 | checkedAdd, 7 | checkedMultiply, 8 | maxLong, 9 | minLong, 10 | multiplyDivideFloor 11 | } from './long' 12 | 13 | const log = createLogger('ilp-protocol-stream:Congestion') 14 | 15 | interface CongestionOptions { 16 | /** Maximum amount per packet, even if F08 reports larger */ 17 | maximumPacketAmount?: Long 18 | } 19 | 20 | export class CongestionController { 21 | /** Used to probe for the Maximum Packet Amount if the connectors don't tell us directly */ 22 | private _testMaximumPacketAmount: Long 23 | /** The path's Maximum Packet Amount, discovered through F08 errors */ 24 | private _maximumPacketAmount: Long 25 | /** The sender-chosen maximum packet amount. */ 26 | private _fixedPacketAmount: Long 27 | 28 | constructor (opts: CongestionOptions) { 29 | this._testMaximumPacketAmount = Long.MAX_UNSIGNED_VALUE 30 | this._maximumPacketAmount = Long.MAX_UNSIGNED_VALUE 31 | this._fixedPacketAmount = opts.maximumPacketAmount || Long.MAX_UNSIGNED_VALUE 32 | } 33 | 34 | get testMaximumPacketAmount (): Long { 35 | return this._testMaximumPacketAmount 36 | } 37 | 38 | get maximumPacketAmount (): Long { 39 | return minLong(this._maximumPacketAmount, this._fixedPacketAmount) 40 | } 41 | 42 | setMaximumAmounts (amount: Long) { 43 | this._testMaximumPacketAmount = amount 44 | this._maximumPacketAmount = amount 45 | } 46 | 47 | onFulfill (amountSent: Long) { 48 | const maximumPacketAmount = this.maximumPacketAmount 49 | const shouldRaiseLimit = amountSent.equals(this._testMaximumPacketAmount) 50 | && this._testMaximumPacketAmount.lessThan(maximumPacketAmount) 51 | if (!shouldRaiseLimit) return 52 | // If we're trying to pinpoint the Maximum Packet Amount, raise 53 | // the limit because we know that the testMaximumPacketAmount works 54 | 55 | let newTestMax 56 | const isMaxPacketAmountKnown = 57 | maximumPacketAmount.notEquals(Long.MAX_UNSIGNED_VALUE) 58 | if (isMaxPacketAmountKnown) { 59 | // Take the `max packet amount / 10` and then add it to the last test packet amount for an additive increase. 60 | const additiveIncrease = maximumPacketAmount.divide(10) 61 | newTestMax = minLong( 62 | checkedAdd(this._testMaximumPacketAmount, additiveIncrease).sum, 63 | maximumPacketAmount) 64 | log.trace('last packet amount was successful (max packet amount: %s), raising packet amount from %s to: %s', maximumPacketAmount, this._testMaximumPacketAmount, newTestMax) 65 | } else { 66 | // Increase by 2 times in this case since we do not know the max packet amount 67 | newTestMax = checkedMultiply( 68 | this._testMaximumPacketAmount, 69 | Long.fromNumber(2, true)).product 70 | log.trace('last packet amount was successful, unknown max packet amount, raising packet amount from: %s to: %s', this._testMaximumPacketAmount, newTestMax) 71 | } 72 | this._testMaximumPacketAmount = newTestMax 73 | } 74 | 75 | // Returns the new maximum packet amount. 76 | onAmountTooLargeError (reject: IlpPacket.IlpReject, amountSent: Long): Long { 77 | let receivedAmount: Long | undefined 78 | let maximumAmount: Long | undefined 79 | try { 80 | const reader = Reader.from(reject.data) 81 | receivedAmount = reader.readUInt64Long() 82 | maximumAmount = reader.readUInt64Long() 83 | } catch (err) { 84 | receivedAmount = undefined 85 | maximumAmount = undefined 86 | } 87 | 88 | if (receivedAmount && maximumAmount && receivedAmount.greaterThan(maximumAmount)) { 89 | const newMaximum = multiplyDivideFloor(amountSent, maximumAmount, receivedAmount) 90 | log.trace('reducing maximum packet amount from %s to %s', this._maximumPacketAmount, newMaximum) 91 | this._maximumPacketAmount = newMaximum 92 | this._testMaximumPacketAmount = newMaximum 93 | } else { 94 | // Connector didn't include amounts 95 | this._maximumPacketAmount = amountSent.subtract(1) 96 | this._testMaximumPacketAmount = this.maximumPacketAmount.divide(2) 97 | } 98 | return this.maximumPacketAmount 99 | } 100 | 101 | onInsufficientLiquidityError (reject: IlpPacket.IlpReject, amountSent: Long) { 102 | // TODO add more sophisticated logic for handling bandwidth-related connector errors 103 | // we should really be keeping track of the amount sent within a given window of time 104 | // and figuring out the max amount per window. this logic is just a stand in to fix 105 | // infinite retries when it runs into this type of error 106 | const minPacketAmount = minLong(amountSent, this._testMaximumPacketAmount) 107 | const newTestAmount = minPacketAmount.subtract(minPacketAmount.divide(3)) 108 | // don't let it go to zero, set to 2 so that the other side gets at least 1 after the exchange rate is taken into account 109 | this._testMaximumPacketAmount = maxLong(Long.fromNumber(2, true), newTestAmount) 110 | log.warn('got T04: Insufficient Liquidity error triggered by: %s reducing the packet amount to %s', reject.triggeredBy, this._testMaximumPacketAmount) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/util/crypto-browser.ts: -------------------------------------------------------------------------------- 1 | // According to their type declarations, SubtleCrypto functions return `PromiseLike`. 2 | // They are close enough to a `Promise` to `await`, but tslint doesn't know that. 3 | /* tslint:disable:await-promise */ 4 | 5 | const { crypto } = window 6 | const HASH_ALGORITHM = 'SHA-256' 7 | const ENCRYPTION_ALGORITHM = 'AES-GCM' 8 | const IV_LENGTH = 12 9 | const AUTH_TAG_BYTES = 16 10 | const AUTH_TAG_BITS = 8 * AUTH_TAG_BYTES 11 | const CACHE_EXPIRY = 30000 12 | 13 | // Cache keys so that `subtle.importKey` doesn't need to be called for every operation. 14 | // It would be nicer to just store the `CryptoKey`s on the stream `Connection`, but 15 | // that's tricky since this file takes the place of `crypto-node.ts`. 16 | class KeyCache { 17 | private cache: Map = new Map() 18 | 19 | cleanup () { 20 | const now = Date.now() 21 | for (const [cacheData, cacheEntry] of this.cache) { 22 | if (now - cacheEntry.accessTime > CACHE_EXPIRY) { 23 | this.cache.delete(cacheData) 24 | } 25 | } 26 | } 27 | 28 | async importKey ( 29 | keyData: Buffer, 30 | algorithm: string | HmacImportParams | AesKeyAlgorithm, 31 | keyUsages: string[] 32 | ): Promise { 33 | const oldEntry = this.cache.get(keyData) 34 | if (oldEntry) { 35 | oldEntry.accessTime = Date.now() 36 | return oldEntry.keyObject 37 | } 38 | const keyObject = await crypto.subtle.importKey( 39 | 'raw', 40 | keyData, 41 | algorithm, 42 | false, // extractable 43 | keyUsages 44 | ) 45 | this.cache.set(keyData, { 46 | keyObject, 47 | accessTime: Date.now() 48 | }) 49 | return keyObject 50 | } 51 | } 52 | 53 | interface CacheEntry { 54 | keyObject: CryptoKey, 55 | accessTime: number // milliseconds since epoch 56 | } 57 | 58 | const hmacKeyCache = new KeyCache() 59 | const aesKeyCache = new KeyCache() 60 | 61 | setInterval(() => { 62 | hmacKeyCache.cleanup() 63 | aesKeyCache.cleanup() 64 | }, 30000) 65 | 66 | export async function hash (preimage: Buffer): Promise { 67 | const digest = await crypto.subtle.digest({ name: HASH_ALGORITHM }, preimage) 68 | return Buffer.from(digest) 69 | } 70 | 71 | export async function encrypt (pskEncryptionKey: Buffer, ...buffers: Buffer[]): Promise { 72 | const iv = randomBytes(IV_LENGTH) 73 | const key = await aesKeyCache.importKey( 74 | pskEncryptionKey, ENCRYPTION_ALGORITHM, ['encrypt', 'decrypt']) 75 | const ciphertext = await crypto.subtle.encrypt( 76 | { 77 | name: ENCRYPTION_ALGORITHM, 78 | iv, 79 | tagLength: AUTH_TAG_BITS 80 | }, 81 | key, 82 | Buffer.concat(buffers) 83 | ) 84 | const tagStart = ciphertext.byteLength - AUTH_TAG_BYTES 85 | const tag = ciphertext.slice(tagStart) 86 | const data = ciphertext.slice(0, tagStart) 87 | return Buffer.concat([ 88 | Buffer.from(iv), 89 | Buffer.from(tag), 90 | Buffer.from(data) 91 | ]) 92 | } 93 | 94 | export async function decrypt (pskEncryptionKey: Buffer, data: Buffer): Promise { 95 | const nonce = data.slice(0, IV_LENGTH) 96 | const tag = data.slice(IV_LENGTH, IV_LENGTH + AUTH_TAG_BYTES) 97 | const cipherdata = data.slice(IV_LENGTH + AUTH_TAG_BYTES) 98 | const key = await aesKeyCache.importKey( 99 | pskEncryptionKey, ENCRYPTION_ALGORITHM, ['encrypt', 'decrypt']) 100 | const decryptedData = await crypto.subtle.decrypt( 101 | { 102 | name: ENCRYPTION_ALGORITHM, 103 | iv: nonce 104 | }, 105 | key, 106 | Buffer.concat([cipherdata, tag]) 107 | ) 108 | return Buffer.from(decryptedData) 109 | } 110 | 111 | const HMAC_ALGORITHM = { 112 | name: 'HMAC', 113 | hash: { name: HASH_ALGORITHM } 114 | } 115 | 116 | export async function hmac (key: Buffer, message: Buffer): Promise { 117 | const hmacKey = await hmacKeyCache.importKey( 118 | key, HMAC_ALGORITHM, ['sign', 'verify']) 119 | const signature = await crypto.subtle.sign('HMAC', hmacKey, message) 120 | return Buffer.from(signature) 121 | } 122 | 123 | export function randomBytes (size: number): Buffer { 124 | const randArray = new Uint8Array(size) 125 | const randValues = crypto.getRandomValues(randArray) 126 | return Buffer.from(randValues) 127 | } 128 | 129 | // Dummy function to make typescript happy. This function is only ever used by 130 | // the server, which is not included in the browser build. 131 | export function generateSharedSecretFromToken (seed: Buffer, token: Buffer): Buffer { 132 | throw new Error('unreachable in browser') 133 | } 134 | 135 | // Dummy function to make typescript happy. This function is only ever used by 136 | // the server, which is not included in the browser build. 137 | export function generateReceiptHMAC (secret: Buffer, message: Buffer): Buffer { 138 | throw new Error('unreachable in browser') 139 | } 140 | 141 | // Dummy function to make typescript happy. This function is only ever used by 142 | // the server, which is not included in the browser build. 143 | export function encryptConnectionAddressToken (seed: Buffer, token: Buffer): Buffer { 144 | throw new Error('unreachable in browser') 145 | } 146 | 147 | // Dummy function to make typescript happy. This function is only ever used by 148 | // the server, which is not included in the browser build. 149 | export function decryptConnectionAddressToken (seed: Buffer, token: Buffer): Buffer { 150 | throw new Error('unreachable in browser') 151 | } 152 | -------------------------------------------------------------------------------- /test/packet.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import 'mocha' 3 | import * as PacketModule from '../src/packet' 4 | import { 5 | Packet, 6 | StreamMoneyFrame, 7 | Frame, 8 | FrameType, 9 | StreamMaxMoneyFrame, 10 | StreamMoneyBlockedFrame 11 | } from '../src/packet' 12 | import { Reader, Writer } from 'oer-utils' 13 | import * as Long from 'long' 14 | 15 | describe('Packet Format', function () { 16 | describe('decryptAndDeserialize()', function () { 17 | it('should throw an error if it cannot decrypt the packet', async function () { 18 | const packet = Buffer.from('9c4f511dbc865607311609d7559e01e1fd22f985292539e1f5d8f3eb0832060f', 'hex') 19 | 20 | try { 21 | await Packet.decryptAndDeserialize(Buffer.alloc(32), packet) 22 | } catch (err) { 23 | assert.equal(err.message, 'Unable to decrypt packet. Data was corrupted or packet was encrypted with the wrong key') 24 | return 25 | } 26 | assert(false) 27 | }) 28 | 29 | it('should throw an error if the version is unsupported', function () { 30 | const decryptedPacket = Buffer.from('9c4f511dbc865607311609d7559e01e1fd22f985292539e1f5d8f3eb0832060f', 'hex') 31 | 32 | assert.throws(() => { 33 | return Packet._deserializeUnencrypted(decryptedPacket) 34 | }, new Error('Unsupported protocol version: 156')) 35 | }) 36 | 37 | it('should skip unknown frames', function () { 38 | const unknownFrameWriter = new Writer() 39 | unknownFrameWriter.writeUInt8(255) 40 | unknownFrameWriter.writeVarOctetString(Buffer.alloc(47, '0F', 'hex')) 41 | const unknownFrame = unknownFrameWriter.getBuffer() 42 | 43 | const lastFrame = new StreamMoneyFrame(3, 3).writeTo(new Writer()).getBuffer() 44 | 45 | const packet = new Packet(0, 14, 5, [ 46 | new StreamMoneyFrame(1, 1), 47 | new StreamMoneyFrame(2, 2) 48 | ]) 49 | 50 | const serialized = packet._serialize() 51 | serialized[7] = 5 52 | const serializedWithExtraFrames = Buffer.concat([ 53 | serialized, 54 | unknownFrame, 55 | lastFrame, 56 | unknownFrame 57 | ]) 58 | const deserializedPacket = Packet._deserializeUnencrypted(serializedWithExtraFrames) 59 | 60 | assert.equal(deserializedPacket.frames.length, 3) 61 | assert.equal((deserializedPacket.frames[2] as StreamMoneyFrame).streamId.toNumber(), 3) 62 | }) 63 | 64 | it('should stop reading after the number of frames specified', function () { 65 | const packet = new Packet(0, 14, 5, [ 66 | new StreamMoneyFrame(1, 1), 67 | new StreamMoneyFrame(2, 2) 68 | ]) 69 | const serialized = packet._serialize() 70 | const lastFrame = new StreamMoneyFrame(3, 3).writeTo(new Writer()).getBuffer() 71 | const serializedWithExtraFrames = Buffer.concat([ 72 | serialized, 73 | lastFrame 74 | ]) 75 | 76 | const deserializedPacket = Packet._deserializeUnencrypted(serializedWithExtraFrames) 77 | assert.equal(deserializedPacket.frames.length, 2) 78 | }) 79 | }) 80 | 81 | describe('StreamMaxMoneyFrame', function () { 82 | it('converts larger receiveMax to MaxUInt64', function () { 83 | const writer = new Writer() 84 | writer.writeVarUInt(123) // streamId 85 | writer.writeVarOctetString(Buffer.from([ // receiveMax 86 | 0x01, 0x02, 0x03, 87 | 0x04, 0x05, 0x06, 88 | 0x07, 0x08, 0x09 89 | ])) 90 | writer.writeVarUInt(123) // totalReceived 91 | 92 | const frame = StreamMaxMoneyFrame.fromContents(new Reader(writer.getBuffer())) 93 | assert.deepEqual(frame.receiveMax, Long.MAX_UNSIGNED_VALUE) 94 | }) 95 | }) 96 | 97 | describe('StreamMoneyBlockedFrame', function () { 98 | it('converts larger sendMax to MaxUInt64', function () { 99 | const writer = new Writer() 100 | writer.writeVarUInt(123) // streamId 101 | writer.writeVarOctetString(Buffer.from([ // sendMax 102 | 0x01, 0x02, 0x03, 103 | 0x04, 0x05, 0x06, 104 | 0x07, 0x08, 0x09 105 | ])) 106 | writer.writeVarUInt(123) // totalSent 107 | 108 | const frame = StreamMoneyBlockedFrame.fromContents(new Reader(writer.getBuffer())) 109 | assert.deepEqual(frame.sendMax, Long.MAX_UNSIGNED_VALUE) 110 | }) 111 | }) 112 | }) 113 | 114 | describe('Packet Fixtures', function () { 115 | const fixtures = require('./fixtures/packets.json') 116 | fixtures.forEach(function (fixture: any) { 117 | const wantBuffer = Buffer.from(fixture.buffer, 'base64') 118 | const wantPacket = new Packet( 119 | fixture.packet.sequence, 120 | fixture.packet.packetType, 121 | fixture.packet.amount, 122 | fixture.packet.frames.map(buildFrame) 123 | ) 124 | 125 | it('deserializes ' + fixture.name, function () { 126 | const gotPacket = Packet._deserializeUnencrypted(wantBuffer) 127 | assert.deepEqual(gotPacket, wantPacket) 128 | }) 129 | 130 | if (fixture.decode_only) return 131 | 132 | it('serializes ' + fixture.name, function () { 133 | const gotBuffer = wantPacket._serialize() 134 | assert(gotBuffer.equals(wantBuffer)) 135 | }) 136 | }) 137 | }) 138 | 139 | function buildFrame (options: any) { 140 | for (const key in options) { 141 | const value = options[key] 142 | if (typeof value === 'string') { 143 | if (/^\d+$/.test(value)) { 144 | options[key] = Long.fromString(value, true) 145 | } else if (['data', 'receipt'].indexOf(key) !== -1) { 146 | options[key] = Buffer.from(value, 'base64') 147 | } 148 | } 149 | } 150 | return Object.assign( 151 | Object.create(PacketModule[options.name + 'Frame'].prototype), 152 | options 153 | ) 154 | } 155 | -------------------------------------------------------------------------------- /scripts/generate-fixtures.ts: -------------------------------------------------------------------------------- 1 | import * as Long from 'long' 2 | import * as IlpPacket from 'ilp-packet' 3 | import * as Packet from '../src/packet' 4 | 5 | const NUMBERS = [ 6 | { name: '0', value: 0 }, 7 | { name: 'max_js', value: Number.MAX_SAFE_INTEGER }, 8 | { name: 'max_uint_64', value: Long.MAX_UNSIGNED_VALUE } 9 | ] 10 | 11 | Long.prototype['toJSON'] = function () { 12 | return this.toString() 13 | } 14 | 15 | Packet.StreamDataFrame.prototype.toJSON = function () { 16 | return { 17 | type: this.type, 18 | name: this.name, 19 | streamId: this.streamId, 20 | offset: this.offset, 21 | data: this.data.toString('base64') 22 | } 23 | } 24 | 25 | const variants = Array.prototype.concat.apply([], [ 26 | NUMBERS.map((pair) => ({ name: 'sequence:' + pair.name, sequence: pair.value })), 27 | { name: 'type:prepare', packetType: Packet.IlpPacketType.Prepare }, 28 | { name: 'type:fulfill', packetType: Packet.IlpPacketType.Fulfill }, 29 | { name: 'type:reject', packetType: Packet.IlpPacketType.Reject }, 30 | NUMBERS.map((pair) => ({ name: 'amount:' + pair.name, amount: pair.value })), 31 | 32 | { 33 | name: 'frame:connection_close', 34 | frame: new Packet.ConnectionCloseFrame(0x01, 'fail') 35 | }, 36 | { 37 | name: 'frame:connection_new_address', 38 | frame: new Packet.ConnectionNewAddressFrame('example.alice') 39 | }, 40 | { 41 | name: 'frame:connection_asset_details', 42 | frame: new Packet.ConnectionAssetDetailsFrame('ABC', 256 - 1) 43 | }, 44 | 45 | NUMBERS.map((pair) => ({ 46 | name: 'frame:connection_max_data:' + pair.name, 47 | frame: new Packet.ConnectionMaxDataFrame(pair.value) 48 | })), 49 | NUMBERS.map((pair) => ({ 50 | name: 'frame:connection_data_blocked:' + pair.name, 51 | frame: new Packet.ConnectionDataBlockedFrame(pair.value) 52 | })), 53 | NUMBERS.map((pair) => ({ 54 | name: 'frame:connection_max_stream_id:' + pair.name, 55 | frame: new Packet.ConnectionMaxStreamIdFrame(pair.value) 56 | })), 57 | NUMBERS.map((pair) => ({ 58 | name: 'frame:connection_stream_id_blocked:' + pair.name, 59 | frame: new Packet.ConnectionStreamIdBlockedFrame(pair.value) 60 | })), 61 | 62 | { 63 | name: 'frame:stream_close', 64 | frame: new Packet.StreamCloseFrame(123, 256 - 1, 'an error message') 65 | }, 66 | 67 | NUMBERS.map((pair) => ({ 68 | name: 'frame:stream_money:' + pair.name, 69 | frame: new Packet.StreamMoneyFrame(123, pair.value) 70 | })), 71 | NUMBERS.map((pair) => ({ 72 | name: 'frame:stream_max_money:receive_max:' + pair.name, 73 | frame: new Packet.StreamMaxMoneyFrame(123, pair.value, 456) 74 | })), 75 | NUMBERS.map((pair) => ({ 76 | name: 'frame:stream_max_money:total_received:' + pair.name, 77 | frame: new Packet.StreamMaxMoneyFrame(123, 456, pair.value) 78 | })), 79 | NUMBERS.map((pair) => ({ 80 | name: 'frame:stream_money_blocked:send_max:' + pair.name, 81 | frame: new Packet.StreamMoneyBlockedFrame(123, pair.value, 456) 82 | })), 83 | NUMBERS.map((pair) => ({ 84 | name: 'frame:stream_money_blocked:total_sent:' + pair.name, 85 | frame: new Packet.StreamMoneyBlockedFrame(123, 456, pair.value) 86 | })), 87 | 88 | { 89 | name: 'frame:stream_data', 90 | frame: new Packet.StreamDataFrame(123, 456, Buffer.from('foobar')) 91 | }, 92 | NUMBERS.map((pair) => ({ 93 | name: 'frame:stream_data:offset:' + pair.name, 94 | frame: new Packet.StreamDataFrame(123, pair.value, Buffer.alloc(0)) 95 | })), 96 | NUMBERS.map((pair) => ({ 97 | name: 'frame:stream_max_data:offset:' + pair.name, 98 | frame: new Packet.StreamMaxDataFrame(123, pair.value) 99 | })), 100 | NUMBERS.map((pair) => ({ 101 | name: 'frame:stream_data_blocked:offset:' + pair.name, 102 | frame: new Packet.StreamDataBlockedFrame(123, pair.value) 103 | })), 104 | { 105 | name: 'frame:stream_receipt', 106 | frame: new Packet.StreamReceiptFrame(1, 107 | Buffer.from('AQAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAfTBIvoCUt67Zy1ZGCP3EOmVFtZzhc85fah8yPnoyL9RMA==', 'base64')) 108 | } 109 | ]) 110 | 111 | const fixtures = variants.map(function (params: any) { 112 | const packetOptions: { 113 | sequence: string, 114 | packetType: IlpPacket.Type, 115 | amount: string, 116 | frames: Packet.Frame[] 117 | } = { 118 | sequence: '0', 119 | packetType: Packet.IlpPacketType.Prepare, 120 | amount: '0', 121 | frames: [] 122 | } 123 | 124 | if (params.sequence !== undefined) packetOptions.sequence = params.sequence.toString() 125 | if (params.packetType !== undefined) packetOptions.packetType = params.packetType 126 | if (params.amount !== undefined) packetOptions.amount = params.amount.toString() 127 | if (params.frame) packetOptions.frames.push(params.frame) 128 | 129 | const packet = new Packet.Packet( 130 | packetOptions.sequence, 131 | packetOptions.packetType, 132 | packetOptions.amount, 133 | packetOptions.frames 134 | ) 135 | 136 | return { 137 | name: params.name, 138 | packet: packetOptions, 139 | buffer: packet._serialize().toString('base64') 140 | } 141 | }) 142 | 143 | // The receive_max is set to `Long.MAX_UNSIGNED_VALUE + 1`. 144 | fixtures.push({ 145 | name: 'frame:stream_max_money:receive_max:too_big', 146 | packet: { 147 | sequence: '0', 148 | packetType: Packet.IlpPacketType.Prepare, 149 | amount: '0', 150 | frames: [new Packet.StreamMaxMoneyFrame(123, Long.MAX_UNSIGNED_VALUE, 456)] 151 | }, 152 | buffer: 'AQwBAAEAAQESDwF7CQEAAAAAAAAAAAIByA==', 153 | decode_only: true 154 | }) 155 | 156 | // The send_max is set to `Long.MAX_UNSIGNED_VALUE + 1`. 157 | fixtures.push({ 158 | name: 'frame:stream_money_blocked:send_max:too_big', 159 | packet: { 160 | sequence: '0', 161 | packetType: Packet.IlpPacketType.Prepare, 162 | amount: '0', 163 | frames: [new Packet.StreamMoneyBlockedFrame(123, Long.MAX_UNSIGNED_VALUE, 456)] 164 | }, 165 | buffer: 'AQwBAAEAAQETDwF7CQEAAAAAAAAAAAIByA==', 166 | decode_only: true 167 | }) 168 | 169 | console.log(JSON.stringify(fixtures, null, ' ')) 170 | -------------------------------------------------------------------------------- /docs/assets/stream-in-protocol-suite.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | G 11 | 12 | 13 | cluster_0 14 | 15 | 16 | 17 | cluster_1 18 | 19 | 20 | 21 | 22 | app 23 | Application 24 | 25 | 26 | 27 | transport 28 | Transport 29 | 30 | 31 | 32 | interledger 33 | Interledger 34 | 35 | 36 | 37 | link 38 | Link 39 | 40 | 41 | 42 | ledger 43 | Ledger 44 | 45 | 46 | 47 | app1 48 | 49 | SPSP 50 | 51 | 52 | 53 | tr1 54 | 55 | STREAM 56 | 57 | 58 | 59 | app1--tr1 60 | 61 | 62 | 63 | 64 | app2 65 | 66 | ... 67 | 68 | 69 | 70 | app2--tr1 71 | 72 | 73 | 74 | 75 | im1 76 | 77 | ILPv4 78 | 79 | 80 | 81 | tr1--im1 82 | 83 | 84 | 85 | 86 | tr2 87 | 88 | ... 89 | 90 | 91 | 92 | tr2--im1 93 | 94 | 95 | 96 | 97 | link1 98 | 99 | BTP 100 | 101 | 102 | 103 | im1--link1 104 | 105 | 106 | 107 | 108 | link2 109 | 110 | ... 111 | 112 | 113 | 114 | im1--link2 115 | 116 | 117 | 118 | 119 | ledger1 120 | 121 | ... 122 | 123 | 124 | 125 | link1--ledger1 126 | 127 | 128 | 129 | 130 | ledger3 131 | 132 | ... 133 | 134 | 135 | 136 | link2--ledger3 137 | 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /test/util/rational.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import * as assert from 'assert' 3 | import BigNumber from 'bignumber.js' 4 | import * as Long from 'long' 5 | import Rational from '../../src/util/rational' 6 | 7 | function L (value: number, unsigned?: boolean): Long { 8 | return Long.fromNumber(value, unsigned === undefined ? true : unsigned) 9 | } 10 | 11 | // The number of times to repeat each randomized test. 12 | const REPS = 10000 13 | 14 | describe('Rational', function () { 15 | describe('constructor', function () { 16 | it('should throw if the numerator is incorrectly signed', function () { 17 | assert.throws( 18 | () => { new Rational(L(123, false), L(123, true), true) }, 19 | /numerator is incorrectly signed/ 20 | ) 21 | }) 22 | 23 | it('should throw if the denominator is incorrectly signed', function () { 24 | assert.throws( 25 | () => new Rational(L(123, true), L(123, false), true), 26 | /denominator is incorrectly signed/ 27 | ) 28 | }) 29 | 30 | it('should throw if the denominator is zero', function () { 31 | assert.throws( 32 | () => new Rational(L(123, true), Long.UZERO, true), 33 | /denominator must be non-zero/ 34 | ) 35 | }) 36 | }) 37 | 38 | describe('isRational', function () { 39 | it('returns true for Rational', function () { 40 | const value = Rational.fromNumbers(123, 456, true) 41 | assert.strictEqual(Rational.isRational(value), true) 42 | }) 43 | 44 | it('returns false for Number', function () { 45 | assert.strictEqual(Rational.isRational(123), false) 46 | }) 47 | 48 | it('returns false for String', function () { 49 | assert.strictEqual(Rational.isRational('123'), false) 50 | }) 51 | }) 52 | 53 | describe('fromNumber', function () { 54 | it('creates a Rational from known values', function () { 55 | assert.deepEqual( 56 | Rational.fromNumber(0, true), 57 | new Rational(L(0), L(1), true) 58 | ) 59 | 60 | assert.deepEqual( 61 | Rational.fromNumber(1, true), 62 | new Rational(L(1), L(1), true) 63 | ) 64 | 65 | // Integer: 66 | assert.deepEqual( 67 | Rational.fromNumber(123, true), 68 | new Rational(L(123), L(1), true) 69 | ) 70 | 71 | assert.deepEqual( 72 | Rational.fromNumber(0.5, true).toNumber(), 73 | 0.5 74 | ) 75 | }) 76 | 77 | it('creates a Rational from very small values', function () { 78 | assert.deepEqual( 79 | Rational.fromNumber(1.0e-10, true).toNumber(), 80 | 1.0e-10 81 | ) 82 | assert.deepEqual( 83 | Rational.fromNumber(1.23e-10, true).toNumber(), 84 | 1.23e-10 85 | ) 86 | assert.deepEqual( 87 | Rational.fromNumber(1.0e-17, true).toNumber(), 88 | 1.0e-17 89 | ) 90 | }) 91 | 92 | it('creates a Rational from a floating-point number', function () { 93 | for (let i = 0; i < REPS; i++) { 94 | const value = Math.random() * 10 95 | const result = Rational.fromNumber(value, true).toNumber() 96 | assert( 97 | Math.abs(result - value) < 1.0e-14, 98 | `attempt=${i} got=${result} want=${value}` 99 | ) 100 | } 101 | }) 102 | 103 | it('throws creating an unsigned Rational from a non-finite number', function () { 104 | assert.throws( 105 | () => Rational.fromNumber(Infinity, true), 106 | /value must be finite/ 107 | ) 108 | }) 109 | 110 | it('throws creating an unsigned Rational from a negative number', function () { 111 | assert.throws( 112 | () => Rational.fromNumber(-123, true), 113 | /unsigned value must be positive/ 114 | ) 115 | }) 116 | }) 117 | 118 | describe('multiplyByLong', function () { 119 | it('multiplies by a Long', function () { 120 | const rat = Rational.fromNumbers(1, 3, true) 121 | assert.deepEqual(rat.multiplyByLong(L(100)), L(33)) 122 | assert.deepEqual(rat.multiplyByLong(L(200)), L(66)) 123 | }) 124 | }) 125 | 126 | describe('multiplyByLongCeil', function () { 127 | it('multiplies by a Long', function () { 128 | const rat = Rational.fromNumbers(1, 3, true) 129 | assert.deepEqual(rat.multiplyByLongCeil(L(100)), L(34)) 130 | }) 131 | }) 132 | 133 | describe('multiplyByRational', function () { 134 | it('multiplies two rational numbers', function () { 135 | for (let i = 0; i < REPS; i++) { 136 | const a = Math.floor(Math.random() * 100000000) 137 | const b = Math.floor(Math.random() * 100000000) 138 | const c = Math.floor(Math.random() * 100000000) 139 | const d = Math.floor(Math.random() * 100000000) 140 | 141 | const rat1 = Rational.fromNumbers(a, b, true) 142 | const rat2 = Rational.fromNumbers(c, d, true) 143 | 144 | const result = new BigNumber(rat1.multiplyByRational(rat2).toString()) 145 | const expect = new BigNumber(a).times(c) 146 | .div(b).div(d) 147 | 148 | assert( 149 | result.minus(expect).abs().lt(1.0e-19), 150 | `attempt ${i}: ${result} != ${expect} = ((${a}/${b}) * (${c}/${d}))` 151 | ) 152 | } 153 | }) 154 | }) 155 | 156 | describe('complement', function () { 157 | it('returns (1 - this)', function () { 158 | assert.deepEqual( 159 | Rational.fromNumbers(1, 3, true).complement(), 160 | Rational.fromNumbers(2, 3, true) 161 | ) 162 | }) 163 | 164 | it('should throw if >1', function () { 165 | const value = Rational.fromNumbers(4, 3, true) 166 | assert.throws( 167 | () => value.complement(), 168 | /cannot take complement of rational >1/ 169 | ) 170 | }) 171 | }) 172 | 173 | describe('reciprocal', function () { 174 | it('swaps the numerator and denominator', function () { 175 | assert.deepEqual( 176 | Rational.fromNumbers(1, 2, true).reciprocal(), 177 | Rational.fromNumbers(2, 1, true) 178 | ) 179 | }) 180 | }) 181 | 182 | describe('toString', function () { 183 | it('returns a string', function () { 184 | assert.equal(Rational.fromNumbers(1, 2, true).toString(), '0.5') 185 | assert.equal(Rational.fromNumbers(2, 1, true).toString(), '2') 186 | assert.equal(Rational.UZERO.toString(), '0') 187 | }) 188 | 189 | it('returns a string for a Rational with a large numerator', function () { 190 | const value1 = new Rational(Long.MAX_UNSIGNED_VALUE, L(1), true) 191 | assert.equal(value1.toString(), '18446744073709551615') 192 | 193 | const value2 = new Rational(Long.MAX_UNSIGNED_VALUE, L(1000), true) 194 | assert.equal(value2.toString(), '18446744073709551.615') 195 | 196 | const value3 = new Rational(Long.MAX_UNSIGNED_VALUE, L(1000000), true) 197 | assert.equal(value3.toString(), '18446744073709.551615') 198 | }) 199 | 200 | it('returns a string for a Rational with a small numerator', function () { 201 | const value = Rational.fromNumbers(150, 150000, true) 202 | assert.equal(value.toString(), '0.001') 203 | }) 204 | }) 205 | }) 206 | -------------------------------------------------------------------------------- /test/util/long.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import * as assert from 'assert' 3 | import BigNumber from 'bignumber.js' 4 | import * as Long from 'long' 5 | import { 6 | longFromValue, 7 | maxLong, 8 | minLong, 9 | minLongs, 10 | countDigits, 11 | checkedAdd, 12 | checkedSubtract, 13 | checkedMultiply, 14 | multiplyDivideFloor, 15 | multiplyDivideCeil, 16 | multiplyDivideRound, 17 | multiplyDivide 18 | } from '../../src/util/long' 19 | 20 | function L (value: number, unsigned?: boolean): Long { 21 | return Long.fromNumber(value, unsigned === undefined ? true : unsigned) 22 | } 23 | 24 | // The number of times to repeat each randomized test. 25 | const REPS = 10000 26 | 27 | describe('util/long', function () { 28 | describe('longFromValue', function () { 29 | it('creates a Long from a number', function () { 30 | assert.deepEqual(longFromValue(123, true), L(123)) 31 | }) 32 | 33 | it('creates a Long from a string', function () { 34 | assert.deepEqual(longFromValue('123', true), L(123)) 35 | }) 36 | 37 | it('creates a Long from a Long', function () { 38 | assert.deepEqual(longFromValue(L(123), true), L(123)) 39 | }) 40 | 41 | it('throws when creating an unsigned Long from a negative number', function () { 42 | assert.throws( 43 | () => longFromValue(-123, true), 44 | /Expected positive number/ 45 | ) 46 | }) 47 | 48 | it('throws when creating an unsigned Long from a negative string', function () { 49 | assert.throws( 50 | () => longFromValue('-123', true), 51 | /Expected positive number/ 52 | ) 53 | }) 54 | 55 | it('throws when creating a Long from a too-large string', function () { 56 | assert.throws( 57 | () => longFromValue('18446744073709551616', true), 58 | /Value 18446744073709551616 does not fit in a Long\./ 59 | ) 60 | }) 61 | }) 62 | 63 | describe('minLong', function () { 64 | it('returns the smaller value', function () { 65 | assert.deepEqual(minLong(L(1), L(2)), L(1)) 66 | assert.deepEqual(minLong(L(2), L(1)), L(1)) 67 | }) 68 | }) 69 | 70 | describe('maxLong', function () { 71 | it('returns the larget value', function () { 72 | assert.deepEqual(maxLong(L(1), L(2)), L(2)) 73 | assert.deepEqual(maxLong(L(2), L(1)), L(2)) 74 | }) 75 | }) 76 | 77 | describe('minLongs', function () { 78 | it('returns the smallest value', function () { 79 | assert.deepEqual(minLongs([L(2)]), L(2)) 80 | assert.deepEqual(minLongs([L(2), L(3)]), L(2)) 81 | assert.deepEqual(minLongs([L(2), L(3), L(1)]), L(1)) 82 | }) 83 | }) 84 | 85 | describe('countDigits', function () { 86 | it('returns the number of digits', function () { 87 | assert.equal(countDigits(L(0)), 0) 88 | assert.equal(countDigits(L(1)), 1) 89 | assert.equal(countDigits(L(12)), 2) 90 | assert.equal(countDigits(L(99999999)), 8) 91 | assert.equal(countDigits(L(100000000)), 9) 92 | }) 93 | }) 94 | 95 | describe('checkedAdd', function () { 96 | it('returns the sum and whether a+b overflows', function () { 97 | assert.deepEqual( 98 | checkedAdd(L(123), L(456)), 99 | { 100 | sum: L(123+456), 101 | overflow: false 102 | } 103 | ) 104 | assert.deepEqual( 105 | checkedAdd(Long.MAX_UNSIGNED_VALUE, L(2)), 106 | { 107 | sum: Long.MAX_UNSIGNED_VALUE, 108 | overflow: true 109 | } 110 | ) 111 | }) 112 | }) 113 | 114 | describe('checkedSubtract', function () { 115 | it('returns the difference and whether a-b underflows', function () { 116 | assert.deepEqual( 117 | checkedSubtract(L(2), L(1)), 118 | { 119 | difference: L(1), 120 | underflow: false 121 | } 122 | ) 123 | assert.deepEqual( 124 | checkedSubtract(L(1), L(2)), 125 | { 126 | difference: L(0), 127 | underflow: true 128 | } 129 | ) 130 | }) 131 | }) 132 | 133 | describe('checkedMultiply', function () { 134 | it('returns the product and whether a*b overflows', function () { 135 | assert.deepEqual( 136 | checkedMultiply(L(2), L(3)), 137 | { 138 | product: L(6), 139 | overflow: false 140 | } 141 | ) 142 | assert.deepEqual( 143 | checkedMultiply(Long.MAX_UNSIGNED_VALUE, L(2)), 144 | { 145 | product: Long.MAX_UNSIGNED_VALUE, 146 | overflow: true 147 | } 148 | ) 149 | }) 150 | }) 151 | 152 | describe('multiplyDivideFloor', function () { 153 | it('is equivalent to a*b/c', function () { 154 | for (let i = 0; i < REPS; i++) { 155 | const a = Math.floor(Math.random() * 1.0e8) 156 | const b = Math.floor(Math.random() * 1.0e8) 157 | const c = Math.floor(Math.random() * 1.0e8) 158 | 159 | const expect = new BigNumber(a) 160 | .times(b) 161 | .div(c) 162 | .integerValue(BigNumber.ROUND_FLOOR) 163 | const result = multiplyDivideFloor(L(a), L(b), L(c)) 164 | 165 | assert.equal( 166 | result.toString(), expect.toString(), 167 | `attempt ${i}: ${result} != ${expect} = (${a}*${b}/${c})` 168 | ) 169 | } 170 | }) 171 | 172 | it('returns 0 when a=0', function () { 173 | assert( 174 | multiplyDivideFloor(L(0), L(123), L(123)) 175 | .equals(Long.UZERO) 176 | ) 177 | }) 178 | 179 | it('returns 0 when b=0', function () { 180 | assert( 181 | multiplyDivideFloor(L(123), L(0), L(123)) 182 | .equals(Long.UZERO) 183 | ) 184 | }) 185 | 186 | it('returns Long.MAX_UNSIGNED_VALUE the result overflows', function () { 187 | assert( 188 | multiplyDivideFloor(Long.MAX_UNSIGNED_VALUE.divide(2), L(3), L(1)) 189 | .equals(Long.MAX_UNSIGNED_VALUE) 190 | ) 191 | }) 192 | }) 193 | 194 | describe('multiplyDivideCeil', function () { 195 | it('rounds up', function () { 196 | assert( 197 | multiplyDivideCeil(L(2), L(3), L(100)) 198 | .equals(L(1)) 199 | ) 200 | assert( 201 | multiplyDivideCeil(L(3), L(5), L(4)) 202 | .equals(L(4)) 203 | ) 204 | }) 205 | 206 | it('returns 0 when a or b is 0', function () { 207 | assert( 208 | multiplyDivideCeil(L(0), L(123), L(123)) 209 | .equals(Long.UZERO) 210 | ) 211 | assert( 212 | multiplyDivideCeil(L(123), L(0), L(123)) 213 | .equals(Long.UZERO) 214 | ) 215 | assert( 216 | multiplyDivideCeil(L(0), L(0), L(123)) 217 | .equals(Long.UZERO) 218 | ) 219 | }) 220 | 221 | it('returns Long.MAX_UNSIGNED_VALUE if the result overflows', function () { 222 | assert( 223 | multiplyDivideCeil(Long.MAX_UNSIGNED_VALUE.divide(2), L(3), L(1)) 224 | .equals(Long.MAX_UNSIGNED_VALUE) 225 | ) 226 | }) 227 | }) 228 | 229 | describe('multiplyDivideRound', function () { 230 | it('returns the rounded result', function () { 231 | // Integer result (no round). 232 | assert.deepEqual( 233 | multiplyDivideRound(L(3), L(4), L(6)), 234 | L(2) 235 | ) 236 | // Round down: 3*5/7 = 2.1428… ≅ 2 237 | assert.deepEqual( 238 | multiplyDivideRound(L(3), L(5), L(7)), 239 | L(2) 240 | ) 241 | // Round up: 3*5/6 = 2.5 ≅ 3 242 | assert.deepEqual( 243 | multiplyDivideRound(L(3), L(5), L(6)), 244 | L(3) 245 | ) 246 | // Round down (odd denominator): 2*5/3 = 3.3333… ≅ 3. 247 | assert.deepEqual( 248 | multiplyDivideRound(L(2), L(5), L(3)), 249 | L(3) 250 | ) 251 | // Multiply by 0. 252 | assert.deepEqual( 253 | multiplyDivideRound(L(0), L(10000000000000000000), L(1)), 254 | L(0) 255 | ) 256 | }) 257 | 258 | it('is equivalent to round(a*b/c)', function () { 259 | for (let i = 0; i < REPS; i++) { 260 | const a = Math.floor(Math.random() * 100000000) 261 | const b = Math.floor(Math.random() * 100000000) 262 | const c = Math.floor(Math.random() * 100000000) 263 | 264 | const expect = new BigNumber(a).times(b).div(c) 265 | .integerValue(BigNumber.ROUND_HALF_UP) 266 | const result = multiplyDivideRound(L(a), L(b), L(c)) 267 | 268 | assert.equal( 269 | result.toString(), expect.toString(), 270 | `attempt ${i}: ${result} != ${expect} = (${a}*${b}/${c})` 271 | ) 272 | } 273 | }) 274 | }) 275 | 276 | describe('multiplyDivide', function () { 277 | it('returns a quotient and remainder', function () { 278 | assert.deepEqual( 279 | multiplyDivide(L(3), L(5), L(12)), 280 | { quo: L(1), rem: L(3) } 281 | ) 282 | }) 283 | }) 284 | }) 285 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import * as IlpPacket from 'ilp-packet' 3 | import * as ILDCP from 'ilp-protocol-ildcp' 4 | import createLogger from 'ilp-logger' 5 | import * as cryptoHelper from './crypto' 6 | import { Connection, ConnectionOpts } from './connection' 7 | import { ServerConnectionPool } from './pool' 8 | import { Predictor, Writer } from 'oer-utils' 9 | import { Plugin } from './util/plugin-interface' 10 | 11 | const DEFAULT_DISCONNECT_DELAY = 100 12 | 13 | export interface ServerOpts extends ConnectionOpts { 14 | serverSecret?: Buffer 15 | 16 | /** 17 | * Number of milliseconds to wait between closing the server and disconnecting 18 | * the plugin so packets may be safely returned 19 | */ 20 | disconnectDelay?: number 21 | } 22 | 23 | export interface GenerateAddressSecretOpts { 24 | connectionTag?: string, 25 | receiptNonce?: Buffer, 26 | receiptSecret?: Buffer 27 | } 28 | 29 | /** 30 | * STREAM Server that can listen on an account and handle multiple incoming [`Connection`s]{@link Connection}. 31 | * Note: the connections this refers to are over ILP, not over the Internet. 32 | * 33 | * The Server operator should give a unique address and secret (generated by calling 34 | * [`generateAddressAndSecret`]{@link generateAddressAndSecret}) to each client that it expects to connect. 35 | * 36 | * The Server will emit a `'connection'` event when the first packet is received for a specific Connection. 37 | */ 38 | export class Server extends EventEmitter { 39 | protected serverSecret: Buffer 40 | protected plugin: Plugin 41 | protected serverAccount: string 42 | protected serverAssetCode: string 43 | protected serverAssetScale: number 44 | protected log: any 45 | protected enablePadding?: boolean 46 | protected connected: boolean 47 | protected connectionOpts: ConnectionOpts 48 | protected pendingRequests: Promise = Promise.resolve() 49 | protected disconnectDelay: number 50 | private pool: ServerConnectionPool 51 | 52 | constructor (opts: ServerOpts) { 53 | super() 54 | this.serverSecret = opts.serverSecret || cryptoHelper.randomBytes(32) 55 | this.plugin = opts.plugin 56 | this.log = createLogger('ilp-protocol-stream:Server') 57 | this.connectionOpts = Object.assign({}, opts, { 58 | serverSecret: undefined 59 | }) as ConnectionOpts 60 | this.disconnectDelay = opts.disconnectDelay || DEFAULT_DISCONNECT_DELAY 61 | this.connected = false 62 | } 63 | 64 | /** 65 | * Event fired when a new [`Connection`]{@link Connection} is received. 66 | * The connection event handler should immediately (synchronously) add a 67 | * `"stream"` event handler to ensure no incoming streams are ignored. 68 | * @event connection 69 | * @type {Connection} 70 | */ 71 | 72 | /** 73 | * Connect the plugin and start listening for incoming connections. 74 | * 75 | * When a new connection is accepted, the server will emit the "connection" event. 76 | * 77 | * @fires connection 78 | */ 79 | async listen (): Promise { 80 | if (this.connected && this.plugin.isConnected()) { 81 | return 82 | } 83 | this.plugin.registerDataHandler(data => { 84 | this.emit('_incoming_prepare') 85 | const request = this.handleData(data) 86 | this.pendingRequests = this.pendingRequests.then(() => request.finally()) 87 | return request 88 | }) 89 | await this.plugin.connect() 90 | const { clientAddress, assetCode, assetScale } = await ILDCP.fetch(this.plugin.sendData.bind(this.plugin)) 91 | this.serverAccount = clientAddress 92 | this.serverAssetCode = assetCode 93 | this.serverAssetScale = assetScale 94 | this.connected = true 95 | this.pool = new ServerConnectionPool(this.serverSecret, { 96 | ...this.connectionOpts, 97 | isServer: true, 98 | plugin: this.plugin, 99 | sourceAccount: this.serverAccount, 100 | assetCode: this.serverAssetCode, 101 | assetScale: this.serverAssetScale 102 | }, (connection: Connection) => { 103 | this.emit('connection', connection) 104 | }) 105 | } 106 | 107 | /** 108 | * End all connections and disconnect the plugin 109 | */ 110 | async close (): Promise { 111 | // Stop handling new requests, and return T99 while the connection is closing. 112 | // If an F02 unreachable was returned on new packets: clients would immediately destroy the connection 113 | // If an F99 was returned on on new packets: clients would retry with no backoff 114 | this.plugin.deregisterDataHandler() 115 | this.plugin.registerDataHandler(async () => IlpPacket.serializeIlpReject({ 116 | code: IlpPacket.Errors.codes.T99_APPLICATION_ERROR, 117 | triggeredBy: this.serverAccount, 118 | message: 'Shutting down server', 119 | data: Buffer.alloc(0) 120 | })) 121 | 122 | // Wait for in-progress requests to finish so all Fulfills are returned 123 | await this.pendingRequests 124 | // Allow the plugin time to send the reply packets back before disconnecting it 125 | await new Promise(r => setTimeout(r, this.disconnectDelay)) 126 | 127 | // Gracefully close the connection and all streams 128 | await this.pool.close() 129 | 130 | this.plugin.deregisterDataHandler() 131 | await this.plugin.disconnect() 132 | 133 | this.emit('_close') 134 | this.connected = false 135 | } 136 | 137 | /** 138 | * Resolves when the next connection is accepted. 139 | * 140 | * To handle subsequent connections, the user must call `acceptConnection` again. 141 | * Alternatively, the user can listen on the `'connection'` event. 142 | */ 143 | async acceptConnection (): Promise { 144 | await this.listen() 145 | /* tslint:disable-next-line:no-unnecessary-type-assertion */ 146 | return new Promise((resolve, reject) => { 147 | const done = (connection: Connection | undefined) => { 148 | this.removeListener('connection', done) 149 | this.removeListener('_close', done) 150 | if (connection) resolve(connection) 151 | else reject(new Error('server closed')) 152 | } 153 | this.once('connection', done) 154 | this.once('_close', done) 155 | }) as Promise 156 | } 157 | 158 | /** 159 | * Generate an address and secret for a specific client to enable them to create a connection to the server. 160 | * 161 | * Two different clients SHOULD NOT be given the same address and secret. 162 | * 163 | * @param connectionTag Optional connection identifier that will be appended to the ILP address and can be used to identify incoming connections. Can only include characters that can go into an ILP Address 164 | * @param receiptNonce Optional nonce to include in STREAM receipts 165 | * @param receiptSecret Optional secret to use for signing STREAM receipts 166 | */ 167 | generateAddressAndSecret (opts?: string | GenerateAddressSecretOpts): { destinationAccount: string, sharedSecret: Buffer, receiptsEnabled: boolean } { 168 | if (!this.connected) { 169 | throw new Error('Server must be connected to generate address and secret') 170 | } 171 | let connectionTag = Buffer.alloc(0) 172 | let receiptNonce = Buffer.alloc(0) 173 | let receiptSecret = Buffer.alloc(0) 174 | let receiptsEnabled = false 175 | if (opts) { 176 | if (typeof opts === 'object') { 177 | if (opts.connectionTag) { 178 | connectionTag = Buffer.from(opts.connectionTag, 'ascii') 179 | } 180 | if (!opts.receiptNonce !== !opts.receiptSecret) { 181 | throw new Error('receiptNonce and receiptSecret must accompany each other') 182 | } 183 | if (opts.receiptNonce) { 184 | if (opts.receiptNonce.length !== 16) { 185 | throw new Error('receiptNonce must be 16 bytes') 186 | } 187 | receiptsEnabled = true 188 | receiptNonce = opts.receiptNonce 189 | } 190 | if (opts.receiptSecret) { 191 | if (opts.receiptSecret.length !== 32) { 192 | throw new Error('receiptSecret must be 32 bytes') 193 | } 194 | receiptSecret = opts.receiptSecret 195 | } 196 | } else { 197 | connectionTag = Buffer.from(opts, 'ascii') 198 | } 199 | } 200 | const tokenNonce = cryptoHelper.generateTokenNonce() 201 | const predictor = new Predictor() 202 | predictor.writeOctetString(tokenNonce, cryptoHelper.TOKEN_NONCE_LENGTH) 203 | predictor.writeVarOctetString(connectionTag) 204 | predictor.writeVarOctetString(receiptNonce) 205 | predictor.writeVarOctetString(receiptSecret) 206 | const writer = new Writer(predictor.length) 207 | writer.writeOctetString(tokenNonce, cryptoHelper.TOKEN_NONCE_LENGTH) 208 | writer.writeVarOctetString(connectionTag) 209 | writer.writeVarOctetString(receiptNonce) 210 | writer.writeVarOctetString(receiptSecret) 211 | 212 | const token = cryptoHelper.encryptConnectionAddressToken(this.serverSecret, writer.getBuffer()) 213 | const sharedSecret = cryptoHelper.generateSharedSecretFromToken(this.serverSecret, token) 214 | return { 215 | // TODO should this be called serverAccount or serverAddress instead? 216 | destinationAccount: `${this.serverAccount}.${base64url(token)}`, 217 | sharedSecret, 218 | receiptsEnabled 219 | } 220 | } 221 | 222 | get assetCode (): string { 223 | if (!this.connected) { 224 | throw new Error('Server must be connected to get asset code.') 225 | } 226 | return this.serverAssetCode 227 | } 228 | 229 | get assetScale (): number { 230 | if (!this.connected) { 231 | throw new Error('Server must be connected to get asset scale.') 232 | } 233 | return this.serverAssetScale 234 | } 235 | 236 | /** 237 | * Parse incoming ILP Prepare packets and pass them to the correct connection 238 | */ 239 | protected async handleData (data: Buffer): Promise { 240 | try { 241 | let prepare: IlpPacket.IlpPrepare 242 | try { 243 | prepare = IlpPacket.deserializeIlpPrepare(data) 244 | } catch (err) { 245 | this.log.error('got data that is not an ILP Prepare packet: %h', data) 246 | return IlpPacket.serializeIlpReject({ 247 | code: 'F00', 248 | message: `Expected an ILP Prepare packet (type 12), but got packet with type: ${data[0]}`, 249 | data: Buffer.alloc(0), 250 | triggeredBy: this.serverAccount 251 | }) 252 | } 253 | 254 | const localAddressParts = prepare.destination.replace(this.serverAccount + '.', '').split('.') 255 | if (localAddressParts.length === 0 || !localAddressParts[0]) { 256 | this.log.error('destination in ILP Prepare packet does not have a Connection ID: %s', prepare.destination) 257 | /* Why no error message here? 258 | We return an empty message here because we want to minimize the amount of information sent unencrypted 259 | that identifies this protocol and specific implementation for the rest of the network. For example, 260 | if every implementation returns a slightly different message here, you could determine what type of 261 | endpoint is listening on a particular ILP address just by changing the last character of the destination 262 | in a packet and seeing what error message you get back. 263 | Apologies if this caused additional debugging time for you! */ 264 | throw new IlpPacket.Errors.UnreachableError('') 265 | } 266 | const connectionId = localAddressParts[0] 267 | 268 | const connection = await this.pool.getConnection(connectionId, prepare) 269 | .catch((_err: Error) => { 270 | // See "Why no error message here?" note above 271 | throw new IlpPacket.Errors.UnreachableError('') 272 | }) 273 | const fulfill = await connection.handlePrepare(prepare) 274 | return IlpPacket.serializeIlpFulfill(fulfill) 275 | } catch (err) { 276 | if (!err.ilpErrorCode) { 277 | this.log.error('error handling prepare:', err) 278 | } 279 | // TODO should the default be F00 or T00? 280 | return IlpPacket.serializeIlpReject({ 281 | code: err.ilpErrorCode || 'F00', 282 | message: err.ilpErrorMessage || '', 283 | data: err.ilpErrorData || Buffer.alloc(0), 284 | triggeredBy: this.serverAccount || '' 285 | }) 286 | } 287 | } 288 | } 289 | 290 | /** 291 | * Creates a [`Server`]{@link Server} and resolves when the server is connected and listening 292 | */ 293 | export async function createServer (opts: ServerOpts): Promise { 294 | const server = new Server(opts) 295 | await server.listen() 296 | return server 297 | } 298 | 299 | function base64url (buffer: Buffer) { 300 | return buffer.toString('base64') 301 | .replace(/=+$/, '') 302 | .replace(/\+/g, '-') 303 | .replace(/\//g, '_') 304 | } 305 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import { Connection } from '../src/connection' 3 | import { DataAndMoneyStream } from '../src/stream' 4 | import { createConnection, Server, createServer, GenerateAddressSecretOpts } from '../src/index' 5 | import MockPlugin from './mocks/plugin' 6 | import * as sinon from 'sinon' 7 | import * as Chai from 'chai' 8 | import * as chaiAsPromised from 'chai-as-promised' 9 | Chai.use(chaiAsPromised) 10 | const assert = Object.assign(Chai.assert, sinon.assert) 11 | 12 | describe('Server', function () { 13 | beforeEach(function () { 14 | this.clientPlugin = new MockPlugin(0.5) 15 | this.serverPlugin = this.clientPlugin.mirror 16 | }) 17 | 18 | describe('constructor', function () { 19 | it('should generate a random serverSecret if one is not supplied', function () { 20 | const server = new Server({ 21 | plugin: this.serverPlugin 22 | }) 23 | assert(Buffer.isBuffer(server['serverSecret'])) 24 | assert.lengthOf(server['serverSecret'], 32) 25 | }) 26 | }) 27 | 28 | describe('acceptConnection', function () { 29 | beforeEach(async function () { 30 | this.server = new Server({ 31 | serverSecret: Buffer.alloc(32), 32 | plugin: this.serverPlugin 33 | }) 34 | }) 35 | 36 | it('rejects when the server closes', async function () { 37 | process.nextTick(() => { this.server.close() }) 38 | await assert.isRejected(this.server.acceptConnection()) 39 | }) 40 | }) 41 | 42 | describe('generateAddressAndSecret', function () { 43 | beforeEach(async function () { 44 | this.server = new Server({ 45 | serverSecret: Buffer.alloc(32), 46 | plugin: this.serverPlugin 47 | }) 48 | }) 49 | 50 | it('should throw an error if the server is not connected', async function () { 51 | const server = new Server({ 52 | serverSecret: Buffer.alloc(32), 53 | plugin: this.serverPlugin 54 | }) 55 | 56 | assert.throws(() => server.generateAddressAndSecret(), 'Server must be connected to generate address and secret') 57 | }) 58 | 59 | it('should return a destinationAccount and sharedSecret', async function () { 60 | await this.server.listen() 61 | 62 | const result = this.server.generateAddressAndSecret() 63 | assert(Buffer.isBuffer(result.sharedSecret)) 64 | assert.lengthOf(result.sharedSecret, 32) 65 | assert.typeOf(result.destinationAccount, 'string') 66 | }) 67 | 68 | it('should return whether or not the connection will have receipts', async function () { 69 | await this.server.listen() 70 | 71 | let result = this.server.generateAddressAndSecret() 72 | assert.typeOf(result.receiptsEnabled, 'boolean') 73 | assert.equal(result.receiptsEnabled, false) 74 | 75 | const receiptNonce = Buffer.alloc(16) 76 | const receiptSecret = Buffer.alloc(32) 77 | result = this.server.generateAddressAndSecret({ receiptNonce, receiptSecret }) 78 | assert.typeOf(result.receiptsEnabled, 'boolean') 79 | assert.equal(result.receiptsEnabled, true) 80 | }) 81 | 82 | it('should accept connections created without options', async function () { 83 | await this.server.listen() 84 | const { destinationAccount, sharedSecret } = this.server.generateAddressAndSecret() 85 | const connectionPromise = this.server.acceptConnection() 86 | 87 | const clientConn = await createConnection({ 88 | plugin: this.clientPlugin, 89 | destinationAccount, 90 | sharedSecret 91 | }) 92 | 93 | const connection = await connectionPromise 94 | }) 95 | 96 | it('should accept connections created with empty options', async function () { 97 | await this.server.listen() 98 | const opts: GenerateAddressSecretOpts = {} 99 | const { destinationAccount, sharedSecret } = this.server.generateAddressAndSecret(opts) 100 | const connectionPromise = this.server.acceptConnection() 101 | 102 | const clientConn = await createConnection({ 103 | plugin: this.clientPlugin, 104 | destinationAccount, 105 | sharedSecret 106 | }) 107 | 108 | const connection = await connectionPromise 109 | }) 110 | 111 | it('should accept a connectionTag as a string and attach it to the incoming connection', async function () { 112 | await this.server.listen() 113 | const connectionTag = 'hello-there_123' 114 | const { destinationAccount, sharedSecret } = this.server.generateAddressAndSecret(connectionTag) 115 | const connectionPromise = this.server.acceptConnection() 116 | 117 | const clientConn = await createConnection({ 118 | plugin: this.clientPlugin, 119 | destinationAccount, 120 | sharedSecret 121 | }) 122 | 123 | const connection = await connectionPromise 124 | assert.equal(connection.connectionTag, connectionTag) 125 | }) 126 | 127 | it('should accept a connectionTag and attach it to the incoming connection', async function () { 128 | await this.server.listen() 129 | const connectionTag = 'hello-there_123' 130 | const { destinationAccount, sharedSecret } = this.server.generateAddressAndSecret({ connectionTag }) 131 | const connectionPromise = this.server.acceptConnection() 132 | 133 | const clientConn = await createConnection({ 134 | plugin: this.clientPlugin, 135 | destinationAccount, 136 | sharedSecret 137 | }) 138 | 139 | const connection = await connectionPromise 140 | assert.equal(connection.connectionTag, connectionTag) 141 | }) 142 | 143 | it('should reject the connection if the connectionTag is modified', async function () { 144 | await this.server.listen() 145 | const connectionName = 'hello-there_123' 146 | const { destinationAccount, sharedSecret } = this.server.generateAddressAndSecret(connectionName) 147 | 148 | const spy = sinon.spy() 149 | this.server.on('connection', spy) 150 | 151 | const realSendData = this.clientPlugin.sendData.bind(this.clientPlugin) 152 | const responses: Buffer[] = [] 153 | this.clientPlugin.sendData = async (data: Buffer): Promise => { 154 | const response = await realSendData(data) 155 | responses.push(response) 156 | return response 157 | } 158 | 159 | await assert.isRejected(createConnection({ 160 | plugin: this.clientPlugin, 161 | destinationAccount: destinationAccount + '456', 162 | sharedSecret 163 | }), 'Error connecting: Unable to establish connection, no packets meeting the minimum exchange precision of 3 digits made it through the path.') 164 | 165 | assert.notCalled(spy) 166 | }) 167 | 168 | it('should accept receipt nonce and secret with or without connectionTag', async function () { 169 | await this.server.listen() 170 | const connectionTag = 'hello-there_123' 171 | const receiptNonce = Buffer.alloc(16) 172 | const receiptSecret = Buffer.alloc(32) 173 | this.server.generateAddressAndSecret({ connectionTag, receiptNonce, receiptSecret }) 174 | this.server.generateAddressAndSecret({ receiptNonce, receiptSecret }) 175 | }) 176 | 177 | it('should require receipt nonce and secret together', async function () { 178 | await this.server.listen() 179 | const connectionTag = 'hello-there_123' 180 | const receiptNonce = Buffer.from('nonce_123', 'ascii') 181 | const receiptSecret = Buffer.from('secret_123', 'ascii') 182 | assert.throws(() => this.server.generateAddressAndSecret({ receiptNonce }), 'receiptNonce and receiptSecret must accompany each other') 183 | assert.throws(() => this.server.generateAddressAndSecret({ receiptSecret }), 'receiptNonce and receiptSecret must accompany each other') 184 | assert.throws(() => this.server.generateAddressAndSecret({ connectionTag, receiptNonce }), 'receiptNonce and receiptSecret must accompany each other') 185 | assert.throws(() => this.server.generateAddressAndSecret({ connectionTag, receiptSecret }), 'receiptNonce and receiptSecret must accompany each other') 186 | }) 187 | 188 | it('should require 16 byte receipt nonce', async function () { 189 | await this.server.listen() 190 | const receiptNonce = Buffer.alloc(15) 191 | const receiptSecret = Buffer.alloc(32) 192 | assert.throws(() => this.server.generateAddressAndSecret({ receiptNonce, receiptSecret }), 'receiptNonce must be 16 bytes') 193 | }) 194 | 195 | it('should require 32 byte receipt secret', async function () { 196 | await this.server.listen() 197 | const receiptNonce = Buffer.alloc(16) 198 | const receiptSecret = Buffer.alloc(31) 199 | assert.throws(() => this.server.generateAddressAndSecret({ receiptNonce, receiptSecret }), 'receiptSecret must be 32 bytes') 200 | }) 201 | }) 202 | 203 | describe('close', function () { 204 | beforeEach(async function () { 205 | this.server = new Server({ 206 | serverSecret: Buffer.alloc(32), 207 | plugin: this.serverPlugin, 208 | async shouldFulfill() { 209 | await new Promise(r => setTimeout(r, 1000)) 210 | } 211 | }) 212 | await this.server.listen() 213 | }) 214 | 215 | it('safely closes all connections', async function () { 216 | const serverPromise = this.server.acceptConnection() 217 | 218 | const clientConn = await createConnection({ 219 | ...this.server.generateAddressAndSecret('hello'), 220 | plugin: this.clientPlugin 221 | }) 222 | const clientStream = clientConn.createStream() 223 | 224 | const serverEndSpy = sinon.spy() 225 | const serverConn: Connection = await serverPromise 226 | let serverStream: DataAndMoneyStream 227 | serverConn.once('stream', (stream: DataAndMoneyStream) => { 228 | serverStream = stream 229 | serverStream.setReceiveMax(100) 230 | }) 231 | serverConn.on('end', serverEndSpy) 232 | 233 | // Wait for initial connection establishment to finish before sending any money 234 | await new Promise(r => clientStream.once('_send_loop_finished', r)) 235 | await new Promise(r => serverStream.once('_send_loop_finished', r)) 236 | 237 | // After it receives the next packet, immediately try to close the server 238 | let closePromise: Promise 239 | const pluginDisconnectSpy = sinon.spy(this.serverPlugin, 'disconnect') 240 | this.server.once('_incoming_prepare', () => { 241 | process.nextTick(() => { 242 | closePromise = this.server.close() 243 | }) 244 | }) 245 | 246 | // First payment should succeed because the packet 247 | // was accepted before the server closed 248 | await assert.isFulfilled(clientStream.sendTotal(100)) 249 | assert.equal('50', serverConn.totalReceived) 250 | assert.equal('50', clientConn.totalDelivered) 251 | 252 | // Second payment should fail because the server already closed 253 | await assert.isRejected(clientStream.sendTotal(150)) 254 | assert.equal('50', serverConn.totalReceived) 255 | assert.equal('50', clientConn.totalDelivered) 256 | 257 | // @ts-ignore 258 | await closePromise 259 | 260 | assert.calledOnce(serverEndSpy) 261 | assert.calledOnce(pluginDisconnectSpy) 262 | }) 263 | }) 264 | 265 | describe('"connection" event', function () { 266 | beforeEach(async function () { 267 | this.server = new Server({ 268 | serverSecret: Buffer.alloc(32), 269 | plugin: this.serverPlugin 270 | }) 271 | await this.server.listen() 272 | }) 273 | 274 | it('should not reject the packet if there is an error in the connection event handler', async function () { 275 | this.server.on('connection', () => { 276 | throw new Error('blah') 277 | }) 278 | 279 | await createConnection({ 280 | ...this.server.generateAddressAndSecret(), 281 | plugin: this.clientPlugin 282 | }) 283 | }) 284 | }) 285 | 286 | describe('Closed Connections', function () { 287 | beforeEach(async function () { 288 | this.server = new Server({ 289 | serverSecret: Buffer.alloc(32), 290 | plugin: this.serverPlugin 291 | }) 292 | await this.server.listen() 293 | 294 | const { destinationAccount, sharedSecret } = this.server.generateAddressAndSecret() 295 | this.destinationAccount = destinationAccount 296 | this.sharedSecret = sharedSecret 297 | 298 | const serverConnPromise = this.server.acceptConnection() 299 | this.clientConn = await createConnection({ 300 | plugin: this.clientPlugin, 301 | destinationAccount, 302 | sharedSecret 303 | }) 304 | this.serverConn = await serverConnPromise 305 | }) 306 | 307 | it('should create a new connection if client sends more packets after connection is closed', async function () { 308 | await this.serverConn.destroy() 309 | 310 | await assert.isFulfilled(createConnection({ 311 | plugin: this.clientPlugin, 312 | sharedSecret: this.sharedSecret, 313 | destinationAccount: this.destinationAccount 314 | }), 'Error connecting: Unable to establish connection, no packets meeting the minimum exchange precision of 3 digits made it through the path.') 315 | }) 316 | 317 | it('should remove the record of closed connections', async function () { 318 | assert.equal(Object.keys(this.server['pool']['activeConnections']).length, 1) 319 | await this.serverConn.destroy() 320 | assert.equal(Object.keys(this.server['pool']['activeConnections']).length, 0) 321 | }) 322 | }) 323 | }) 324 | 325 | describe('createServer', function () { 326 | beforeEach(function () { 327 | this.clientPlugin = new MockPlugin(0.5) 328 | this.serverPlugin = this.clientPlugin.mirror 329 | }) 330 | 331 | it('should return a server that is listening', async function () { 332 | const spy = sinon.spy(this.serverPlugin, 'connect') 333 | const server = await createServer({ 334 | serverSecret: Buffer.alloc(32), 335 | plugin: this.serverPlugin 336 | }) 337 | assert.instanceOf(server, Server) 338 | assert.called(spy) 339 | }) 340 | 341 | it('should return a server with an assetCode and assetScale', async function () { 342 | const server = await createServer({ 343 | serverSecret: Buffer.alloc(32), 344 | plugin: this.serverPlugin 345 | }) 346 | assert.equal(server.assetCode, 'XYZ') 347 | assert.equal(server.assetScale, 9) 348 | }) 349 | }) 350 | -------------------------------------------------------------------------------- /src/packet.ts: -------------------------------------------------------------------------------- 1 | import { Reader, Writer, WriterInterface, Predictor } from 'oer-utils' 2 | import * as IlpPacket from 'ilp-packet' 3 | import * as Long from 'long' 4 | import { encrypt, decrypt, ENCRYPTION_OVERHEAD } from './crypto' 5 | import { LongValue, longFromValue } from './util/long' 6 | 7 | const VERSION = Long.fromNumber(1, true) 8 | 9 | const ZERO_BYTES = Buffer.alloc(32) 10 | 11 | export const IlpPacketType = { 12 | Prepare: IlpPacket.Type.TYPE_ILP_PREPARE, 13 | Fulfill: IlpPacket.Type.TYPE_ILP_FULFILL, 14 | Reject: IlpPacket.Type.TYPE_ILP_REJECT 15 | } 16 | 17 | /** 18 | * STREAM Protocol Error Codes 19 | */ 20 | export enum ErrorCode { 21 | NoError = 0x01, 22 | InternalError = 0x02, 23 | EndpointBusy = 0x03, 24 | FlowControlError = 0x04, 25 | StreamIdError = 0x05, 26 | StreamStateError = 0x06, 27 | FrameFormatError = 0x07, 28 | ProtocolViolation = 0x08, 29 | ApplicationError = 0x09 30 | } 31 | 32 | /** 33 | * STREAM Protocol Frame Identifiers 34 | */ 35 | export enum FrameType { 36 | ConnectionClose = 0x01, 37 | ConnectionNewAddress = 0x02, 38 | ConnectionMaxData = 0x03, 39 | ConnectionDataBlocked = 0x04, 40 | ConnectionMaxStreamId = 0x05, 41 | ConnectionStreamIdBlocked = 0x06, 42 | ConnectionAssetDetails = 0x07, 43 | 44 | StreamClose = 0x10, 45 | StreamMoney = 0x11, 46 | StreamMaxMoney = 0x12, 47 | StreamMoneyBlocked = 0x13, 48 | StreamData = 0x14, 49 | StreamMaxData = 0x15, 50 | StreamDataBlocked = 0x16, 51 | StreamReceipt = 0x17 52 | } 53 | 54 | /** 55 | * All of the frames included in the STREAM protocol 56 | */ 57 | export type Frame = 58 | ConnectionCloseFrame 59 | | ConnectionNewAddressFrame 60 | | ConnectionAssetDetailsFrame 61 | | ConnectionMaxDataFrame 62 | | ConnectionDataBlockedFrame 63 | | ConnectionMaxStreamIdFrame 64 | | ConnectionStreamIdBlockedFrame 65 | | StreamCloseFrame 66 | | StreamMoneyFrame 67 | | StreamMaxMoneyFrame 68 | | StreamMoneyBlockedFrame 69 | | StreamDataFrame 70 | | StreamMaxDataFrame 71 | | StreamDataBlockedFrame 72 | | StreamReceiptFrame 73 | 74 | /** 75 | * STREAM Protocol Packet 76 | * 77 | * Each packet is comprised of a header and zero or more Frames. 78 | * Packets are serialized, encrypted, and sent as the data field in ILP Packets. 79 | */ 80 | export class Packet { 81 | sequence: Long 82 | ilpPacketType: IlpPacket.Type 83 | prepareAmount: Long 84 | frames: Frame[] 85 | 86 | constructor ( 87 | sequence: LongValue, 88 | ilpPacketType: IlpPacket.Type, 89 | packetAmount: LongValue = Long.UZERO, 90 | frames: Frame[] = [] 91 | ) { 92 | this.sequence = longFromValue(sequence, true) 93 | this.ilpPacketType = ilpPacketType 94 | this.prepareAmount = longFromValue(packetAmount, true) 95 | this.frames = frames 96 | } 97 | 98 | static async decryptAndDeserialize (pskEncryptionKey: Buffer, buffer: Buffer): Promise { 99 | let decrypted: Buffer 100 | try { 101 | decrypted = await decrypt(pskEncryptionKey, buffer) 102 | } catch (err) { 103 | throw new Error(`Unable to decrypt packet. Data was corrupted or packet was encrypted with the wrong key`) 104 | } 105 | return Packet._deserializeUnencrypted(decrypted) 106 | } 107 | 108 | /** @private */ 109 | static _deserializeUnencrypted (buffer: Buffer): Packet { 110 | const reader = Reader.from(buffer) 111 | const version = reader.readUInt8Long() 112 | if (!version.equals(VERSION)) { 113 | throw new Error(`Unsupported protocol version: ${version}`) 114 | } 115 | const ilpPacketType = reader.readUInt8Number() 116 | const sequence = reader.readVarUIntLong() 117 | const packetAmount = reader.readVarUIntLong() 118 | const numFrames = reader.readVarUIntNumber() 119 | const frames: Frame[] = [] 120 | 121 | for (let i = 0; i < numFrames; i++) { 122 | const frame = parseFrame(reader) 123 | if (frame) { 124 | frames.push(frame) 125 | } 126 | } 127 | return new Packet(sequence, ilpPacketType, packetAmount, frames) 128 | } 129 | 130 | serializeAndEncrypt (pskEncryptionKey: Buffer, padPacketToSize?: number): Promise { 131 | const serialized = this._serialize() 132 | 133 | // Pad packet to max data size, if desired 134 | if (padPacketToSize !== undefined) { 135 | const paddingSize = padPacketToSize - ENCRYPTION_OVERHEAD - serialized.length 136 | const args = [pskEncryptionKey, serialized] 137 | for (let i = 0; i < Math.floor(paddingSize / 32); i++) { 138 | args.push(ZERO_BYTES) 139 | } 140 | args.push(ZERO_BYTES.slice(0, paddingSize % 32)) 141 | return encrypt.apply(null, args) 142 | } 143 | 144 | return encrypt(pskEncryptionKey, serialized) 145 | } 146 | 147 | /** @private */ 148 | _serialize (): Buffer { 149 | const predictor = new Predictor() 150 | this.writeTo(predictor) 151 | const writer = new Writer(predictor.length) 152 | this.writeTo(writer) 153 | return writer.getBuffer() 154 | } 155 | 156 | writeTo (writer: WriterInterface): void { 157 | // Write the packet header 158 | writer.writeUInt8(VERSION) 159 | writer.writeUInt8(this.ilpPacketType) 160 | writer.writeVarUInt(this.sequence) 161 | writer.writeVarUInt(this.prepareAmount) 162 | 163 | // Write the number of frames (excluding padding) 164 | writer.writeVarUInt(this.frames.length) 165 | 166 | // Write each of the frames 167 | for (let frame of this.frames) { 168 | frame.writeTo(writer) 169 | } 170 | } 171 | 172 | byteLength (): number { 173 | const predictor = new Predictor() 174 | this.writeTo(predictor) 175 | return predictor.getSize() + ENCRYPTION_OVERHEAD 176 | } 177 | } 178 | 179 | /** 180 | * Base class that each Frame extends 181 | */ 182 | export abstract class BaseFrame { 183 | type: FrameType 184 | name: string 185 | 186 | constructor (name: keyof typeof FrameType) { 187 | this.type = FrameType[name] 188 | this.name = name 189 | } 190 | 191 | static fromContents (reader: Reader): BaseFrame { 192 | throw new Error(`class method "fromContents" is not implemented`) 193 | } 194 | 195 | writeTo (writer: T): T { 196 | const predictor = new Predictor() 197 | this.writeContentsTo(predictor) 198 | writer.writeUInt8(this.type) 199 | this.writeContentsTo(writer.createVarOctetString(predictor.length)) 200 | return writer 201 | } 202 | 203 | protected writeContentsTo (contents: WriterInterface) { 204 | const properties = Object.getOwnPropertyNames(this).filter((propName: string) => propName !== 'type' && propName !== 'name') 205 | for (let prop of properties) { 206 | const value = this[prop] 207 | if (typeof value === 'number') { 208 | contents.writeUInt8(value) 209 | } else if (typeof value === 'string') { 210 | contents.writeVarOctetString(Buffer.from(value, 'utf8')) 211 | } else if (Buffer.isBuffer(value)) { 212 | contents.writeVarOctetString(value) 213 | } else if (Long.isLong(value)) { 214 | contents.writeVarUInt(value) 215 | } else { 216 | throw new Error(`Unexpected property type for property "${prop}": ${typeof value}`) 217 | } 218 | } 219 | } 220 | 221 | byteLength (): number { 222 | const predictor = new Predictor() 223 | this.writeTo(predictor) 224 | return predictor.getSize() 225 | } 226 | } 227 | 228 | export class ConnectionCloseFrame extends BaseFrame { 229 | type: FrameType.ConnectionClose 230 | errorCode: ErrorCode 231 | errorMessage: string 232 | 233 | constructor (errorCode: ErrorCode, errorMessage: string) { 234 | super('ConnectionClose') 235 | this.errorCode = errorCode 236 | this.errorMessage = errorMessage 237 | } 238 | 239 | static fromContents (reader: Reader): ConnectionCloseFrame { 240 | const errorCode = reader.readUInt8Number() as ErrorCode 241 | const errorMessage = reader.readVarOctetString().toString() 242 | return new ConnectionCloseFrame(errorCode, errorMessage) 243 | } 244 | } 245 | 246 | export class ConnectionNewAddressFrame extends BaseFrame { 247 | type: FrameType.ConnectionNewAddress 248 | sourceAccount: string 249 | 250 | constructor (sourceAccount: string) { 251 | super('ConnectionNewAddress') 252 | this.sourceAccount = sourceAccount 253 | } 254 | 255 | static fromContents (reader: Reader): ConnectionNewAddressFrame { 256 | const sourceAccount = reader.readVarOctetString().toString('utf8') 257 | return new ConnectionNewAddressFrame(sourceAccount) 258 | } 259 | } 260 | 261 | export class ConnectionAssetDetailsFrame extends BaseFrame { 262 | type: FrameType.ConnectionAssetDetails 263 | sourceAssetCode: string 264 | sourceAssetScale: number 265 | 266 | constructor (sourceAssetCode: string, sourceAssetScale: number) { 267 | super('ConnectionAssetDetails') 268 | this.sourceAssetCode = sourceAssetCode 269 | this.sourceAssetScale = sourceAssetScale 270 | } 271 | 272 | static fromContents (reader: Reader): ConnectionAssetDetailsFrame { 273 | const sourceAssetCode = reader.readVarOctetString().toString('utf8') 274 | const sourceAssetScale = reader.readUInt8Number() 275 | return new ConnectionAssetDetailsFrame(sourceAssetCode, sourceAssetScale) 276 | } 277 | } 278 | 279 | export class ConnectionMaxDataFrame extends BaseFrame { 280 | type: FrameType.ConnectionMaxData 281 | maxOffset: Long 282 | 283 | constructor (maxOffset: LongValue) { 284 | super('ConnectionMaxData') 285 | this.maxOffset = longFromValue(maxOffset, true) 286 | } 287 | 288 | static fromContents (reader: Reader): ConnectionMaxDataFrame { 289 | const maxOffset = reader.readVarUIntLong() 290 | return new ConnectionMaxDataFrame(maxOffset) 291 | } 292 | } 293 | 294 | export class ConnectionDataBlockedFrame extends BaseFrame { 295 | type: FrameType.ConnectionDataBlocked 296 | maxOffset: Long 297 | 298 | constructor (maxOffset: LongValue) { 299 | super('ConnectionDataBlocked') 300 | this.maxOffset = longFromValue(maxOffset, true) 301 | } 302 | 303 | static fromContents (reader: Reader): ConnectionDataBlockedFrame { 304 | const maxOffset = reader.readVarUIntLong() 305 | return new ConnectionDataBlockedFrame(maxOffset) 306 | } 307 | } 308 | 309 | export class ConnectionMaxStreamIdFrame extends BaseFrame { 310 | type: FrameType.ConnectionMaxStreamId 311 | maxStreamId: Long 312 | 313 | constructor (maxStreamId: LongValue) { 314 | super('ConnectionMaxStreamId') 315 | this.maxStreamId = longFromValue(maxStreamId, true) 316 | } 317 | 318 | static fromContents (reader: Reader): ConnectionMaxStreamIdFrame { 319 | const maxStreamId = reader.readVarUIntLong() 320 | return new ConnectionMaxStreamIdFrame(maxStreamId) 321 | } 322 | } 323 | 324 | export class ConnectionStreamIdBlockedFrame extends BaseFrame { 325 | type: FrameType.ConnectionStreamIdBlocked 326 | maxStreamId: Long 327 | 328 | constructor (maxStreamId: LongValue) { 329 | super('ConnectionStreamIdBlocked') 330 | this.maxStreamId = longFromValue(maxStreamId, true) 331 | } 332 | 333 | static fromContents (reader: Reader): ConnectionStreamIdBlockedFrame { 334 | const maxStreamId = reader.readVarUIntLong() 335 | return new ConnectionStreamIdBlockedFrame(maxStreamId) 336 | } 337 | } 338 | 339 | export class StreamCloseFrame extends BaseFrame { 340 | type: FrameType.StreamClose 341 | streamId: Long 342 | errorCode: ErrorCode 343 | errorMessage: string 344 | 345 | constructor (streamId: LongValue, errorCode: ErrorCode, errorMessage: string) { 346 | super('StreamClose') 347 | this.streamId = longFromValue(streamId, true) 348 | this.errorCode = errorCode 349 | this.errorMessage = errorMessage 350 | } 351 | 352 | static fromContents (reader: Reader): StreamCloseFrame { 353 | const streamId = reader.readVarUIntLong() 354 | const errorCode = reader.readUInt8Number() as ErrorCode 355 | const errorMessage = reader.readVarOctetString().toString('utf8') 356 | return new StreamCloseFrame(streamId, errorCode, errorMessage) 357 | } 358 | } 359 | 360 | export class StreamMoneyFrame extends BaseFrame { 361 | type: FrameType.StreamMoney 362 | streamId: Long 363 | shares: Long 364 | 365 | constructor (streamId: LongValue, shares: LongValue) { 366 | super('StreamMoney') 367 | this.streamId = longFromValue(streamId, true) 368 | this.shares = longFromValue(shares, true) 369 | } 370 | 371 | static fromContents (reader: Reader): StreamMoneyFrame { 372 | const streamId = reader.readVarUIntLong() 373 | const amount = reader.readVarUIntLong() 374 | return new StreamMoneyFrame(streamId, amount) 375 | } 376 | } 377 | 378 | export class StreamMaxMoneyFrame extends BaseFrame { 379 | type: FrameType.StreamMaxMoney 380 | streamId: Long 381 | receiveMax: Long 382 | totalReceived: Long 383 | 384 | constructor (streamId: LongValue, receiveMax: LongValue, totalReceived: LongValue) { 385 | super('StreamMaxMoney') 386 | if (typeof receiveMax === 'number' && !isFinite(receiveMax)) { 387 | receiveMax = Long.MAX_UNSIGNED_VALUE 388 | } 389 | 390 | this.streamId = longFromValue(streamId, true) 391 | this.receiveMax = longFromValue(receiveMax, true) 392 | this.totalReceived = longFromValue(totalReceived, true) 393 | } 394 | 395 | static fromContents (reader: Reader): StreamMaxMoneyFrame { 396 | const streamId = reader.readVarUIntLong() 397 | const receiveMax = saturatingReadVarUInt(reader) 398 | const totalReceived = reader.readVarUIntLong() 399 | return new StreamMaxMoneyFrame(streamId, receiveMax, totalReceived) 400 | } 401 | } 402 | 403 | export class StreamMoneyBlockedFrame extends BaseFrame { 404 | type: FrameType.StreamMoneyBlocked 405 | streamId: Long 406 | sendMax: Long 407 | totalSent: Long 408 | 409 | constructor (streamId: LongValue, sendMax: LongValue, totalSent: LongValue) { 410 | super('StreamMoneyBlocked') 411 | this.streamId = longFromValue(streamId, true) 412 | this.sendMax = longFromValue(sendMax, true) 413 | this.totalSent = longFromValue(totalSent, true) 414 | } 415 | 416 | static fromContents (reader: Reader): StreamMoneyBlockedFrame { 417 | const streamId = reader.readVarUIntLong() 418 | const sendMax = saturatingReadVarUInt(reader) 419 | const totalSent = reader.readVarUIntLong() 420 | return new StreamMoneyBlockedFrame(streamId, sendMax, totalSent) 421 | } 422 | } 423 | 424 | export class StreamDataFrame extends BaseFrame { 425 | type: FrameType.StreamData 426 | streamId: Long 427 | offset: Long 428 | data: Buffer 429 | 430 | constructor (streamId: LongValue, offset: LongValue, data: Buffer) { 431 | super('StreamData') 432 | this.streamId = longFromValue(streamId, true) 433 | this.offset = longFromValue(offset, true) 434 | this.data = data 435 | } 436 | 437 | static fromContents (reader: Reader): StreamDataFrame { 438 | const streamId = reader.readVarUIntLong() 439 | const offset = reader.readVarUIntLong() 440 | const data = reader.readVarOctetString() 441 | return new StreamDataFrame(streamId, offset, data) 442 | } 443 | 444 | // Leave out the data because that may be very long 445 | toJSON (): Object { 446 | return { 447 | type: this.type, 448 | name: this.name, 449 | streamId: this.streamId, 450 | offset: this.offset, 451 | dataLength: this.data.length 452 | } 453 | } 454 | } 455 | 456 | export class StreamMaxDataFrame extends BaseFrame { 457 | type: FrameType.StreamMaxData 458 | streamId: Long 459 | maxOffset: Long 460 | 461 | constructor (streamId: LongValue, maxOffset: LongValue) { 462 | super('StreamMaxData') 463 | this.streamId = longFromValue(streamId, true) 464 | this.maxOffset = longFromValue(maxOffset, true) 465 | } 466 | 467 | static fromContents (reader: Reader): StreamMaxDataFrame { 468 | const streamId = reader.readVarUIntLong() 469 | const maxOffset = reader.readVarUIntLong() 470 | return new StreamMaxDataFrame(streamId, maxOffset) 471 | } 472 | } 473 | 474 | export class StreamDataBlockedFrame extends BaseFrame { 475 | type: FrameType.StreamDataBlocked 476 | streamId: Long 477 | maxOffset: Long 478 | 479 | constructor (streamId: LongValue, maxOffset: LongValue) { 480 | super('StreamDataBlocked') 481 | this.streamId = longFromValue(streamId, true) 482 | this.maxOffset = longFromValue(maxOffset, true) 483 | } 484 | 485 | static fromContents (reader: Reader): StreamDataBlockedFrame { 486 | const streamId = reader.readVarUIntLong() 487 | const maxOffset = reader.readVarUIntLong() 488 | return new StreamDataBlockedFrame(streamId, maxOffset) 489 | } 490 | } 491 | 492 | export class StreamReceiptFrame extends BaseFrame { 493 | type: FrameType.StreamReceipt 494 | streamId: Long 495 | receipt: Buffer 496 | 497 | constructor (streamId: LongValue, receipt: Buffer) { 498 | super('StreamReceipt') 499 | this.streamId = longFromValue(streamId, true) 500 | this.receipt = receipt 501 | } 502 | 503 | static fromContents (reader: Reader): StreamReceiptFrame { 504 | const streamId = reader.readVarUIntLong() 505 | const receipt = reader.readVarOctetString() 506 | return new StreamReceiptFrame(streamId, receipt) 507 | } 508 | 509 | toJSON (): Object { 510 | return { 511 | type: this.type, 512 | name: this.name, 513 | streamId: this.streamId, 514 | receipt: this.receipt.toString('base64') 515 | } 516 | } 517 | } 518 | 519 | function parseFrame (reader: Reader): Frame | undefined { 520 | const type = reader.readUInt8Number() 521 | const contents = Reader.from(reader.readVarOctetString()) 522 | 523 | switch (type) { 524 | case FrameType.ConnectionClose: 525 | return ConnectionCloseFrame.fromContents(contents) 526 | case FrameType.ConnectionNewAddress: 527 | return ConnectionNewAddressFrame.fromContents(contents) 528 | case FrameType.ConnectionAssetDetails: 529 | return ConnectionAssetDetailsFrame.fromContents(contents) 530 | case FrameType.ConnectionMaxData: 531 | return ConnectionMaxDataFrame.fromContents(contents) 532 | case FrameType.ConnectionDataBlocked: 533 | return ConnectionDataBlockedFrame.fromContents(contents) 534 | case FrameType.ConnectionMaxStreamId: 535 | return ConnectionMaxStreamIdFrame.fromContents(contents) 536 | case FrameType.ConnectionStreamIdBlocked: 537 | return ConnectionStreamIdBlockedFrame.fromContents(contents) 538 | case FrameType.StreamClose: 539 | return StreamCloseFrame.fromContents(contents) 540 | case FrameType.StreamMoney: 541 | return StreamMoneyFrame.fromContents(contents) 542 | case FrameType.StreamMaxMoney: 543 | return StreamMaxMoneyFrame.fromContents(contents) 544 | case FrameType.StreamMoneyBlocked: 545 | return StreamMoneyBlockedFrame.fromContents(contents) 546 | case FrameType.StreamData: 547 | return StreamDataFrame.fromContents(contents) 548 | case FrameType.StreamMaxData: 549 | return StreamMaxDataFrame.fromContents(contents) 550 | case FrameType.StreamDataBlocked: 551 | return StreamDataBlockedFrame.fromContents(contents) 552 | case FrameType.StreamReceipt: 553 | return StreamReceiptFrame.fromContents(contents) 554 | default: 555 | return undefined 556 | } 557 | } 558 | 559 | // Behaves like `readVarUIntLong`, but returns `Long.MAX_UNSIGNED_VALUE` if the 560 | // VarUInt is too large to fit in a UInt64. 561 | function saturatingReadVarUInt (reader: Reader): Long { 562 | if (reader.peekVarOctetString().length > 8) { 563 | reader.skipVarOctetString() 564 | return Long.MAX_UNSIGNED_VALUE 565 | } else { 566 | return reader.readVarUIntLong() 567 | } 568 | } 569 | -------------------------------------------------------------------------------- /test/fixtures/packets.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "sequence:0", 4 | "packet": { 5 | "sequence": "0", 6 | "packetType": 12, 7 | "amount": "0", 8 | "frames": [] 9 | }, 10 | "buffer": "AQwBAAEAAQA=" 11 | }, 12 | { 13 | "name": "sequence:max_js", 14 | "packet": { 15 | "sequence": "9007199254740991", 16 | "packetType": 12, 17 | "amount": "0", 18 | "frames": [] 19 | }, 20 | "buffer": "AQwHH////////wEAAQA=" 21 | }, 22 | { 23 | "name": "sequence:max_uint_64", 24 | "packet": { 25 | "sequence": "18446744073709551615", 26 | "packetType": 12, 27 | "amount": "0", 28 | "frames": [] 29 | }, 30 | "buffer": "AQwI//////////8BAAEA" 31 | }, 32 | { 33 | "name": "type:prepare", 34 | "packet": { 35 | "sequence": "0", 36 | "packetType": 12, 37 | "amount": "0", 38 | "frames": [] 39 | }, 40 | "buffer": "AQwBAAEAAQA=" 41 | }, 42 | { 43 | "name": "type:fulfill", 44 | "packet": { 45 | "sequence": "0", 46 | "packetType": 13, 47 | "amount": "0", 48 | "frames": [] 49 | }, 50 | "buffer": "AQ0BAAEAAQA=" 51 | }, 52 | { 53 | "name": "type:reject", 54 | "packet": { 55 | "sequence": "0", 56 | "packetType": 14, 57 | "amount": "0", 58 | "frames": [] 59 | }, 60 | "buffer": "AQ4BAAEAAQA=" 61 | }, 62 | { 63 | "name": "amount:0", 64 | "packet": { 65 | "sequence": "0", 66 | "packetType": 12, 67 | "amount": "0", 68 | "frames": [] 69 | }, 70 | "buffer": "AQwBAAEAAQA=" 71 | }, 72 | { 73 | "name": "amount:max_js", 74 | "packet": { 75 | "sequence": "0", 76 | "packetType": 12, 77 | "amount": "9007199254740991", 78 | "frames": [] 79 | }, 80 | "buffer": "AQwBAAcf////////AQA=" 81 | }, 82 | { 83 | "name": "amount:max_uint_64", 84 | "packet": { 85 | "sequence": "0", 86 | "packetType": 12, 87 | "amount": "18446744073709551615", 88 | "frames": [] 89 | }, 90 | "buffer": "AQwBAAj//////////wEA" 91 | }, 92 | { 93 | "name": "frame:connection_close", 94 | "packet": { 95 | "sequence": "0", 96 | "packetType": 12, 97 | "amount": "0", 98 | "frames": [ 99 | { 100 | "type": 1, 101 | "name": "ConnectionClose", 102 | "errorCode": 1, 103 | "errorMessage": "fail" 104 | } 105 | ] 106 | }, 107 | "buffer": "AQwBAAEAAQEBBgEEZmFpbA==" 108 | }, 109 | { 110 | "name": "frame:connection_new_address", 111 | "packet": { 112 | "sequence": "0", 113 | "packetType": 12, 114 | "amount": "0", 115 | "frames": [ 116 | { 117 | "type": 2, 118 | "name": "ConnectionNewAddress", 119 | "sourceAccount": "example.alice" 120 | } 121 | ] 122 | }, 123 | "buffer": "AQwBAAEAAQECDg1leGFtcGxlLmFsaWNl" 124 | }, 125 | { 126 | "name": "frame:connection_asset_details", 127 | "packet": { 128 | "sequence": "0", 129 | "packetType": 12, 130 | "amount": "0", 131 | "frames": [ 132 | { 133 | "type": 7, 134 | "name": "ConnectionAssetDetails", 135 | "sourceAssetCode": "ABC", 136 | "sourceAssetScale": 255 137 | } 138 | ] 139 | }, 140 | "buffer": "AQwBAAEAAQEHBQNBQkP/" 141 | }, 142 | { 143 | "name": "frame:connection_max_data:0", 144 | "packet": { 145 | "sequence": "0", 146 | "packetType": 12, 147 | "amount": "0", 148 | "frames": [ 149 | { 150 | "type": 3, 151 | "name": "ConnectionMaxData", 152 | "maxOffset": "0" 153 | } 154 | ] 155 | }, 156 | "buffer": "AQwBAAEAAQEDAgEA" 157 | }, 158 | { 159 | "name": "frame:connection_max_data:max_js", 160 | "packet": { 161 | "sequence": "0", 162 | "packetType": 12, 163 | "amount": "0", 164 | "frames": [ 165 | { 166 | "type": 3, 167 | "name": "ConnectionMaxData", 168 | "maxOffset": "9007199254740991" 169 | } 170 | ] 171 | }, 172 | "buffer": "AQwBAAEAAQEDCAcf////////" 173 | }, 174 | { 175 | "name": "frame:connection_max_data:max_uint_64", 176 | "packet": { 177 | "sequence": "0", 178 | "packetType": 12, 179 | "amount": "0", 180 | "frames": [ 181 | { 182 | "type": 3, 183 | "name": "ConnectionMaxData", 184 | "maxOffset": "18446744073709551615" 185 | } 186 | ] 187 | }, 188 | "buffer": "AQwBAAEAAQEDCQj//////////w==" 189 | }, 190 | { 191 | "name": "frame:connection_data_blocked:0", 192 | "packet": { 193 | "sequence": "0", 194 | "packetType": 12, 195 | "amount": "0", 196 | "frames": [ 197 | { 198 | "type": 4, 199 | "name": "ConnectionDataBlocked", 200 | "maxOffset": "0" 201 | } 202 | ] 203 | }, 204 | "buffer": "AQwBAAEAAQEEAgEA" 205 | }, 206 | { 207 | "name": "frame:connection_data_blocked:max_js", 208 | "packet": { 209 | "sequence": "0", 210 | "packetType": 12, 211 | "amount": "0", 212 | "frames": [ 213 | { 214 | "type": 4, 215 | "name": "ConnectionDataBlocked", 216 | "maxOffset": "9007199254740991" 217 | } 218 | ] 219 | }, 220 | "buffer": "AQwBAAEAAQEECAcf////////" 221 | }, 222 | { 223 | "name": "frame:connection_data_blocked:max_uint_64", 224 | "packet": { 225 | "sequence": "0", 226 | "packetType": 12, 227 | "amount": "0", 228 | "frames": [ 229 | { 230 | "type": 4, 231 | "name": "ConnectionDataBlocked", 232 | "maxOffset": "18446744073709551615" 233 | } 234 | ] 235 | }, 236 | "buffer": "AQwBAAEAAQEECQj//////////w==" 237 | }, 238 | { 239 | "name": "frame:connection_max_stream_id:0", 240 | "packet": { 241 | "sequence": "0", 242 | "packetType": 12, 243 | "amount": "0", 244 | "frames": [ 245 | { 246 | "type": 5, 247 | "name": "ConnectionMaxStreamId", 248 | "maxStreamId": "0" 249 | } 250 | ] 251 | }, 252 | "buffer": "AQwBAAEAAQEFAgEA" 253 | }, 254 | { 255 | "name": "frame:connection_max_stream_id:max_js", 256 | "packet": { 257 | "sequence": "0", 258 | "packetType": 12, 259 | "amount": "0", 260 | "frames": [ 261 | { 262 | "type": 5, 263 | "name": "ConnectionMaxStreamId", 264 | "maxStreamId": "9007199254740991" 265 | } 266 | ] 267 | }, 268 | "buffer": "AQwBAAEAAQEFCAcf////////" 269 | }, 270 | { 271 | "name": "frame:connection_max_stream_id:max_uint_64", 272 | "packet": { 273 | "sequence": "0", 274 | "packetType": 12, 275 | "amount": "0", 276 | "frames": [ 277 | { 278 | "type": 5, 279 | "name": "ConnectionMaxStreamId", 280 | "maxStreamId": "18446744073709551615" 281 | } 282 | ] 283 | }, 284 | "buffer": "AQwBAAEAAQEFCQj//////////w==" 285 | }, 286 | { 287 | "name": "frame:connection_stream_id_blocked:0", 288 | "packet": { 289 | "sequence": "0", 290 | "packetType": 12, 291 | "amount": "0", 292 | "frames": [ 293 | { 294 | "type": 6, 295 | "name": "ConnectionStreamIdBlocked", 296 | "maxStreamId": "0" 297 | } 298 | ] 299 | }, 300 | "buffer": "AQwBAAEAAQEGAgEA" 301 | }, 302 | { 303 | "name": "frame:connection_stream_id_blocked:max_js", 304 | "packet": { 305 | "sequence": "0", 306 | "packetType": 12, 307 | "amount": "0", 308 | "frames": [ 309 | { 310 | "type": 6, 311 | "name": "ConnectionStreamIdBlocked", 312 | "maxStreamId": "9007199254740991" 313 | } 314 | ] 315 | }, 316 | "buffer": "AQwBAAEAAQEGCAcf////////" 317 | }, 318 | { 319 | "name": "frame:connection_stream_id_blocked:max_uint_64", 320 | "packet": { 321 | "sequence": "0", 322 | "packetType": 12, 323 | "amount": "0", 324 | "frames": [ 325 | { 326 | "type": 6, 327 | "name": "ConnectionStreamIdBlocked", 328 | "maxStreamId": "18446744073709551615" 329 | } 330 | ] 331 | }, 332 | "buffer": "AQwBAAEAAQEGCQj//////////w==" 333 | }, 334 | { 335 | "name": "frame:stream_close", 336 | "packet": { 337 | "sequence": "0", 338 | "packetType": 12, 339 | "amount": "0", 340 | "frames": [ 341 | { 342 | "type": 16, 343 | "name": "StreamClose", 344 | "streamId": "123", 345 | "errorCode": 255, 346 | "errorMessage": "an error message" 347 | } 348 | ] 349 | }, 350 | "buffer": "AQwBAAEAAQEQFAF7/xBhbiBlcnJvciBtZXNzYWdl" 351 | }, 352 | { 353 | "name": "frame:stream_money:0", 354 | "packet": { 355 | "sequence": "0", 356 | "packetType": 12, 357 | "amount": "0", 358 | "frames": [ 359 | { 360 | "type": 17, 361 | "name": "StreamMoney", 362 | "streamId": "123", 363 | "shares": "0" 364 | } 365 | ] 366 | }, 367 | "buffer": "AQwBAAEAAQERBAF7AQA=" 368 | }, 369 | { 370 | "name": "frame:stream_money:max_js", 371 | "packet": { 372 | "sequence": "0", 373 | "packetType": 12, 374 | "amount": "0", 375 | "frames": [ 376 | { 377 | "type": 17, 378 | "name": "StreamMoney", 379 | "streamId": "123", 380 | "shares": "9007199254740991" 381 | } 382 | ] 383 | }, 384 | "buffer": "AQwBAAEAAQERCgF7Bx////////8=" 385 | }, 386 | { 387 | "name": "frame:stream_money:max_uint_64", 388 | "packet": { 389 | "sequence": "0", 390 | "packetType": 12, 391 | "amount": "0", 392 | "frames": [ 393 | { 394 | "type": 17, 395 | "name": "StreamMoney", 396 | "streamId": "123", 397 | "shares": "18446744073709551615" 398 | } 399 | ] 400 | }, 401 | "buffer": "AQwBAAEAAQERCwF7CP//////////" 402 | }, 403 | { 404 | "name": "frame:stream_max_money:receive_max:0", 405 | "packet": { 406 | "sequence": "0", 407 | "packetType": 12, 408 | "amount": "0", 409 | "frames": [ 410 | { 411 | "type": 18, 412 | "name": "StreamMaxMoney", 413 | "streamId": "123", 414 | "receiveMax": "0", 415 | "totalReceived": "456" 416 | } 417 | ] 418 | }, 419 | "buffer": "AQwBAAEAAQESBwF7AQACAcg=" 420 | }, 421 | { 422 | "name": "frame:stream_max_money:receive_max:max_js", 423 | "packet": { 424 | "sequence": "0", 425 | "packetType": 12, 426 | "amount": "0", 427 | "frames": [ 428 | { 429 | "type": 18, 430 | "name": "StreamMaxMoney", 431 | "streamId": "123", 432 | "receiveMax": "9007199254740991", 433 | "totalReceived": "456" 434 | } 435 | ] 436 | }, 437 | "buffer": "AQwBAAEAAQESDQF7Bx////////8CAcg=" 438 | }, 439 | { 440 | "name": "frame:stream_max_money:receive_max:max_uint_64", 441 | "packet": { 442 | "sequence": "0", 443 | "packetType": 12, 444 | "amount": "0", 445 | "frames": [ 446 | { 447 | "type": 18, 448 | "name": "StreamMaxMoney", 449 | "streamId": "123", 450 | "receiveMax": "18446744073709551615", 451 | "totalReceived": "456" 452 | } 453 | ] 454 | }, 455 | "buffer": "AQwBAAEAAQESDgF7CP//////////AgHI" 456 | }, 457 | { 458 | "name": "frame:stream_max_money:total_received:0", 459 | "packet": { 460 | "sequence": "0", 461 | "packetType": 12, 462 | "amount": "0", 463 | "frames": [ 464 | { 465 | "type": 18, 466 | "name": "StreamMaxMoney", 467 | "streamId": "123", 468 | "receiveMax": "456", 469 | "totalReceived": "0" 470 | } 471 | ] 472 | }, 473 | "buffer": "AQwBAAEAAQESBwF7AgHIAQA=" 474 | }, 475 | { 476 | "name": "frame:stream_max_money:total_received:max_js", 477 | "packet": { 478 | "sequence": "0", 479 | "packetType": 12, 480 | "amount": "0", 481 | "frames": [ 482 | { 483 | "type": 18, 484 | "name": "StreamMaxMoney", 485 | "streamId": "123", 486 | "receiveMax": "456", 487 | "totalReceived": "9007199254740991" 488 | } 489 | ] 490 | }, 491 | "buffer": "AQwBAAEAAQESDQF7AgHIBx////////8=" 492 | }, 493 | { 494 | "name": "frame:stream_max_money:total_received:max_uint_64", 495 | "packet": { 496 | "sequence": "0", 497 | "packetType": 12, 498 | "amount": "0", 499 | "frames": [ 500 | { 501 | "type": 18, 502 | "name": "StreamMaxMoney", 503 | "streamId": "123", 504 | "receiveMax": "456", 505 | "totalReceived": "18446744073709551615" 506 | } 507 | ] 508 | }, 509 | "buffer": "AQwBAAEAAQESDgF7AgHICP//////////" 510 | }, 511 | { 512 | "name": "frame:stream_money_blocked:send_max:0", 513 | "packet": { 514 | "sequence": "0", 515 | "packetType": 12, 516 | "amount": "0", 517 | "frames": [ 518 | { 519 | "type": 19, 520 | "name": "StreamMoneyBlocked", 521 | "streamId": "123", 522 | "sendMax": "0", 523 | "totalSent": "456" 524 | } 525 | ] 526 | }, 527 | "buffer": "AQwBAAEAAQETBwF7AQACAcg=" 528 | }, 529 | { 530 | "name": "frame:stream_money_blocked:send_max:max_js", 531 | "packet": { 532 | "sequence": "0", 533 | "packetType": 12, 534 | "amount": "0", 535 | "frames": [ 536 | { 537 | "type": 19, 538 | "name": "StreamMoneyBlocked", 539 | "streamId": "123", 540 | "sendMax": "9007199254740991", 541 | "totalSent": "456" 542 | } 543 | ] 544 | }, 545 | "buffer": "AQwBAAEAAQETDQF7Bx////////8CAcg=" 546 | }, 547 | { 548 | "name": "frame:stream_money_blocked:send_max:max_uint_64", 549 | "packet": { 550 | "sequence": "0", 551 | "packetType": 12, 552 | "amount": "0", 553 | "frames": [ 554 | { 555 | "type": 19, 556 | "name": "StreamMoneyBlocked", 557 | "streamId": "123", 558 | "sendMax": "18446744073709551615", 559 | "totalSent": "456" 560 | } 561 | ] 562 | }, 563 | "buffer": "AQwBAAEAAQETDgF7CP//////////AgHI" 564 | }, 565 | { 566 | "name": "frame:stream_money_blocked:total_sent:0", 567 | "packet": { 568 | "sequence": "0", 569 | "packetType": 12, 570 | "amount": "0", 571 | "frames": [ 572 | { 573 | "type": 19, 574 | "name": "StreamMoneyBlocked", 575 | "streamId": "123", 576 | "sendMax": "456", 577 | "totalSent": "0" 578 | } 579 | ] 580 | }, 581 | "buffer": "AQwBAAEAAQETBwF7AgHIAQA=" 582 | }, 583 | { 584 | "name": "frame:stream_money_blocked:total_sent:max_js", 585 | "packet": { 586 | "sequence": "0", 587 | "packetType": 12, 588 | "amount": "0", 589 | "frames": [ 590 | { 591 | "type": 19, 592 | "name": "StreamMoneyBlocked", 593 | "streamId": "123", 594 | "sendMax": "456", 595 | "totalSent": "9007199254740991" 596 | } 597 | ] 598 | }, 599 | "buffer": "AQwBAAEAAQETDQF7AgHIBx////////8=" 600 | }, 601 | { 602 | "name": "frame:stream_money_blocked:total_sent:max_uint_64", 603 | "packet": { 604 | "sequence": "0", 605 | "packetType": 12, 606 | "amount": "0", 607 | "frames": [ 608 | { 609 | "type": 19, 610 | "name": "StreamMoneyBlocked", 611 | "streamId": "123", 612 | "sendMax": "456", 613 | "totalSent": "18446744073709551615" 614 | } 615 | ] 616 | }, 617 | "buffer": "AQwBAAEAAQETDgF7AgHICP//////////" 618 | }, 619 | { 620 | "name": "frame:stream_data", 621 | "packet": { 622 | "sequence": "0", 623 | "packetType": 12, 624 | "amount": "0", 625 | "frames": [ 626 | { 627 | "type": 20, 628 | "name": "StreamData", 629 | "streamId": "123", 630 | "offset": "456", 631 | "data": "Zm9vYmFy" 632 | } 633 | ] 634 | }, 635 | "buffer": "AQwBAAEAAQEUDAF7AgHIBmZvb2Jhcg==" 636 | }, 637 | { 638 | "name": "frame:stream_data:offset:0", 639 | "packet": { 640 | "sequence": "0", 641 | "packetType": 12, 642 | "amount": "0", 643 | "frames": [ 644 | { 645 | "type": 20, 646 | "name": "StreamData", 647 | "streamId": "123", 648 | "offset": "0", 649 | "data": "" 650 | } 651 | ] 652 | }, 653 | "buffer": "AQwBAAEAAQEUBQF7AQAA" 654 | }, 655 | { 656 | "name": "frame:stream_data:offset:max_js", 657 | "packet": { 658 | "sequence": "0", 659 | "packetType": 12, 660 | "amount": "0", 661 | "frames": [ 662 | { 663 | "type": 20, 664 | "name": "StreamData", 665 | "streamId": "123", 666 | "offset": "9007199254740991", 667 | "data": "" 668 | } 669 | ] 670 | }, 671 | "buffer": "AQwBAAEAAQEUCwF7Bx////////8A" 672 | }, 673 | { 674 | "name": "frame:stream_data:offset:max_uint_64", 675 | "packet": { 676 | "sequence": "0", 677 | "packetType": 12, 678 | "amount": "0", 679 | "frames": [ 680 | { 681 | "type": 20, 682 | "name": "StreamData", 683 | "streamId": "123", 684 | "offset": "18446744073709551615", 685 | "data": "" 686 | } 687 | ] 688 | }, 689 | "buffer": "AQwBAAEAAQEUDAF7CP//////////AA==" 690 | }, 691 | { 692 | "name": "frame:stream_max_data:offset:0", 693 | "packet": { 694 | "sequence": "0", 695 | "packetType": 12, 696 | "amount": "0", 697 | "frames": [ 698 | { 699 | "type": 21, 700 | "name": "StreamMaxData", 701 | "streamId": "123", 702 | "maxOffset": "0" 703 | } 704 | ] 705 | }, 706 | "buffer": "AQwBAAEAAQEVBAF7AQA=" 707 | }, 708 | { 709 | "name": "frame:stream_max_data:offset:max_js", 710 | "packet": { 711 | "sequence": "0", 712 | "packetType": 12, 713 | "amount": "0", 714 | "frames": [ 715 | { 716 | "type": 21, 717 | "name": "StreamMaxData", 718 | "streamId": "123", 719 | "maxOffset": "9007199254740991" 720 | } 721 | ] 722 | }, 723 | "buffer": "AQwBAAEAAQEVCgF7Bx////////8=" 724 | }, 725 | { 726 | "name": "frame:stream_max_data:offset:max_uint_64", 727 | "packet": { 728 | "sequence": "0", 729 | "packetType": 12, 730 | "amount": "0", 731 | "frames": [ 732 | { 733 | "type": 21, 734 | "name": "StreamMaxData", 735 | "streamId": "123", 736 | "maxOffset": "18446744073709551615" 737 | } 738 | ] 739 | }, 740 | "buffer": "AQwBAAEAAQEVCwF7CP//////////" 741 | }, 742 | { 743 | "name": "frame:stream_data_blocked:offset:0", 744 | "packet": { 745 | "sequence": "0", 746 | "packetType": 12, 747 | "amount": "0", 748 | "frames": [ 749 | { 750 | "type": 22, 751 | "name": "StreamDataBlocked", 752 | "streamId": "123", 753 | "maxOffset": "0" 754 | } 755 | ] 756 | }, 757 | "buffer": "AQwBAAEAAQEWBAF7AQA=" 758 | }, 759 | { 760 | "name": "frame:stream_data_blocked:offset:max_js", 761 | "packet": { 762 | "sequence": "0", 763 | "packetType": 12, 764 | "amount": "0", 765 | "frames": [ 766 | { 767 | "type": 22, 768 | "name": "StreamDataBlocked", 769 | "streamId": "123", 770 | "maxOffset": "9007199254740991" 771 | } 772 | ] 773 | }, 774 | "buffer": "AQwBAAEAAQEWCgF7Bx////////8=" 775 | }, 776 | { 777 | "name": "frame:stream_data_blocked:offset:max_uint_64", 778 | "packet": { 779 | "sequence": "0", 780 | "packetType": 12, 781 | "amount": "0", 782 | "frames": [ 783 | { 784 | "type": 22, 785 | "name": "StreamDataBlocked", 786 | "streamId": "123", 787 | "maxOffset": "18446744073709551615" 788 | } 789 | ] 790 | }, 791 | "buffer": "AQwBAAEAAQEWCwF7CP//////////" 792 | }, 793 | { 794 | "name": "frame:stream_receipt", 795 | "packet": { 796 | "sequence": "0", 797 | "packetType": 12, 798 | "amount": "0", 799 | "frames": [ 800 | { 801 | "type": 23, 802 | "name": "StreamReceipt", 803 | "streamId": "1", 804 | "receipt": "AQAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAfTBIvoCUt67Zy1ZGCP3EOmVFtZzhc85fah8yPnoyL9RMA==" 805 | } 806 | ] 807 | }, 808 | "buffer": "AQwBAAEAAQEXPQEBOgEAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAH0wSL6AlLeu2ctWRgj9xDplRbWc4XPOX2ofMj56Mi/UTA=" 809 | }, 810 | { 811 | "name": "frame:stream_max_money:receive_max:too_big", 812 | "packet": { 813 | "sequence": "0", 814 | "packetType": 12, 815 | "amount": "0", 816 | "frames": [ 817 | { 818 | "type": 18, 819 | "name": "StreamMaxMoney", 820 | "streamId": "123", 821 | "receiveMax": "18446744073709551615", 822 | "totalReceived": "456" 823 | } 824 | ] 825 | }, 826 | "buffer": "AQwBAAEAAQESDwF7CQEAAAAAAAAAAAIByA==", 827 | "decode_only": true 828 | }, 829 | { 830 | "name": "frame:stream_money_blocked:send_max:too_big", 831 | "packet": { 832 | "sequence": "0", 833 | "packetType": 12, 834 | "amount": "0", 835 | "frames": [ 836 | { 837 | "type": 19, 838 | "name": "StreamMoneyBlocked", 839 | "streamId": "123", 840 | "sendMax": "18446744073709551615", 841 | "totalSent": "456" 842 | } 843 | ] 844 | }, 845 | "buffer": "AQwBAAEAAQETDwF7CQEAAAAAAAAAAAIByA==", 846 | "decode_only": true 847 | } 848 | ] 849 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | This document describes the JavaScript implementation of Interledger's STREAM transport protocol. 4 | 5 | [![NPM Package](https://img.shields.io/npm/v/ilp-protocol-stream.svg?style=flat)](https://npmjs.org/package/ilp-protocol-stream) 6 | [![CircleCI](https://circleci.com/gh/interledgerjs/ilp-protocol-stream.svg?style=shield)](https://circleci.com/gh/interledgerjs/ilp-protocol-stream) 7 | [![codecov](https://codecov.io/gh/interledgerjs/ilp-protocol-stream/branch/master/graph/badge.svg)](https://codecov.io/gh/interledgerjs/ilp-protocol-stream) 8 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 9 | [![Known Vulnerabilities](https://snyk.io/test/github/interledgerjs/ilp-protocol-stream/badge.svg)](https://snyk.io/test/github/interledgerjs/ilp-protocol-stream) [![Greenkeeper badge](https://badges.greenkeeper.io/interledgerjs/ilp-protocol-stream.svg)](https://greenkeeper.io/) 10 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Finterledgerjs%2Filp-protocol-stream.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Finterledgerjs%2Filp-protocol-stream?ref=badge_shield) 11 | 12 | ## References 13 | 14 | * [STREAM RFC](https://interledger.org/rfcs/0029-stream/) 15 | * [Interledger protocol suite](https://interledger.org/overview.html) 16 | * API docs: 17 | * [Connection](https://interledgerjs.github.io/ilp-protocol-stream/modules/_connection_.html) - Manages the communication between a client and a server 18 | * [Index](https://interledgerjs.github.io/ilp-protocol-stream/modules/_index_.html) - Creates a connection to a server 19 | * [Stream](https://interledgerjs.github.io/ilp-protocol-stream/modules/_stream_.html) - Sends/receives data and money 20 | 21 | # Table of Contents 22 | 23 | - [Overview](#overview) 24 | * [References](#references) 25 | - [STREAM protocol overview](#stream-protocol-overview) 26 | - [STREAM connections](#stream-connections) 27 | * [Open a STREAM connection](#open-a-stream-connection) 28 | - [Streams](#streams) 29 | - [Install the STREAM module](#install-the-stream-module) 30 | * [Dependencies](#dependencies) 31 | - [Usage examples](#usage-examples) 32 | * [Exchange destination address and shared secret](#exchange-destination-address-and-shared-secret) 33 | * [Create a STREAM connection](#create-a-stream-connection) 34 | * [Send and receive on streams](#send-and-receive-on-streams) 35 | * [Stream multiple payments on a single connection](#stream-multiple-payments-on-a-single-connection) 36 | * [Stream data](#stream-data) 37 | * [Stream expiry](#stream-expiry) 38 | * [Close a stream](#close-a-stream) 39 | * [Close a connection](#close-a-connection) 40 | * [How a receiving wallet can use the STREAM server](#how-a-receiving-wallet-can-use-the-stream-server) 41 | * [Configure the STREAM library as a sender for Web Monetization](#configure-the-stream-library-as-a-sender-for-web-monetization) 42 | - [How STREAM handles assets](#how-stream-handles-assets) 43 | * [Connectors](#connectors) 44 | * [Determine an exchange rate (optional)](#determine-an-exchange-rate--optional-) 45 | - [STREAM receipts](#stream-receipts) 46 | 47 | # STREAM protocol overview 48 | 49 | STREAM stands for Streaming Transport for the Real-time Exchange of Assets and Messages. It’s a transport-layer protocol that's used to send packets of money and data through the Interledger network, rather than over the internet. 50 | 51 | ![STREAM in protocol suite](./docs/assets/stream-in-protocol-suite.svg) 52 | 53 | STREAM is designed for use by applications that stream micropayments and those that deliver larger, discrete payments. It's responsible for: 54 | 55 | * Defining the conditions and fulfillments used in the Interledger protocol layer 56 | * Grouping and retrying packets to achieve a desired outcome 57 | * Determining the effective exchange rate of a payment 58 | * Adapting to the speed at which money can be sent, and for what amounts (congestion and flow control) 59 | * Encrypting and decrypting data 60 | 61 | # STREAM connections 62 | 63 | A STREAM connection is a session established between two endpoints (a client and a server). Since STREAM is an Interledger protocol, the connection occurs over the Interledger network, not the internet. 64 | 65 | A single connection can support multiple streams, which are logical, bidirectional channels over which money and data are sent and received. 66 | 67 | ## Open a STREAM connection 68 | 69 | Before establishing a STREAM connection, the client must request and receive a destination ILP address and shared secret from the server. This exchange is not handled by the STREAM protocol. It must be handled by a higher-level protocol and/or out-of-band authenticated communication channel. The Simple Payment Setup Protocol (SPSP) is an Interledger application-layer protocol that can be used for this purpose. 70 | 71 | When the client receives the destination address and shared secret, it passes the credentials to its STREAM client module. The module then initiates a STREAM connection to the server over the Interledger network. 72 | 73 | ### STREAM and SPSP servers 74 | 75 | STREAM and SPSP are separate entities though they often run in the same process. This is because the STREAM server that handles incoming funds must use the shared secret that was generated by the SPSP server's `generateAddressAndSecret` call. 76 | 77 | While SPSP uses HTTPS to communicate the destination address and shared secret to the client, STREAM does **not** create the connection over the internet. The connection is created over the Interledger network. 78 | 79 | Creating one STREAM server object through the protocol-stream library and using the object for both the STREAM and SPSP servers simply saves you some work. They are often put together in flow diagrams because that's how they are most commonly implemented. 80 | 81 | # Streams 82 | 83 | A stream is a logical channel on which money and data is sent and received over a STREAM connection. A single connection, using a single shared secret, can support multiple streams. 84 | 85 | Either endpoint (client or server) can open a new stream. Multiple streams can deliver smaller payments on an ongoing basis or a single stream can deliver one large, discreet payment. 86 | 87 | # Install the STREAM module 88 | 89 | Install the `ilp-protocol-stream` module into your Node.js project. You must be running Node 10 or greater. You need the module if you intend to develop a client or run a server that supports Interledger. 90 | 91 | ``` 92 | $ npm install ilp-protocol-stream 93 | ``` 94 | 95 | Also, `ilp-protocol-stream` will run in a browser via webpack, but only as a client. 96 | 97 | ## Dependencies 98 | 99 | The `ilp-protocol-stream` module is bundled with a set of dependencies. 100 | 101 | * Bundle dependencies 102 | * ilp-logger - Debug logging utility for Interledger modules 103 | * ilp-packet - Codecs for ILP packets and messages 104 | * ilp-protocol-ildcp - Transfers node and ledger information from a parent node to a child node 105 | * Development dependencies 106 | * ilp-plugin - Gets ILP credentials 107 | * ilp-plugin-btp - Acts as a building block for plugins that don't have an underlying ledger 108 | 109 | # Usage examples 110 | 111 | ## Exchange destination address and shared secret 112 | 113 | ### Server 114 | 115 | The server must generate a destination account address and shared secret for each client, then pass the information to the client through an authenticated communication channel. 116 | 117 | ```js 118 | const { createServer } = require('ilp-protocol-stream') 119 | const Plugin = require('ilp-plugin-btp') 120 | 121 | 122 | // Connects to the given plugin and waits for streams. 123 | async function run () { 124 | const server = await createServer({ 125 | plugin: new Plugin({ server: process.env.BTP_SERVER }) 126 | }) 127 | 128 | const { destinationAccount, sharedSecret } = server.generateAddressAndSecret() 129 | } 130 | ``` 131 | 132 | ### Client 133 | 134 | After the server provides the destination account and shared secret, the client can create a STREAM connection to the server. 135 | 136 | ```js 137 | const { createConnection } = require('ilp-protocol-stream') 138 | const getPlugin = require('ilp-plugin-btp') 139 | 140 | const { destinationAccount, sharedSecret } = 141 | providedByTheServerSomehow() 142 | 143 | async function run () { 144 | const connection = await createConnection({ 145 | plugin: new Plugin({ server: process.env.BTP_SERVER}), 146 | destinationAccount, 147 | sharedSecret 148 | }) 149 | } 150 | ``` 151 | 152 | ## Create a STREAM connection 153 | 154 | ### Server 155 | 156 | The server listens for an incoming STREAM connection from the client. 157 | 158 | ```js 159 | server.on('connection', connection => { ... 160 | ``` 161 | 162 | ### Client 163 | 164 | The client creates a STREAM connection to the server. 165 | 166 | ```js 167 | const connection = await createConnection({ 168 | plugin: new Plugin({ server: process.env.BTP_SERVER}), 169 | destinationAccount, 170 | sharedSecret 171 | }) 172 | ``` 173 | 174 | After the connection is opened between the client and server, the connection is ready to support streams. A single connection, using a single shared secret, can support multiple streams. 175 | 176 | ## Send and receive on streams 177 | 178 | Streams are bidirectional, meaning both endpoints can send and receive on a stream. Each stream is its own object that emits events when money comes in or goes out. 179 | 180 | Streams returned by `connection.createStream`, or produced by the `stream` event on a STREAM server, expose the Node.js [Stream API](https://interledgerjs.github.io/ilp-protocol-stream/modules/_stream_.html) for sending data. They also expose functions for sending and receiving money. 181 | 182 | Payments are sent and received using `setSendMax` and `setReceiveMax`. `setSendMax` and `setReceiveMax` set the maximum amount an individual stream can send and receive, thereby enforcing sending and receiving limits. These calls occur instantly, before any funds are sent or received. 183 | 184 | The amounts given for both functions are denominated in the connection plugin’s units. This amount is absolute, not relative. For example, calling `setSendMax(100)` twice on a single stream only allows the stream to send 100 units. 185 | 186 | When used with event listeners, these calls can supply you with a high level of detail by providing events for each individual micropayment. 187 | 188 | ### Set a send max and send a stream 189 | 190 | `setSendMax` sets the maximum amount an individual stream can send, thereby enforcing a sending limit. This call occurs instantly, before any funds are sent. 191 | 192 | A payment is automatically triggered when the `setSendMax` value is more than the amount already sent. As an example, micropayments in Web Monetization would be a single stream. A `setSendMax` would be called on some time interval where each time it’s called, it’s called with a higher amount. 193 | 194 | The stream will always attempt to send up to its defined max, but ultimately will only send as much as the receiver is willing to receive. If the receiver has set a `receiveMax`, then the amount is adjusted by the exchange rate. 195 | 196 | ![sendMax example](./docs/assets/sendmax.png) 197 | 198 | ```js 199 | stream.on('outgoing_money', amount => { 200 | console.log('sent', amount) 201 | }) 202 | 203 | stream.setSendMax(100) 204 | ``` 205 | 206 | In the following scenario, the receiver isn’t willing to receive the whole amount, but some money still moves. The sender will send as much as they can. 207 | 208 | #### Sender 209 | 210 | ```js 211 | stream.on('outgoing_money', amount => { 212 | console.log('sent', amount) 213 | }) 214 | stream.setSendMax(100) 215 | ``` 216 | 217 | #### Receiver 218 | 219 | ```js 220 | server.on('connection', connection => { 221 | connection.on('stream', stream => { 222 | stream.setReceiveMax(75) 223 | stream.on('money', amount => { 224 | console.log(`got money: ${amount} on stream ${stream.id}`) 225 | }) 226 | stream.on('end', () => { 227 | console.log('stream closed') 228 | }) 229 | }) 230 | }) 231 | ``` 232 | 233 | ### Set a receive max and receive a stream 234 | 235 | `setReceiveMax` sets the maximum amount an individual stream can receive, thereby enforcing a receiving limit. This call occurs instantly, before any funds are received. 236 | 237 | Payments are automatically received when the `setReceiveMax` value is more than the amount already received. 238 | 239 | The stream will always attempt to receive up to its defined max. If the sender has set a `sendMax`, then the amount is adjusted by the exchange rate. 240 | 241 | ![receiveMax example](./docs/assets/receivemax.png) 242 | 243 | To receive a stream, the server listens for a `connection` event. 244 | 245 | Since each connection can include many separate streams of money and data, the connection then listens for `stream` events. 246 | 247 | ```js 248 | server.on('connection', connection => { 249 | connection.on('stream', stream => { 250 | stream.setReceiveMax(10000) 251 | stream.on('money', amount => { 252 | console.log(`got money: ${amount} on stream ${stream.id}`) 253 | }) 254 | }) 255 | }) 256 | ``` 257 | 258 | > The `stream.id` is handled by the STREAM library in the background. Server-initiated streams are assigned an even number. Client-initiated streams are assigned an odd number. 259 | 260 | ## Stream multiple payments on a single connection 261 | 262 | Micropayments are sent and received the same way as a single payment, except that multiple streams are created and closed. 263 | 264 | ### Client 265 | 266 | ```js 267 | const stream = connection.createStream() 268 | stream.setSendMax(10) 269 | 270 | const stream2 = connection.createStream() 271 | stream2.setSendMax(25) 272 | ``` 273 | 274 | ### Server 275 | 276 | ```js 277 | server.on('connection', connection => { 278 | connection.on('stream', stream => { 279 | 280 | stream.setReceiveMax(10000) 281 | 282 | stream.on('money', amount => { 283 | console.log(`got money: ${amount} on stream ${stream.id}`) 284 | }) 285 | 286 | stream.on('end', () => { 287 | console.log('stream closed') 288 | }) 289 | }) 290 | }) 291 | ``` 292 | 293 | ## Stream data 294 | 295 | You can stream data using the same interface as the Node.js Stream API. 296 | 297 | ### Send data 298 | 299 | ```js 300 | const stream = connection.createStream() 301 | stream.write('hello\n') 302 | stream.write('here is some more data') 303 | stream.end() 304 | ``` 305 | 306 | ### Receive data 307 | 308 | ```js 309 | stream.on('data', chunk => { 310 | console.log(`got data on stream ${stream.id}: ${chunk.toString('utf8')}`) 311 | }) 312 | ``` 313 | 314 | > The `stream.id` is handled by the STREAM library in the background. Server-initiated streams are assigned an even number. Client-initiated streams are assigned an odd number. 315 | 316 | ## Stream expiry 317 | 318 | The sender calls the `getExpiry` function for each packet or sets the expiry to the current date/time plus 30 seconds if `getExpiry` is not provided. 319 | 320 | For example, if a packet is created on May 26, 2020 at 8:15:00 am, US Eastern Standard Time, then the expiry is set to 2020-05-26T13:30:300Z by default. 321 | 322 | The intermediaries between the sender and receiver forward the packet as long as the packet is not about to expire in <1s. 323 | 324 | ## Close a stream 325 | 326 | Either endpoint can close a stream. Streams can close due to errors, expirations, and completions. 327 | 328 | ```js 329 | stream.end() 330 | ``` 331 | 332 | To log the `end` event: 333 | 334 | ```js 335 | stream.on('end', () => { 336 | console.log('stream closed') 337 | }) 338 | ``` 339 | 340 | To force a stream to close immediately without waiting for pending money and data to be sent, use the `destroy` method. 341 | 342 | ```js 343 | stream.destroy() 344 | ``` 345 | 346 | ## Close a connection 347 | 348 | Either endpoint can close a connection. When a connection is closed, all streams are closed as well. Connections can close due to errors and completions. 349 | 350 | ```js 351 | connection.end() 352 | ``` 353 | 354 | A closed connection can’t be reused. A new connection must be created with a new shared secret and destination address. 355 | 356 | To log the `end` event: 357 | 358 | ```js 359 | connection.on('end', () => { 360 | console.log('1: The connection closed normally') 361 | }) 362 | ``` 363 | 364 | To force a connection to close immediately without waiting for pending money and data to be sent, use the `destroy` method. 365 | 366 | ```js 367 | connection.destroy() 368 | ``` 369 | 370 | ## How a receiving wallet can use the STREAM server 371 | 372 | This section describes how a wallet that only receives incoming payments (it doesn’t send outbound payments) could use the STREAM server and the security and development considerations related to that. 373 | 374 | At a high level, the STREAM server: 375 | 376 | 1. Binds the `connection` event. 377 | 2. Might perform some **non-async** setup. 378 | > A common pitfall is for an asynchronous operation to happen between the `connection` event and binding the stream (in the next step), causing the server to miss when the stream opens. 379 | 3. Binds the `stream` event. 380 | 4. Might perform more **non-async** setup. 381 | 5. Sets the `setReceiveMax` to allow incoming funds. 382 | 6. Binds the `money` event. 383 | 7. Dispatches something to the database or some kind of store whenever a `money` event triggers. 384 | 385 | ```js 386 | streamServer.on('connection', connection => { 387 | const metadata = JSON.parse(this.connectionTag.decode(connection.connectionTag)) 388 | connection.on('stream', stream => { 389 | stream.setReceiveMax(Infinity) 390 | stream.on('money', amount => { 391 | console.log('received money', amount) 392 | }) 393 | }) 394 | }) 395 | ``` 396 | 397 | ## Configure the STREAM library as a sender for Web Monetization 398 | 399 | This section provides some best practices for configuring the STREAM library as a sender for Web Monetization. In this scenario, bidirectional communication isn’t necessary. The sender repeatedly sends money to the receiver, but does not expect money or data in return. 400 | 401 | This section assumes you have an ILP uplink, meaning you have a peer and an arrangement for settlement, or you connect to a system that performs settlements for you and acts as the source of truth. 402 | 403 | All STREAM payments for a Web Monetized page visit must happen over a single stream. 404 | 405 | ### Best practices 406 | 407 | #### Expiration due to clock skew 408 | 409 | When initializing the sender connection (`createConnection`), pass in a `getExpiry` function that returns a far-future expiry (`FAR_FUTURE_EXPIRY`). This ensures that packets won’t unexpectedly expire if the user’s OS clock is skewed. The next connector will reduce the expiry before passing it on, preventing packets from hanging forever. 410 | 411 | #### Slippage 412 | 413 | Senders should set `slippage: 1.0`. 414 | 415 | Exchange rates are irrelevant to Web Monetization because there’s no target amount that the sender is trying to get to the receiver. 416 | 417 | If you **do** need to consider exchange rates, you can verify that the connection’s rate is acceptable by calling `connection.minimumAcceptableExchangeRate` before sending any money. 418 | 419 | ### Web Monetization lifecycle 420 | 421 | 1. The sender opens a connection using `create.Connection`. 422 | 2. The sender opens a stream using `connection.createStream`. 423 | 3. The sender begins paying using `stream.setSendMax(cumulativeAmountToSend)`. 424 | > The sender’s `stream.on(‘outgoing_money’)` event emits every time money is successfully delivered to the recipient. 425 | 4. After some time passes, the sender can increase the `setSendMax` according to their application’s requirements. 426 | 427 | If any step fails, or the connection dies, terminate whatever is left of the connection and plugin and start over. Keep track of how much was sent on the old connection with `connection.totalSent` or `connection.totalDelivered`. 428 | 429 | ### Example 430 | 431 | The following source file is part of the Coil Extension. Lines 303 - 493 can be used as a starting point for configuring the STREAM library as a sender: https://github.com/coilhq/web-monetization-projects/blob/master/packages/coil-extension/src/background/services/Stream.ts#L303-L493 432 | 433 | # How STREAM handles assets 434 | 435 | An asset is comprised three parts: 436 | 437 | * Amount - The quantity of the asset expressed as an integer greater than or equal to zero 438 | * Asset code - A code, typically three characters, that identifies the amount's unit (e.g., USD, XRP, EUR, BTC) 439 | * Asset scale - The number of places past the decimal for the amount 440 | 441 | For example: 442 | 443 | ```js 444 | amount: "7567", 445 | assetCode: "USD", 446 | assetScale: 2 447 | ``` 448 | 449 | We have an amount of 7567 that's denominated in US dollars. Since the asset scale is 2, it means that we're working with $75.67. If the asset scale was 3, then we'd have $7.567 (~$7.57), and so on. 450 | 451 | In the next example, the amount is 1,000,000,000. Since the scale is 9, we're working with 1 XRP. 452 | 453 | ```js 454 | amount: "1000000000", 455 | assetCode: "XRP", 456 | assetScale: 9 457 | ``` 458 | 459 | The sender can send a currency that’s different from what the receiver wants to receive. As the Stream API communicates sender and receiver currency details, one or more intermediaries on the Interledger network convert the currency automatically. Connectors are one type of intermediary that can perform currency conversions. 460 | 461 | ## Connectors 462 | 463 | One example of an intermediary is a connector. A connector is a node between a sender and receiver that receives streams, performs currency conversions, then forwards the stream with the revised amount to the next node. Each connector has a direct peering relationship with other nodes. Each peering relationship has a single currency associated with it. The next node to receive a stream could be another connector, if additional conversions are needed, or the destination. Connectors themselves might generate revenue from spreads on currency conversion, through subscription fees, or other means. 464 | 465 | ## Determine an exchange rate (optional) 466 | 467 | A stream’s exchange rate can be determined experimentally by sending test packets to the destination. The destination responds with the amount they received in the stream. That rate can be used as a baseline for how much the sender can expect to be delivered. 468 | 469 | # STREAM receipts 470 | 471 | STREAM receipts can be generated by the receiver to serve as proof of the total amount received on streams. In Web Monetization, for example, receipts allow you to verify payments before securely serving exclusive content. 472 | 473 | Standardized receipt functionality at the STREAM layer enables the payment to be verified without needing to access the corresponding Interledger packets. This verification is typically handled by a third-party verifier. 474 | 475 | At a high-level, STREAM receipts work as follows: 476 | 477 | 1. A receipt secret and receipt nonce are communicated to the receiver by a verifier over some channel, such as SPSP. These parameters are used to generate connection details, which are required before the STREAM connection can be established. 478 | 2. After a STREAM connection is established and the sender is sending, the receiver returns a STREAM receipt to the sender each time a packet is sent, indicating the total amount of money received in each stream. 479 | 3. The receipts returned by the receiver are exposed by the STREAM client on `stream.receipt`. The receipts may be submitted directly or indirectly to the verifier. 480 | 4. Each receipt on a stream contains a progressively higher amount, representing the total amount received on the stream. This means the latest receipt on a stream replaces any previous receipts on that stream. 481 | 482 | For more information, see the [STREAM Receipts RFC](https://interledger.org/rfcs/0039-stream-receipts/). 483 | --------------------------------------------------------------------------------