diff --git a/client.js b/client.js new file mode 100644 index 0000000..4a4474c --- /dev/null +++ b/client.js @@ -0,0 +1,59 @@ +const IlpPacket = require('ilp-packet') +const Plugin = require('ilp-plugin-xrp-escrow') +const crypto = require('crypto') +const fetch = require('node-fetch') +const uuid = require('uuid/v4') +function base64 (buf) { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } +function sha256 (secret) { return crypto.createHash('sha256').update(secret).digest() } +function hmac (secret, input) { return crypto.createHmac('sha256', secret).update(input).digest() } + +const plugin = new Plugin({ + secret: 'sndb5JDdyWiHZia9zv44zSr2itRy1', + account: 'rGtqDAJNTDMLaNNfq1RVYgPT8onFMj19Aj', + server: 'wss://s.altnet.rippletest.net:51233', + prefix: 'test.crypto.xrp.' +}) + +let counter = 0 + +function sendTransfer (obj) { + obj.id = uuid() + obj.from = plugin.getAccount() + // to + obj.ledger = plugin.getInfo().prefix + // amount + // executionCondition + obj.expiresAt = new Date(new Date().getTime() + 1000000).toISOString() + return plugin.sendTransfer(obj).then(function () { + return obj.id + }) +} + +plugin.connect().then(function () { + return fetch('http://localhost:8000/') +}).then(function (inRes) { + inRes.body.pipe(process.stdout) + const payHeaderParts = inRes.headers.get('Pay').split(' ') + console.log(payHeaderParts) + // e.g. Pay: 1 test.crypto.xrp.asdfaqefq3f.26wrgevaew SkTcFTZCBKgP6A6QOUVcwWCCgYIP4rJPHlIzreavHdU + setInterval(function () { + const ilpPacket = IlpPacket.serializeIlpPayment({ + account: payHeaderParts[1] + '.' + (++counter), + amount: '1', + data: '' + }) + const fulfillmentGenerator = hmac(Buffer.from(payHeaderParts[2], 'base64'), 'ilp_psk_condition') + const fulfillment = hmac(fulfillmentGenerator, ilpPacket) + const condition = sha256(fulfillment) + sendTransfer({ + to: payHeaderParts[1], + amount: '1', + executionCondition: base64(condition), + ilp: base64(ilpPacket) + }).then(function () { + // console.log('transfer sent') + }).catch(function (err) { + console.error(err.message) + }) + }, 500) +}) diff --git a/client2.js b/client2.js new file mode 100644 index 0000000..0176b40 --- /dev/null +++ b/client2.js @@ -0,0 +1,58 @@ +const IlpPacket = require('ilp-packet') +const Plugin = require('ilp-plugin-payment-channel-framework') +const crypto = require('crypto') +const fetch = require('node-fetch') +const uuid = require('uuid/v4') +function base64 (buf) { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } +function sha256 (secret) { return crypto.createHash('sha256').update(secret).digest() } +function hmac (secret, input) { return crypto.createHmac('sha256', secret).update(input).digest() } + +const plugin = new Plugin({ + server: 'btp+ws://:@localhost:9000/' +}) + +let counter = 0 + +function sendTransfer (obj) { + obj.id = uuid() + obj.from = plugin.getAccount() + // to + obj.ledger = plugin.getInfo().prefix + // amount + // executionCondition + obj.expiresAt = new Date(new Date().getTime() + 1000000).toISOString() + // console.log('calling sendTransfer!', obj) + return plugin.sendTransfer(obj).then(function () { + return obj.id + }) +} + +plugin.connect().then(function () { + console.log('plugin connected') + return fetch('http://localhost:8000/') +}).then(function (inRes) { + inRes.body.pipe(process.stdout) + const payHeaderParts = inRes.headers.get('Pay').split(' ') + console.log(payHeaderParts) + // e.g. Pay: 1 test.crypto.xrp.asdfaqefq3f.26wrgevaew SkTcFTZCBKgP6A6QOUVcwWCCgYIP4rJPHlIzreavHdU + setInterval(function () { + const ilpPacket = IlpPacket.serializeIlpPayment({ + account: payHeaderParts[1] + '.' + (++counter), + amount: '1', + data: '' + }) + const fulfillmentGenerator = hmac(Buffer.from(payHeaderParts[2], 'base64'), 'ilp_psk_condition') + const fulfillment = hmac(fulfillmentGenerator, ilpPacket) + const condition = sha256(fulfillment) + sendTransfer({ + to: payHeaderParts[1], + amount: '1', + executionCondition: base64(condition), + ilp: base64(ilpPacket) + }).then(function () { + // console.log('transfer sent') + }).catch(function (err) { + console.error(err.message) + }) + }, 1) +}) diff --git a/index.md b/index.md index 96d4177..e6d1b4c 100644 --- a/index.md +++ b/index.md @@ -7,6 +7,8 @@ first time. The main programming language used is JavaScript. ## Tutorials * [The Letter Shop](./letter-shop) +* [Streaming Payments](./streaming-payments) +* [Trustlines](./trustlines) ## Versioning @@ -20,6 +22,8 @@ Interledger Requests For Comments (IL-RFCs): * [IL-RFC-15, draft 1](https://interledger.org/rfcs/0015-ilp-addresses/draft-1.html): Interledger Addresses * [IL-RFC-22, draft 1](https://interledger.org/rfcs/0022-hashed-timelock-agreements/draft-1.html): Hashed Time Lock Agreements * [IL-RFC-19, draft 1](https://interledger.org/rfcs/0019-glossary/draft-1.html): Glossary +* [IL-RFC-16, draft 3](https://interledger.org/rfcs/0016-pre-shared-key/draft-3.html): Pre-Shared Key (PSK) +* [IL-RFC-23, draft 2](https://interledger.org/rfcs/0023-bilateral-transfer-protocol/draft-2.html): Bilateral Transfer Protocol (BTP) The software you will build during these tutorials will be compatible with software written by other developers, on several levels: diff --git a/shop2.js b/shop2.js new file mode 100644 index 0000000..3c6e581 --- /dev/null +++ b/shop2.js @@ -0,0 +1,56 @@ +const IlpPacket = require('ilp-packet') +const http = require('http') +const crypto = require('crypto') +const Plugin = require('ilp-plugin-xrp-escrow') +function base64 (buf) { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } +function sha256 (preimage) { return crypto.createHash('sha256').update(preimage).digest() } +function hmac (secret, input) { return crypto.createHmac('sha256', secret).update(input).digest() } + +let users = {} + +const plugin = new Plugin({ + secret: 'ssGjGT4sz4rp2xahcDj87P71rTYXo', + account: 'rrhnXcox5bEmZfJCHzPxajUtwdt772zrCW', + server: 'wss://s.altnet.rippletest.net:51233', + prefix: 'test.crypto.xrp.' +}) + +plugin.connect().then(function () { + plugin.on('incoming_prepare', function (transfer) { + const ilpPacket = Buffer.from(transfer.ilp, 'base64') + const ilpPacketContents = IlpPacket.deserializeIlpPayment(ilpPacket) + const parts = ilpPacketContents.account.split('.') + // 0: test, 1: crypto, 2: xrp, 3: rrhnXcox5bEmZfJCHzPxajUtwdt772zrCW, 4: userId, 5: paymentId + if (parts.length < 6 || typeof users[parts[4]] === 'undefined' || ilpPacketContents.amount !== transfer.amount) { + plugin.rejectIncomingTransfer(transfer.id, {}).catch(function () {}) + } else { + const { secret, res } = users[parts[4]] + const fulfillmentGenerator = hmac(secret, 'ilp_psk_condition') + const fulfillment = hmac(fulfillmentGenerator, ilpPacket) + const condition = sha256(fulfillment) + if (transfer.executionCondition === base64(condition)) { + plugin.fulfillCondition(transfer.id, base64(fulfillment)).then(function () { + const letter = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ').split('')[(Math.floor(Math.random() * 26))] + res.write(letter) + }).catch(function (err) { + console.error(err.message) + }) + } else { + console.log('no match!', { secret, fulfillment, condition, transfer }) + } + } + }) + + http.createServer(function (req, res) { + const secret = crypto.randomBytes(32) + const user = base64(crypto.randomBytes(8)) + users[user] = { secret, res } + console.log('user! writing head', user) + res.writeHead(200, { + 'Pay': [ 1, plugin.getAccount() + '.' + user, base64(secret) ].join(' ') + }) + // Flush the headers in a first TCP packet: + res.socket.write(res._header) + res._headerSent = true + }).listen(8000) +}) diff --git a/shop3.js b/shop3.js new file mode 100644 index 0000000..d5ca477 --- /dev/null +++ b/shop3.js @@ -0,0 +1,69 @@ +const IlpPacket = require('ilp-packet') +const http = require('http') +const crypto = require('crypto') +const Plugin = require('ilp-plugin-payment-channel-framework') +function base64 (buf) { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } +function sha256 (secret) { return crypto.createHash('sha256').update(secret).digest() } +function hmac (secret, input) { return crypto.createHmac('sha256', secret).update(input).digest() } + +let users = {} +const store = {} +const plugin = new Plugin({ + listener: { + port: 9000 + }, + incomingSecret: '', + maxBalance: '1000000000', + prefix: 'example.letter-shop.mytrustline.', + info: { + currencyScale: 9, + currencyCode: 'XRP', + prefix: 'example.letter-shop.mytrustline.', + connectors: [] + }, + _store: { // in-memory store for demo purposes + get: (k) => store[k], + put: (k, v) => { store[k] = v }, + del: (k) => delete store[k] + } +}) + +plugin.connect().then(function () { + plugin.on('incoming_prepare', function (transfer) { + const ilpPacket = Buffer.from(transfer.ilp, 'base64') + const ilpPacketContents = IlpPacket.deserializeIlpPayment(ilpPacket) + const parts = ilpPacketContents.account.split('.') + // 0: test, 1: crypto, 2: xrp, 3: rrhnXcox5bEmZfJCHzPxajUtwdt772zrCW, 4: userId, 5: paymentId + if (parts.length < 6 || typeof users[parts[4]] === 'undefined' || ilpPacketContents.amount !== transfer.amount) { + plugin.rejectIncomingTransfer(transfer.id, {}).catch(function () {}) + } else { + const { secret, res } = users[parts[4]] + const fulfillmentGenerator = hmac(secret, 'ilp_psk_condition') + const fulfillment = hmac(fulfillmentGenerator, ilpPacket) + const condition = sha256(fulfillment) + if (transfer.executionCondition === base64(condition)) { + plugin.fulfillCondition(transfer.id, base64(fulfillment)).then(function () { + const letter = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ').split('')[(Math.floor(Math.random() * 26))] + res.write(letter) + }).catch(function (err) { + console.error(err.message) + }) + } else { + console.log('no match!', { secret, fulfillment, condition, transfer }) + } + } + }) + + http.createServer(function (req, res) { + const secret = crypto.randomBytes(32) + const user = base64(crypto.randomBytes(8)) + users[user] = { secret, res } + console.log('user! writing head', user) + res.writeHead(200, { + 'Pay': [ 1, plugin.getAccount() + '.' + user, base64(secret) ].join(' ') + }) + // Flush the headers in a first TCP packet: + res.socket.write(res._header) + res._headerSent = true + }).listen(8000) +}) diff --git a/streaming-payments.md b/streaming-payments.md new file mode 100644 index 0000000..850af37 --- /dev/null +++ b/streaming-payments.md @@ -0,0 +1,139 @@ +# Streaming Tutorial + +## What you need before you start: + +* complete the [Letter Shop](./letter-shop) tutorial first + +## What you'll learn: + +* convert the proxy from the Letter Shop tutorial into a http-ilp client +* using the `Pay` header in your shop and your client +* using the ILP packet +* streaming payments +* deterministically picking a hashlock condition based on a Pre-Shared Key (PSK) + +## The Pay Header + +We'll change the Letter Shop from the previous tutorial a bit, to `shop2.js`. Instead of +using a human-readable "Payment Required" message that starts with "Please ...", we will +now use a machine-readable http header, and a fulfillment/condition pair that is generated +deterministically from a secret that's shared between the shop and the client. + +Starting with the last part, `http.createServer`, you can see the flow of the http server +is a bit simpler; when a request comes in it sends headers, and +then the body will be sent letter-by-letter, as payments come in: + +```js +res.writeHead(200, { + 'Pay': [ 1, plugin.getAccount() + '.' + user, base64(secret) ].join(' ') +}) +// Flush the headers in a first TCP packet: +res.socket.write(res._header) +res._headerSent = true +``` + +The `Pay` header contains 3 parts: + +* amount (in this case, the price of one letter, in XRP) +* user-specific destination address +* a Base64-encoded shared secret for use with [PSK](https://interledger.org/rfcs/0016-pre-shared-key/draft-3.html) + +Note how we are appending `'.' + user` to the shop's Interledger address! This is a special feature of Interledger +addresses, they can be subnetted endlessly, just add another `.` at the end to convert an account address +to a ledger prefix, and then add new sub-accounts after that. In this case, we want to know which user is paying +for letters, so by telling each user a different Interledger sub-address, we can neatly keep our users apart. + +When a transfer comes in, the server opens the [ILP packet](https://interledger.org/rfcs/0003-interledger-protocol/draft-4.html#ilp-payment-packet-format): +```js +const ilpPacket = Buffer.from(transfer.ilp, 'base64') +const ilpPacketContents = IlpPacket.deserializeIlpPayment(ilpPacket) +const parts = ilpPacketContents.account.split('.') +// 0: test, 1: crypto, 2: xrp, 3: rrhnXcox5bEmZfJCHzPxajUtwdt772zrCW, 4: userId, 5: paymentId +``` + +In there is a destination account and a destination amount, which will usually (if all went well) be equal +to the amount of the transfer. In later tutorials, we will see how transfers can be chained together +into one ILP payment, but for now, we will only use single-transfer payments. + +Instead of (like the Letter Shop from the previous tutorial did) remembering the random fulfillment strings +to use, this version of the shop uses PSK to derive the exact fulfillment bytes from the ILP packet and the +shared secret which it previously sent to this user base64-encoded in the `Pay` header: + +```js +const { secret, res } = users[parts[4]] +const fulfillmentGenerator = hmac(secret, 'ilp_psk_condition') +const fulfillment = hmac(fulfillmentGenerator, ilpPacket) +const condition = sha256(fulfillment) +if (transfer.executionCondition === base64(condition)) { + // ... +``` + +To run this new version of the shop, clone or download https://github.com/interledgerjs/tutorials, `cd` into that folder, and type this into your terminal: + +```sh +npm install +node ./shop2.js +``` + +The ILP packet is not very useful in a one-transfer payment, but in future tutorials, we see how connectors can forward an +incoming transfer, and thus connect one ledger to other ledgers. When that happens, we say the whole sender +to receiver process is the "payment", and each link in the chain is a "transfer", so one payment consists of +one or more transfers. The sender can then put information in the ILP packet which the receiver can use +to generate the fulfillment, and if that information would be tampered with by connectors along the way, +this would be detected because the fulfillment would not match. +The ILP Payment Packet is serialized into OER and then Base64-encoded before it's added to the transfer as a Memo. +Note that this assumes that either the ledger supports adding a memo to the transfer, or there is some out-of-band +communication channel, but luckily, most ledgers do support annotating transfers with some sort of custom data. + +## Http-ilp client + +The following is mainly a mix between the `pay.js` and `proxy.js` scripts from the Letter Shop tutorial, +that can pay for content in reaction to an `Pay` header, and then stream that content to the console +as it comes in. Have a look at `client.js`. As you can see, it parses the `Pay` header, and then sends one XRP-drop per 500ms. +This is a naive implementation, that will pay any amount asked of it. + +```js +plugin.connect().then(function () { + return fetch('http://localhost:8000/') +}).then(function (inRes) { + inRes.body.pipe(process.stdout) + const payHeaderParts = inRes.headers.get('Pay').split(' ') + console.log(payHeaderParts) + // e.g. Pay: 1 test.crypto.xrp.asdfaqefq3f.26wrgevaew SkTcFTZCBKgP6A6QOUVcwWCCgYIP4rJPHlIzreavHdU + setInterval(function () { + const ilpPacket = IlpPacket.serializeIlpPayment({ + account: payHeaderParts[1] + '.' + (++counter), + amount: '1', + data: '' + }) + const fulfillmentGenerator = hmac(Buffer.from(payHeaderParts[2], 'base64'), 'ilp_psk_condition') + const fulfillment = hmac(fulfillmentGenerator, ilpPacket) + const condition = sha256(fulfillment) + sendTransfer({ + to: payHeaderParts[1], + amount: '1', + executionCondition: base64(condition), + ilp: base64(ilpPacket) + }).then(function () { + // console.log('transfer sent') + }).catch(function (err) { + console.error(err.message) + }) + }, 500) +}) +``` + +Try it out! + +```sh +$ node ./client.js +``` + +After some startup time, you should see one letter per 500ms being printed. + +## What you learned + +We used the ILP packet for the first time, talked about connectors, payments as chains of transfers, and used +the `Pay` header, so that the shop could request payment from the client that connects to it. +We also saw how with PSK, endless fulfillment/condition pairs can be derived, while only having to share one single secret +between the sender and the receiver of an Interledger payment. diff --git a/trustlines.md b/trustlines.md new file mode 100644 index 0000000..fa14ac7 --- /dev/null +++ b/trustlines.md @@ -0,0 +1,97 @@ +# Trustlines Tutorial + +## What you need before you start: + +* complete the [Streaming Payments](./streaming-payments) tutorial first + +## What you'll learn: + +* how to use trustlines to speed things up +* [Bilateral Transfer Protocol (BTP)](https://interledger.org/rfcs/0023-bilateral-transfer-protocol/draft-2.html) and its relation to ILP + +## Using a trustline + +Getting one letter per 500ms is not very fast. It would be nice if we could stream the money faster, so that +the content arrives faster! For this, we can add a ledger to the shop. The client opens an account on this ledger, +and then pays for letters from its account at the shop's ledger, which will be much faster +than paying via the XRP ledger. We call such a private ledger (which doesn't involve a trusted third party) a "trustline". + +There are two types of trustline, symmetrical and asymmetrical: + +> An asymmetrical trustline is a ledger with two account holders, and one of them is also the ledger administrator. + +> A symmetrical trustline is a ledger with two account holders, who collaborate on an equal basis to administer the ledger between them. + +The shop's ledger will expose the Bilateral Transfer Protocol (BTP), which is an optimization of the Ledger Plugin Interface (LPI) +that we already saw in the Letter Shop tutorial, transported over a WebSocket. +These BTP packets are similar to the objects passed to `plugin.sendTransfer` or `plugin.fulfillCondition`, +although they are a bit more concise, and before they go onto the WebSocket, they are serialized into OER buffers. + +Once a BTP connection has been established between two peers, ILP payments can move back and forth over it in both directions. +In our case though, the ILP receiver (the shop) will be a BTP server, and the ILP sender will be a BTP client. +To learn more about the BTP protocol, read [the BTP spec](https://interledger.org/rfcs/0023-bilateral-transfer-protocol/draft-2.html). + +Thanks to the plugin architecture, we have to change surprisingly little to switch from XRP to BTP: we just include the +`'ilp-plugin-payment-channel-framework'` plugin instead of the `'ilp-plugin-xrp-escrow'` one, and give it the config options +it needs. You can see that here in the `shop3.js` script, which includes the BTP-enabled ledger; run `diff shop2.js shop3.js` +to see how similar they really are; `shop3.js` uses a different plugin: + +```js +const Plugin = require('ilp-plugin-payment-channel-framework') +``` + +... and different plugin constructor options: + +```js +const plugin = new Plugin({ + listener: { + port: 9000 + }, + incomingSecret: '', + maxBalance: '1000000000', + prefix: 'example.letter-shop.mytrustline.', + info: { + currencyScale: 9, + currencyCode: 'XRP', + prefix: 'example.letter-shop.mytrustline.', + connectors: [] + }, + _store: { + get: (k) => store[k], + put: (k, v) => { store[k] = v }, + del: (k) => delete store[k] + } +}) +``` + +To run it, use: + +```sh +npm install interledgerjs/ilp-plugin-payment-channel-framework +node ./shop3.js +``` + +And `client2.js` is the content consumption client, which now also uses BTP instead of XRP for the plugin: + +```js +const Plugin = require('ilp-plugin-payment-channel-framework') +``` + +... and its constructor options: + +```js +const plugin = new Plugin({ + server: 'btp+ws://:@localhost:9000/' +}) +``` + +To run it, use: + +```sh +node ./client2.js +``` + +## What you learned + +We added a BTP-enabled ledger to the shop, so that our content consumption client can receive letters faster. +