├── .gitignore ├── .babelrc ├── seeder.js ├── downloader.js ├── package.json ├── README.md └── src ├── decider.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-2" 5 | ], 6 | "plugins": [ 7 | "add-module-exports" 8 | ], 9 | "sourceMaps": "inline" 10 | } 11 | -------------------------------------------------------------------------------- /seeder.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const WebTorrentIlp = require('./build/index') 4 | const debug = require('debug')('WebTorrentIlp:seeder') 5 | const crypto = require('crypto') 6 | 7 | const seeder = new WebTorrentIlp({ 8 | address: process.env.ADDRESS || 'walt@red.ilpdemo.org', 9 | password: process.env.PASSWORD || 'walt', 10 | publicKey: crypto.randomBytes(32).toString('base64'), 11 | price: process.env.PRICE || '0.00000000001' 12 | }) 13 | 14 | const file = process.argv.length > 2 ? process.argv[2] : '/Users/eschwartz/Downloads/interledger.pdf' 15 | 16 | const seederTorrent = seeder.seed(file, { 17 | announceList: [['http://localhost:8000/announce']], 18 | private: true 19 | }) 20 | 21 | seeder.on('torrent', function (torrent) { 22 | console.log('seeding torrent ' + torrent.infoHash + ' ' + torrent.magnetURI) 23 | }) 24 | -------------------------------------------------------------------------------- /downloader.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const WebTorrentIlp = require('./build/index') 4 | const debug = require('debug')('WebTorrentIlp:downloader') 5 | const crypto = require('crypto') 6 | 7 | const leecher = new WebTorrentIlp({ 8 | address: process.env.ADDRESS || 'alice@blue.ilpdemo.org', 9 | password: process.env.PASSWORD || 'alice', 10 | publicKey: crypto.randomBytes(32).toString('base64'), 11 | price: process.env.PRICE || '0.00000000001' 12 | }) 13 | 14 | const magnetURI = process.argv.length > 2 ? process.argv[2] : 'magnet:?xt=urn:btih:eca3080363229696b44f99f12e1cab902965777d&dn=interledger.pdf&tr=http%3A%2F%2Flocalhost%3A8000%2Fannounce' 15 | 16 | const leecherTorrent = leecher.add(magnetURI, { 17 | announceList: [['http://localhost:8000/announce']] 18 | }) 19 | leecherTorrent.on('done', function () { 20 | debug('leecher done, downloaded ' + leecherTorrent.files.length + ' files') 21 | }) 22 | leecherTorrent.on('wire', function (wire) { 23 | debug('on wire') 24 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webtorrent-ilp", 3 | "version": "1.0.0", 4 | "description": "WebTorrent + ILP", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "babel src -d build", 9 | "watch": "babel src -w -d build", 10 | "prepublish": "npm run build", 11 | "postinstall": "postinstall-build build 'npm run build'", 12 | "lint": "standard src/*" 13 | }, 14 | "keywords": [ 15 | "ilp", 16 | "interledger", 17 | "webtorrent", 18 | "bittorrent", 19 | "micropayment" 20 | ], 21 | "author": "Evan Schwartz ", 22 | "license": "ISC", 23 | "dependencies": { 24 | "bignumber.js": "^2.3.0", 25 | "five-bells-wallet-client": "^1.0.1", 26 | "js-data": "^2.9.0", 27 | "moment": "^2.12.0", 28 | "postinstall-build": "^0.2.1", 29 | "uuid": "^2.0.1", 30 | "webtorrent": "^0.94.3", 31 | "wt_ilp": "https://github.com/emschwartz/wt_ilp.git" 32 | }, 33 | "devDependencies": { 34 | "babel-cli": "^6.6.5", 35 | "babel-plugin-add-module-exports": "^0.1.2", 36 | "babel-preset-es2015": "^6.6.0", 37 | "babel-preset-stage-2": "^6.5.0", 38 | "standard": "^6.0.8" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebTorrent-ILP 2 | 3 | > A modified version of the [WebTorrent](https://github.com/feross/webtorrent) client that pays content creators and seeders using the [Interledger Protocol (ILP)](https://interledger.org). 4 | 5 | To see it in action go to https://instant-io-ilp.herokuapp.com/ 6 | 7 | ## Installation 8 | 9 | ```sh 10 | npm install https://github.com/emschwartz/webtorrent-ilp 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```js 16 | 'use strict' 17 | 18 | const WebTorrentIlp = require('webtorrent-ilp') 19 | const client = new WebTorrentIlp({ 20 | address: 'alice@red.ilpdemo.org', 21 | password: 'alice', 22 | price: '0.0001', // per kilobyte 23 | // publicKey: 'Hq/8TtlPg0E+8ThqQV8ZL3aPGsMXg9jmpWyZLtlpCkg=' // ed25519 public key, optional 24 | // Options will also be passed through to WebTorrent 25 | }) 26 | 27 | client.seed(...) 28 | client.download(...) 29 | // The client will take care of paying for the licenses and content automagically 30 | 31 | // See WebTorrent docs for more on usage: https://github.com/feross/webtorrent/blob/master/docs/get-started.md 32 | ``` 33 | 34 | ## API 35 | 36 | ### client = new WebTorrentIlp([opts]) 37 | 38 | See [WebTorrent docs](https://github.com/feross/webtorrent/blob/master/docs/api.md#client--new-webtorrentopts) 39 | 40 | ### client.seed 41 | 42 | See [WebTorrent docs](https://github.com/feross/webtorrent/blob/master/docs/api.md#clientseedinput-opts-function-onseed-torrent-) 43 | 44 | ### client.download 45 | 46 | See [WebTorrent docs](https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-) 47 | 48 | ### Events 49 | 50 | #### `'wallet_ready'` 51 | 52 | ```js 53 | client.on('wallet_ready', function () { 54 | // client is ready to send payments and receive notifications of incoming payments 55 | }) 56 | ``` 57 | 58 | #### `'incoming_payment'` 59 | 60 | ```js 61 | client.on('incoming_payment', function (paymentDetails) { 62 | console.log(paymentDetails.peerPublicKey) // 'Cyh5tHkF6G1lJUAFSSOm1NfYAn3nYLW8k+lNRL2JjFQ='' 63 | console.log(paymentDetails.amount) // '0.01' 64 | }) 65 | ``` 66 | 67 | #### `'license'` 68 | 69 | ```js 70 | client.on('license', function (torrentHash, license) { 71 | console.log(torrentHash) // '567820be738f5b33f515884d1e059ad68bc96e3f' 72 | console.log(JSON.stringify(license)) // '{ "creator_account": "https://red.ilpdemo.org/ledger/accounts/alice", "creator_public_key": "r/MV0THsvdcUAw7Y8x8ca2/dEc8gXRQNDapQ6xFUG3E=", "license_type": "https://interledger.org/licenses/1.0/mpay", "price_per_minute": "0.0001", "expires_at": "2016-04-09T23:39:59.153Z", "signature": "dqT0wqOxg8mt6fOuRe03NuaVKrXIo07IcwGuR4cqw9aeJ6lq0psg86bDIEKYB1qRaXX8iIrm8cnWi+eViqJDBg==" }' 73 | }) 74 | ``` 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/decider.js: -------------------------------------------------------------------------------- 1 | import Debug from 'debug' 2 | const debug = Debug('WebTorrentIlp:Decider') 3 | import JSData from 'js-data' 4 | import uuid from 'uuid' 5 | import BigNumber from 'bignumber.js' 6 | import moment from 'moment' 7 | 8 | export default class Decider { 9 | constructor (opts) { 10 | this.store = new JSData.DS() 11 | // TODO don't use uuids, maybe use incrementing numbers to reduce memory 12 | // TODO use relations to avoid storing the publicKey and torrentHash many times over 13 | this.Payment = this.store.defineResource({ 14 | name: 'payment', 15 | computed: { 16 | id: ['id', (id) => id || uuid.v4()] 17 | } 18 | }) 19 | this.PaymentRequest = this.store.defineResource({ 20 | name: 'payment_request', 21 | computed: { 22 | id: ['id', (id) => id || uuid.v4()] 23 | } 24 | }) 25 | this.Delivery = this.store.defineResource({ 26 | name: 'delivery', 27 | computed: { 28 | id: ['id', (id) => id || uuid.v4()] 29 | } 30 | }) 31 | } 32 | 33 | shouldSendPayment (paymentRequest) { 34 | const { publicKey, torrentHash } = paymentRequest 35 | this.recordPaymentRequest(paymentRequest) 36 | debug('checking if we shouldSendPayment') 37 | 38 | const peerCostPerByte = this.getCostPerByte({ publicKey, torrentHash }) 39 | debug('peerCostPerByte: ' + peerCostPerByte.toString() + ' (' + publicKey.slice(0, 8) + ')') 40 | if (!peerCostPerByte.isFinite()) { 41 | return false 42 | } 43 | const torrentCostPerByte = this.getCostPerByte({ torrentHash }) 44 | debug('torrentCostPerByte: ' + torrentCostPerByte.toString()) 45 | 46 | // Check if there is a cheaper or faster peer 47 | const peerSpeed = this.getSpeed({ publicKey, torrentHash, includeTimeToNow: true }) 48 | const torrentSpeed = this.getSpeed({ torrentHash, includeTimeToNow: true }) 49 | debug('peerSpeed: ' + peerSpeed.toString() + ' (' + publicKey.slice(0, 8) + ')') 50 | debug('torrentSpeed: ' + torrentSpeed.toString()) 51 | 52 | const maxPaymentsPerInterval = 1 53 | const intervalStart = moment().subtract(3, 'seconds') 54 | const numPaymentsInRecentInterval = this.getNumPaymentsSinceDate({ publicKey, torrentHash, date: intervalStart }) 55 | debug('numPaymentsInRecentInterval: ' + numPaymentsInRecentInterval.toString()) 56 | if (numPaymentsInRecentInterval >= maxPaymentsPerInterval) { 57 | return false 58 | } 59 | 60 | // TODO @tomorrow create models for the peers that automatically track their speed and cost 61 | 62 | return true 63 | } 64 | 65 | recordPaymentRequest (paymentRequest) { 66 | debug('Got payment request %o', paymentRequest) 67 | return this.PaymentRequest.inject(paymentRequest) 68 | } 69 | 70 | recordPayment (payment) { 71 | debug('recordPayment %o', payment) 72 | return this.Payment.inject(payment) 73 | } 74 | 75 | recordFailedPayment (paymentId, err) { 76 | debug('recordFailedPayment %o Error: %o', paymentId, err) 77 | return this.Payment.eject(paymentId) 78 | } 79 | 80 | recordDelivery (delivery) { 81 | debug('recordDelivery %o', delivery) 82 | return this.Delivery.inject(delivery) 83 | } 84 | 85 | getTotalSent (filters) { 86 | const payments = this.Payment.filter(filters) 87 | return sum(payments, 'sourceAmount') 88 | } 89 | 90 | getBytesDelivered (filters) { 91 | const deliveries = this.Delivery.filter(filters) 92 | return sum(deliveries, 'bytes') 93 | } 94 | 95 | getCostPerByte (filters) { 96 | const totalSent = this.getTotalSent(filters) 97 | const bytesDelivered = this.getBytesDelivered(filters) 98 | if (totalSent.equals(0)) { 99 | return new BigNumber(0) 100 | } 101 | return totalSent.div(bytesDelivered) 102 | } 103 | 104 | getSpeed ({ publicKey, torrentHash, includeTimeToNow }) { 105 | let query = { 106 | where: { 107 | torrentHash: { 108 | '===': torrentHash 109 | } 110 | }, 111 | orderBy: [['timestamp', 'ASC']] 112 | } 113 | if (publicKey) { 114 | query.where.publicKey = { '===': publicKey } 115 | } 116 | const deliveries = this.Delivery.filter(query) 117 | if (!deliveries || deliveries.length === 0) { 118 | return new BigNumber(0) 119 | } 120 | let timeSpan 121 | if (includeTimeToNow) { 122 | timeSpan = moment().diff(deliveries[0].timestamp) 123 | } else { 124 | timeSpan = moment(deliveries[deliveries.length - 1]).diff(deliveries[0].timestamp) 125 | } 126 | const bytesDelivered = sum(deliveries, 'bytes') 127 | return bytesDelivered.div(timeSpan) 128 | } 129 | 130 | getNumPaymentsSinceDate ({ publicKey, torrentHash, date }) { 131 | let query = { 132 | where: {} 133 | } 134 | if (publicKey) { 135 | query.where.publicKey = { '===': publicKey } 136 | } 137 | if (torrentHash) { 138 | query.where.torrentHash = { '===': torrentHash } 139 | } 140 | if (date) { 141 | query.where.timestamp = { '>=': date.toISOString() } 142 | } 143 | const payments = this.Payment.filter(query) 144 | return payments.length 145 | } 146 | } 147 | 148 | function sum (arr, key) { 149 | let total = new BigNumber(0) 150 | for (let item of arr) { 151 | total = total.plus(item[key]) 152 | } 153 | return total 154 | } 155 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import wt_ilp from 'wt_ilp' 4 | import moment from 'moment' 5 | import BigNumber from 'bignumber.js' 6 | import WalletClient from 'five-bells-wallet-client' 7 | import Debug from 'debug' 8 | const debug = Debug('WebTorrentIlp') 9 | import Decider from './decider' 10 | import uuid from 'uuid' 11 | import WebTorrent from 'webtorrent' 12 | 13 | export default class WebTorrentIlp extends WebTorrent { 14 | constructor (opts) { 15 | super(opts) 16 | 17 | this.address = opts.address 18 | this.password = opts.password 19 | this.price = new BigNumber(opts.price) // price per kb 20 | debug('set price per kb: ' + this.price.toString()) 21 | this.publicKey = opts.publicKey 22 | 23 | this.startingBid = opts.startingBid || this.price.times(100) 24 | this.bidDecreaseFactor = opts.bidDecreaseFactor || 0.95 25 | this.bidIncreaseFactor = opts.bidIncreaseFactor || 2 26 | 27 | this.decider = new Decider() 28 | 29 | this.walletClient = new WalletClient({ 30 | address: opts.address, 31 | password: opts.password 32 | }) 33 | this.walletClient.connect() 34 | .then(() => this.emit('wallet_ready')) 35 | this.walletClient.on('incoming', this._handleIncomingPayment.bind(this)) 36 | 37 | // : 38 | this.peerBalances = {} 39 | // : <[wire, wire]> 40 | this.peerWires = {} 41 | } 42 | 43 | seed () { 44 | const torrent = WebTorrent.prototype.seed.apply(this, arguments) 45 | this._setupTorrent(torrent) 46 | return torrent 47 | } 48 | 49 | add () { 50 | const torrent = WebTorrent.prototype.add.apply(this, arguments) 51 | this._setupTorrent(torrent) 52 | return torrent 53 | } 54 | 55 | _getPeerBalance (wire) { 56 | const peerPublicKey = wire.wt_ilp.peerPublicKey 57 | return this.peerBalances[peerPublicKey] || new BigNumber(0) 58 | } 59 | 60 | _checkUnchokeWire (wire) { 61 | // Check they have enough balance if we're seeding to them 62 | if (this._getPeerBalance(wire).lessThanOrEqualTo(0)) { 63 | return 64 | } 65 | 66 | wire.wt_ilp.unchoke() 67 | } 68 | 69 | _setupWire (torrent, wire) { 70 | wire.bidAmount = this.price.times(this.startingBid) 71 | debug('starting bid amount: ' + wire.bidAmount.toString()) 72 | 73 | wire.use(wt_ilp({ 74 | account: this.walletClient.accountUri, 75 | publicKey: this.publicKey 76 | })) 77 | wire.wt_ilp.on('ilp_handshake', (handshake) => { 78 | debug('Got extended handshake', handshake) 79 | if (!this.peerWires[handshake.publicKey]) { 80 | this.peerWires[handshake.publicKey] = [] 81 | } 82 | this.peerWires[handshake.publicKey].push(wire) 83 | }) 84 | 85 | // Charge peers for requesting data from us 86 | wire.wt_ilp.on('request', this._chargePeerForRequest.bind(this, wire, torrent)) 87 | wire.wt_ilp.on('payment_request_too_high', (amount) => { 88 | debug('Got payment_request_too_high' + (amount ? ' ' + amount : '')) 89 | wire.bidAmount = wire.bidAmount.times(this.bidDecreaseFactor) 90 | }) 91 | 92 | // Pay peers who we are downloading from 93 | wire.wt_ilp.on('payment_request', this._payPeer.bind(this, wire, torrent)) 94 | 95 | wire.on('download', (bytes) => { 96 | debug('downloaded ' + bytes + ' bytes (' + wire.wt_ilp.peerPublicKey.slice(0, 8) + ')') 97 | this.decider.recordDelivery({ 98 | publicKey: wire.wt_ilp.peerPublicKey, 99 | torrentHash: torrent.infoHash, 100 | bytes: bytes, 101 | timestamp: moment().toISOString() 102 | }) 103 | }) 104 | 105 | wire.wt_ilp.on('warning', (err) => { 106 | debug('Error', err) 107 | }) 108 | 109 | wire.wt_ilp.forceChoke() 110 | } 111 | 112 | _setupTorrent (torrent) { 113 | if (torrent.__setupWithIlp) { 114 | return torrent 115 | } 116 | 117 | debug('Setting up torrent with ILP details') 118 | 119 | torrent.totalCost = new BigNumber(0) 120 | 121 | torrent.on('wire', this._setupWire.bind(this, torrent)) 122 | 123 | torrent.on('done', () => { 124 | debug('torrent total cost: ' + this.decider.getTotalSent({ 125 | torrentHash: torrent.infoHash 126 | })) 127 | }) 128 | 129 | torrent.on('error', (err) => { 130 | debug('torrent error:', err) 131 | }) 132 | 133 | torrent.__setupWithIlp = true 134 | } 135 | 136 | _chargePeerForRequest (wire, torrent, bytesRequested) { 137 | const peerPublicKey = wire.wt_ilp.peerPublicKey 138 | const peerBalance = this._getPeerBalance(wire) 139 | 140 | // TODO get smarter about how we price the amount (maybe based on torrent rarity?) 141 | const amountToCharge = this.price.times(bytesRequested / 1000) 142 | debug('peer request costs: ' + amountToCharge.toString()) 143 | 144 | if (peerBalance.greaterThan(amountToCharge)) { 145 | const newBalance = peerBalance.minus(amountToCharge) 146 | this.peerBalances[wire.wt_ilp.peerPublicKey] = newBalance 147 | debug('charging ' + amountToCharge.toString() + ' for request. balance now: ' + newBalance + ' (' + peerPublicKey.slice(0, 8) + ')') 148 | wire.wt_ilp.unchoke() 149 | } else { 150 | // TODO @tomorrow add bidding agent to track how much peer is willing to send at a time 151 | 152 | // If the amount we request up front is too low, the peer will send us money 153 | // then we won't do anything because it'll be less than the amountToCharge 154 | // and then they'll never send us anything again 155 | if (!wire.bidAmount || amountToCharge.greaterThan(wire.bidAmount)) { 156 | wire.bidAmount = amountToCharge 157 | } 158 | 159 | // TODO base the precision on the ledger amount 160 | wire.bidAmount = wire.bidAmount.round(4, BigNumber.ROUND_UP) 161 | 162 | // TODO handle the min ledger amount more elegantly 163 | const MIN_LEDGER_AMOUNT = '0.0001' 164 | wire.wt_ilp.sendPaymentRequest(BigNumber.max(wire.bidAmount, MIN_LEDGER_AMOUNT)) 165 | wire.wt_ilp.forceChoke() 166 | } 167 | } 168 | 169 | _payPeer (wire, torrent, destinationAmount) { 170 | const _this = this 171 | const destinationAccount = wire.wt_ilp.peerAccount 172 | debug('pay peer ' + destinationAccount + ' ' + destinationAmount) 173 | // Convert the destinationAmount into the sourceAmount 174 | 175 | const payment = this.walletClient.payment({ 176 | destinationAccount: destinationAccount, 177 | destinationAmount: destinationAmount, 178 | message: _this.publicKey 179 | }) 180 | return payment.quote() 181 | // Decide if we should pay 182 | .then((params) => { 183 | const paymentRequest = { 184 | sourceAmount: params.sourceAmount, 185 | destinationAccount: destinationAccount, 186 | publicKey: wire.wt_ilp.peerPublicKey, 187 | torrentHash: torrent.infoHash, 188 | torrentBytesRemaining: torrent.length - torrent.downloaded, 189 | timestamp: moment().toISOString() 190 | } 191 | return { 192 | decision: _this.decider.shouldSendPayment(paymentRequest), 193 | paymentRequest 194 | } 195 | }) 196 | // Send payment 197 | .then(({ decision, paymentRequest }) => { 198 | if (decision === true) { 199 | const paymentId = uuid.v4() 200 | 201 | // TODO track stats like this in a better way 202 | torrent.totalCost = torrent.totalCost.plus(paymentRequest.sourceAmount) 203 | 204 | _this.decider.recordPayment({ 205 | ...paymentRequest, 206 | paymentId 207 | }) 208 | debug('About to send payment: %o', payment) 209 | _this.emit('outgoing_payment', { 210 | peerPublicKey: paymentRequest.publicKey, 211 | amount: paymentRequest.sourceAmount.toString() 212 | }) 213 | payment.send() 214 | .then((result) => debug('Sent payment %o', result)) 215 | .catch((err) => { 216 | // If there was an error, subtract the amount from what we've paid them 217 | // TODO make sure we actually didn't pay them anything 218 | debug('Error sending payment %o', err) 219 | _this.decider.recordFailedPayment(paymentId, err) 220 | }) 221 | } else { 222 | debug('Decider told us not to fulfill request %o', paymentRequest) 223 | wire.wt_ilp.sendPaymentRequestTooHigh() 224 | } 225 | }) 226 | } 227 | 228 | _handleIncomingPayment (incoming) { 229 | const peerPublicKey = incoming.message 230 | if (!peerPublicKey) { 231 | return 232 | } 233 | const previousBalance = this.peerBalances[peerPublicKey] || new BigNumber(0) 234 | const newBalance = previousBalance.plus(incoming.destinationAmount) 235 | debug('Crediting peer for payment of: ' + incoming.destinationAmount + '. balance now: ' + newBalance + ' (' + peerPublicKey.slice(0, 8) + ')') 236 | this.peerBalances[peerPublicKey] = newBalance 237 | this.emit('incoming_payment', { 238 | peerPublicKey: peerPublicKey, 239 | amount: incoming.destinationAmount 240 | }) 241 | 242 | // Unchoke all of this peer's wires 243 | for (let wire of this.peerWires[peerPublicKey]) { 244 | wire.unchoke() 245 | wire.bidAmount = wire.bidAmount.times(this.bidIncreaseFactor) 246 | this._checkUnchokeWire(wire) 247 | } 248 | } 249 | } 250 | 251 | // Note that using module.exports instead of export const here is a hack 252 | // to make this work with https://github.com/59naga/babel-plugin-add-module-exports 253 | module.exports.WEBRTC_SUPPORT = WebTorrent.WEBRTC_SUPPORT 254 | --------------------------------------------------------------------------------