├── .gitignore ├── LN-Channel-Opener.js ├── LN-Channel-Watch.js ├── LN-Invoice-Watch.js ├── Order-Expiry-Watch.js ├── README.md ├── Start-Admin.js ├── Start-Express.js ├── Start-LN.js ├── cli ├── create-inventory-item.json ├── get-inventory.js └── update-inventory.js ├── config ├── auth.json.example └── server.json.example ├── ecosystem.config.js ├── package-lock.json ├── package.json ├── src ├── Admin │ └── ChannelAdmin.js ├── Auth │ └── Auth.js ├── Bitcoin │ └── AddressWatch.js ├── Channel │ ├── BuyChannel.js │ ├── ExchangeRate.js │ ├── FinaliseChannel.js │ ├── GetOrder.js │ ├── LnUrlChannel.js │ ├── NodeInfo.js │ ├── PromoChannels.js │ └── ZeroConf.js ├── DB │ └── DB.js ├── Inventory │ └── Inventory.js ├── Orders │ └── Order.js ├── Server │ ├── Endpoints.js │ └── Http.js └── util │ ├── StatusFile.js │ ├── channel-opening-errors.js │ ├── common-workers.js │ ├── exchange-api.js │ ├── lnurl.js │ ├── pricing.js │ └── sats-convert.js ├── swagger.yaml └── test ├── Auth └── Auth.test.js └── Channel ├── BuyChannel.e2e.test.js ├── BuyChannel.test.js ├── Price.test.js ├── PromoChannels.test.js └── test.config.js.example /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | certs 3 | TODO 4 | /config/*.json 5 | data 6 | status 7 | test/Channel/test.config.js -------------------------------------------------------------------------------- /LN-Channel-Opener.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Db = require('./src/DB/DB') 3 | const Order = require('./src/Orders/Order') 4 | const config = require('./config/server.json') 5 | const async = require('async') 6 | const { Client: GrenacheClient } = require('blocktank-worker') 7 | const { lnWorker, callWorker } = require('./src/util/common-workers') 8 | const { find } = require('lodash') 9 | const {parseChannelOpenErr, chanErrors: errors} = require("./src/util/channel-opening-errors") 10 | 11 | console.log('Starting Channel Opener...') 12 | 13 | const MAX_CHANNEL_OPEN_ATTEMPT = config.constants.max_attempt_channel_open 14 | const MAX_CHAN_FEE = 200 15 | 16 | Db((err) => { 17 | if (err) throw err 18 | console.log('Started database') 19 | }) 20 | 21 | async function getPaidOrders () { 22 | const db = await Db() 23 | return db.LnChannelOrders.find({ 24 | state: Order.ORDER_STATES.URI_SET, 25 | created_at: { $gte: Date.now() - 172800000 } 26 | }).limit(10000000).toArray() 27 | } 28 | 29 | async function getProducts (productIds) { 30 | const db = await Db() 31 | return db.Inventory.find({ _id: { $in: productIds } }).toArray() 32 | } 33 | 34 | async function updateOrders (orders) { 35 | 36 | const logInfo = [] 37 | 38 | const update = await Promise.all(orders.map(async ({ order, result }) => { 39 | result.ts = Date.now() 40 | let state 41 | if (order.order_result.length === MAX_CHANNEL_OPEN_ATTEMPT || result.giveup) { 42 | // GIVE UP OPENING CHANNEL 43 | // Tried too many times or node has issues 44 | state = Order.ORDER_STATES.GIVE_UP 45 | order.order_result.push(result) 46 | log.push([ 47 | order._id, 48 | "gave up", 49 | result 50 | ]) 51 | } else if (result.channel_tx && result.channel_tx.transaction_id) { 52 | // CHANNEL IS OPENING 53 | state = Order.ORDER_STATES.OPENING 54 | order.channel_open_tx = result.channel_tx 55 | alert('info', `Opening Channel: ${order._id} - txid: ${JSON.stringify(result.channel_tx, null, 2)}`) 56 | } else { 57 | if (order.order_result.length === 0 || order.order_result[order.order_result.length - 1].channel_error !== result.channel_error) { 58 | order.order_result.push(result) 59 | } else { 60 | order.order_result[order.order_result.length - 1] = result 61 | } 62 | log.push([ 63 | order._id, 64 | "retry", 65 | result 66 | ]) 67 | state = order.state 68 | } 69 | 70 | if (result.error) { 71 | console.log('Failing to open channel: ', order._id) 72 | } 73 | 74 | await Order.updateOrder(order._id, { 75 | state, 76 | order_result: order.order_result, 77 | channel_open_tx: order.channel_open_tx 78 | }) 79 | })) 80 | 81 | //alert("info",`Channel opening attempt result:\n${JSON.stringify(logInfo)}`) 82 | return update 83 | } 84 | 85 | function parseChannelOptions (product, order) { 86 | if (product.product_type === 'LN_CHANNEL') { 87 | if (order.remote_balance > order.local_balance) { 88 | return false 89 | } 90 | 91 | if(order.remote_balance >= order.local_balance){ 92 | return false 93 | } 94 | 95 | return { 96 | remote_amt: order.remote_balance, 97 | local_amt: order.local_balance 98 | } 99 | } 100 | return false 101 | } 102 | 103 | function addPeer ({ order, product }, cb) { 104 | lnWorker('addPeer', { 105 | socket: order.remote_node.addr, 106 | public_key: order.remote_node.public_key 107 | }, (err, data) => { 108 | if (err) { 109 | console.log('Adding peer failed. Could already be connected') 110 | console.log(err) 111 | } 112 | cb(null, data) 113 | }) 114 | } 115 | 116 | function channelOpener () { 117 | let count = 0 118 | setInterval(() => { 119 | count = 0 120 | }, 60000) 121 | const max = 10 122 | 123 | async function openChannel ({ order, product }, cb) { 124 | if (count >= max) { 125 | alert('info', 'Channel opening is being throttled.') 126 | return cb(new Error('Throttled channel opening')) 127 | } 128 | count++ 129 | const res = { order } 130 | const op = parseChannelOptions(product, order) 131 | if (!op) return cb(new Error('invalid order options')) 132 | 133 | if(op.remote_amt){ 134 | op.give_tokens = op.remote_amt 135 | if(op.give_tokens < 0) return cb(new Error('Invalid channel balance amounts')) 136 | op.local_amt = op.local_amt + op.remote_amt 137 | } 138 | 139 | const chanOpenConfig = { 140 | local_amt:op.local_amt, 141 | remote_amt: op.remote_amt, 142 | remote_pub_key: order.remote_node.public_key, 143 | is_private: order.private_channel 144 | } 145 | 146 | try { 147 | chanOpenConfig.fee_rate = await getOpeningFee() 148 | } catch(err){ 149 | console.log("Failed to get chan opening fee") 150 | return cb(err) 151 | } 152 | 153 | if(chanOpenConfig.fee_rate >= MAX_CHAN_FEE) return cb(chanErrors.FEE_TOO_HIGH(["FEE IS HIGHER THAN "+MAX_CHAN_FEE])) 154 | 155 | 156 | console.log(`Opening LN Channel to: ${JSON.stringify(chanOpenConfig,null,2)}`) 157 | 158 | lnWorker('openChannel', chanOpenConfig, (err, data) => { 159 | if (err) { 160 | const chanErr = parseChannelOpenErr(err, { 161 | remote_node : order.remote_node 162 | }) 163 | res.result = { error: chanErr.toString() } 164 | console.log('Failed to open channel', order._id, chanErr.toString()) 165 | return cb(null, res) 166 | } 167 | if (!data.transaction_id) { 168 | console.log('Failed to open channel, no txid:', order._id) 169 | res.result = { error : chanErrors.NO_TX_ID([err,data]) } 170 | return cb(null, res) 171 | } 172 | data.fee_rate = chanOpenConfig.fee_rate 173 | res.result = { channel_tx: data } 174 | cb(null, res) 175 | }) 176 | } 177 | 178 | return openChannel 179 | } 180 | 181 | 182 | async function getOpeningFee(cb) { 183 | return new Promise((accept, reject) =>{ 184 | callWorker('svc:btc:mempool', 'getCurrrentFeeThreshold', [{}], (err,data)=>{ 185 | if(err) return reject(err) 186 | accept(data.min_fee) 187 | }) 188 | }) 189 | } 190 | 191 | 192 | function alert (level, msg) { 193 | gClient.send('svc:monitor:slack', [level, 'ln_channel', msg], () => {}) 194 | } 195 | 196 | const gClient = new GrenacheClient() 197 | async function main (cb) { 198 | const orders = await getPaidOrders() 199 | if (orders.length === 0) { 200 | console.log(`No orders to process. ${Date.now()}`) 201 | return cb() 202 | } 203 | const openChannel = channelOpener() 204 | 205 | const products = await getProducts(orders.map(({ product_id }) => product_id)) 206 | 207 | console.log(`Processing ${orders.length} for ${products.length} Products`) 208 | 209 | async.mapSeries(orders, (order, next) => { 210 | const product = find(products, ({ _id }) => order.product_id.equals(_id)) 211 | if (!product) return next(new Error('Failed to find product')) 212 | addPeer({ product, order }, () => { 213 | openChannel({ product, order }, next) 214 | }) 215 | }, async (err, data) => { 216 | if (err) { 217 | console.log('Error processing orders', err) 218 | return cb(err) 219 | } 220 | try { 221 | await updateOrders(data) 222 | } catch (err) { 223 | console.log('Failed to update orders', err) 224 | } 225 | cb() 226 | }) 227 | } 228 | 229 | let running = false 230 | setInterval(() => { 231 | if (running) { 232 | console.log('Channel opener is already running.') 233 | return 234 | } 235 | running = true 236 | try { 237 | main(() => { 238 | running = false 239 | }) 240 | } catch (err) { 241 | console.log('Channel opener failed') 242 | console.log(err) 243 | running = false 244 | } 245 | }, 5000) 246 | -------------------------------------------------------------------------------- /LN-Channel-Watch.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { promisify } = require('util') 3 | const _ = require('lodash') 4 | const async = require('async') 5 | const Db = require('./src/DB/DB') 6 | const Order = require('./src/Orders/Order') 7 | const { lnWorker } = require('./src/util/common-workers') 8 | const { ORDER_STATES } = require('./src/Orders/Order') 9 | const { Client: GrenacheClient } = require('blocktank-worker') 10 | const gClient = new GrenacheClient({}) 11 | 12 | function alertSlack (lvl, msg) { 13 | gClient.send('svc:monitor:slack', [lvl, 'channel_watch', msg], (err) => { 14 | if (err) { 15 | return console.log(err) 16 | } 17 | }) 18 | } 19 | console.log('Starting Channel Watcher...') 20 | 21 | Db((err) => { 22 | if (err) throw err 23 | console.log('Started database') 24 | }) 25 | 26 | async function getOrders () { 27 | const db = await Db() 28 | return db.LnChannelOrders.find({ 29 | state: { $in: [ORDER_STATES.OPENING, ORDER_STATES.OPEN, ORDER_STATES.CLOSING] } 30 | }).limit(5000).toArray() 31 | } 32 | 33 | const getOpenedChannels = promisify((cb) => { 34 | lnWorker('listChannels', null, cb) 35 | }) 36 | 37 | const getClosedChannels = promisify((cb) => { 38 | lnWorker('listClosedChannels', null, cb) 39 | }) 40 | 41 | const processOrder = (order, openedChans, closedChans, cb) => { 42 | const openChannelTxId = _.get(order, 'channel_open_tx.transaction_id') 43 | 44 | if (!openChannelTxId) return cb(null, order) 45 | 46 | const openedChannel = _.find(openedChans, { transaction_id: openChannelTxId }) 47 | if (openedChannel && !_.get(openedChannel, 'is_opening') && !_.get(openedChannel, 'is_closing') && order.state !== ORDER_STATES.OPEN) { 48 | console.log(`Order: ${order._id} : Channel Opened: ${openedChannel.id}`) 49 | alertSlack('info', `channel for order ${order._id} is now open`) 50 | order.lightning_channel_id = openedChannel.id 51 | order.state = Order.ORDER_STATES.OPEN 52 | return cb(null, order) 53 | } 54 | 55 | const closedChannel = _.find(closedChans, { transaction_id: openChannelTxId }) 56 | if (closedChannel) { 57 | alertSlack('notice', `Order: ${order._id} channel closed.`) 58 | order.state = Order.ORDER_STATES.CLOSED 59 | order.channel_close_tx = { 60 | transaction_id: closedChannel.close_transaction_id, 61 | ts: Date.now() 62 | } 63 | 64 | if (order.channel_expiry_ts > Date.now()) { 65 | order.channel_closed_early = true 66 | alertSlack('notice', `Order: ${order._id} channel closed before expiry.`) 67 | } 68 | console.log(`Order: ${order._id} : Channel Closed: ${closedChannel.close_transaction_id}`) 69 | return cb(null, order) 70 | } 71 | 72 | cb(null, order) 73 | } 74 | 75 | async function updateOrders (orders) { 76 | const db = await Db() 77 | return Promise.all(orders.map((order) => { 78 | return db.LnChannelOrders.updateOne( 79 | { _id: order._id }, 80 | { $set: { ...order } }) 81 | })) 82 | } 83 | 84 | async function main () { 85 | const channels = await getOpenedChannels() 86 | const closedChannels = await getClosedChannels() 87 | const orders = await getOrders() 88 | if (orders.length === 0) { 89 | console.log(`No orders to process. ${Date.now()}`) 90 | return 91 | } 92 | 93 | async.mapSeries(orders, (order, next) => { 94 | processOrder(order, channels, closedChannels, next) 95 | }, (err, data) => { 96 | if (err) { 97 | console.log('Failed process') 98 | return console.log(err) 99 | } 100 | updateOrders(data) 101 | }) 102 | } 103 | 104 | setInterval(() => { 105 | try { 106 | main() 107 | } catch (err) { 108 | console.log('Channel watcher failed') 109 | console.log(err) 110 | } 111 | }, 5000) 112 | 113 | main() 114 | -------------------------------------------------------------------------------- /LN-Invoice-Watch.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | const async = require('async') 4 | const { get } = require('lodash') 5 | const { lnWorker } = require('./src/util/common-workers') 6 | const { ORDER_STATES } = require('./src/Orders/Order') 7 | const { Client: GrenacheClient } = require('blocktank-worker') 8 | 9 | function getInvoice (order) { 10 | return order.state === ORDER_STATES.CREATED 11 | ? order.ln_invoice 12 | : get(order, 'renewal_quote.ln_invoice', {}) 13 | } 14 | 15 | function getUpdatePayload (order, invoice) { 16 | if (order.state === ORDER_STATES.CREATED) { 17 | return { 18 | state: ORDER_STATES.PAID, 19 | amount_received: invoice.tokens 20 | } 21 | } 22 | 23 | order.renewals.push({ 24 | previous_channel_expiry: order.channel_expiry_ts, 25 | ...order.renewal_quote 26 | }) 27 | return { 28 | renewals: order.renewals, 29 | channel_expiry_ts: order.channel_expiry_ts 30 | } 31 | } 32 | 33 | function getHodlOrders (cb) { 34 | async.waterfall([ 35 | (next) => { 36 | getOrder({}, next) 37 | }, 38 | (orders, next) => { 39 | async.mapLimit(orders, 5, (order, next) => { 40 | const invoice = getInvoice(order) 41 | lnWorker('getInvoice', { id: invoice.id }, (err, invoice) => { 42 | if (err) return next(err) 43 | if (invoice.is_held) { 44 | return next(null, { order, invoice }) 45 | } 46 | next(null, null) 47 | }) 48 | }, (err, data) => { 49 | if (err) return next(err) 50 | next(null, data.filter(Boolean)) 51 | }) 52 | } 53 | ], cb) 54 | } 55 | 56 | function settleInvoice (order, cb) { 57 | const txt = `remote: ${order.remote_balance} | local: ${order.local_balance} | total: ${order.local_balance + order.remote_balance}` 58 | alert('info', 'payment', `Payment ${order._id} received. ${txt}`) 59 | lnWorker('settleHodlInvoice', { secret: getInvoice(order).secret }, (err) => { 60 | if (err) { 61 | console.log('Failed to settle invoice') 62 | console.log(err) 63 | return cancelInvoice(order, cb) 64 | } 65 | cb(null, { settled: true }) 66 | }) 67 | } 68 | 69 | function cancelInvoice (order, cb) { 70 | console.log('Cancelling order invoice: ', order._id) 71 | lnWorker('cancelInvoice', { id: getInvoice(order).id }, (err) => { 72 | if (err) { 73 | console.log('Failed to cancel invoice') 74 | console.log(err) 75 | return cb(err) 76 | } 77 | cb(null, { settled: true }) 78 | }) 79 | } 80 | 81 | async function processHeldInvoice ({ order, invoice }, options = {}) { 82 | console.log(`Invoice is being received and held : ${invoice.id}`) 83 | 84 | async.waterfall([ 85 | (next) => { 86 | settleInvoice(order, next) 87 | }, 88 | (res, next) => { 89 | if (!res.settled) return next(null, false) 90 | updateOrder({ 91 | id: order._id, 92 | update: getUpdatePayload(order, invoice) 93 | }, next) 94 | } 95 | ], (err) => { 96 | if (err) { 97 | console.log(`Failed to settle invoice: ${order._id}`) 98 | console.log(err) 99 | return 100 | } 101 | console.log(`Settled invoice: ${order._id}`) 102 | }) 103 | } 104 | 105 | function startWatch () { 106 | let running = false 107 | setInterval(() => { 108 | if (running) { 109 | return console.log('Still processing orders....') 110 | } 111 | running = true 112 | getHodlOrders(async (err, data) => { 113 | if (err) { 114 | running = false 115 | throw err 116 | } 117 | try { 118 | console.log(`Processing ${data.length} invoices`) 119 | await Promise.all(data.map((d) => processHeldInvoice(d))) 120 | } catch (err) { 121 | console.log('Failed to process orders') 122 | console.log(err) 123 | } 124 | running = false 125 | }) 126 | }, 5000) 127 | } 128 | 129 | function alert (level, tag, msg) { 130 | return new Promise((resolve, reject) => { 131 | gClient.send('svc:monitor:slack', [level, 'payment', msg], (err, data) => { 132 | if (err) { 133 | return reject(err) 134 | } 135 | resolve(data) 136 | }) 137 | }) 138 | } 139 | 140 | function updateOrder (args, cb) { 141 | gClient.send('svc:get_order', { 142 | method: 'updateOrder', 143 | args: args 144 | }, cb) 145 | } 146 | 147 | function getOrder (args, cb) { 148 | gClient.send('svc:get_order', { 149 | method: 'getPendingPaymentOrders', 150 | args: args 151 | }, (err, data) => { 152 | if (err) { 153 | return cb(err) 154 | } 155 | cb(null, data) 156 | }) 157 | } 158 | 159 | const gClient = new GrenacheClient() 160 | startWatch() 161 | -------------------------------------------------------------------------------- /Order-Expiry-Watch.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | const { Client: GrenacheClient } = require('blocktank-worker') 4 | 5 | function startWatch () { 6 | let running = false 7 | setInterval(() => { 8 | if (running) { 9 | return console.log('Still processing orders....') 10 | } 11 | running = true 12 | console.log('Marking orders as expired') 13 | gClient.send('svc:get_order', { 14 | method: 'markOrdersExpired', 15 | args: {} 16 | }, (err) => { 17 | running = false 18 | if (err) throw err 19 | console.log('Done') 20 | }) 21 | // TODO mark orders given up 22 | }, 5000) 23 | } 24 | 25 | const gClient = new GrenacheClient() 26 | startWatch() 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blocktank Server 2 | 3 | Blocktank is an LSP that allows businesses, apps, or online platforms to integrate, automate, and monetize services from your Lightning node. This includes channel configuration, channel purchases, channel info and more. 4 | 5 | 6 | #### [Live Version](http://synonym.to/blocktank) 7 | #### [API Docs](https://synonym.readme.io/reference/nodeinfo) 8 | 9 | 10 | ## Development 11 | Blocktank is a fully open source and everyone is welcome to contribute. The Blocktank project gives you everything you need to become your own LSP. 12 | ### ⚠️ **Warning** ⚠️ 13 | **Run this program at your own risk.** 14 | 15 | 16 | ## Dependencies 17 | 18 | * Mongodb 19 | * LND 20 | * Node.js >v12 21 | * [PM2](https://pm2.keymetrics.io/) 22 | * [Grenache Grape](https://github.com/bitfinexcom/grenache-grape) 23 | 24 | ## How to run: 25 | 26 | Start 2 Grapes in the background for microservice communication: 27 | ``` 28 | grape --dp 20001 --aph 30001 --bn '127.0.0.1:20002' & 29 | grape --dp 20002 --aph 40001 --bn '127.0.0.1:20001' & 30 | ``` 31 | 32 | Copy and update the example settings files located in `./config` 33 | ``` 34 | cp ./config/server.json.example ./config/server.json 35 | cp ./config/auth.json.example ./config/auth.json 36 | ``` 37 | 38 | Create Inventory item (this creates a product in the mongodb database) 39 | ``` 40 | cd ./cli 41 | node update-inventory 42 | ``` 43 | 44 | Add the new inventory id to `./config/server.json` under `product_id`. Blocktank creates a new DB in MongoDB called Lighthouse. Looking in the `Inventory` collection to find your new product id... 45 | 46 | ``` 47 | mongosh 48 | use Lighthouse 49 | db.Inventory.find({}) 50 | ``` 51 | 52 | Run all microservice workers (including the dependent workers) 53 | 54 | ``` 55 | pm2 start ecosystem.config.js 56 | ``` 57 | 58 | ## See Also 59 | 60 | [Blocktank BTC Worker](https://github.com/synonymdev/blocktank-worker-btc) - a set of service workers that provide access to Bitcoin RPC endpoints, watch the mempool and alerts on new blocks 61 | 62 | [Blocktank LN Worker](https://github.com/synonymdev/blocktank-worker-ln) - service workers that provide access to Lightning Node features 63 | 64 | 65 | ## Architecture 66 | 67 | ### Microservices 68 | * Blocktank Server is a series of small scripts communicating with each other via [Grenache](https://blog.bitfinex.com/tutorial/bitfinex-loves-microservices-grenache/) - [Github](https://github.com/bitfinexcom/grenache). 69 | 70 | ### Workers in this repo 71 | 72 | * LN-Channel-Opener: 73 | * Fetches orders that have been paid and claimed, then opens the channel. 74 | * LN-Channel-Watch 75 | * Watch channels that are opened and update an order's channel. 76 | * LN-Invoice-Watch 77 | * Listens for payments on from Lightning. 78 | * Order-Expiry-Watch 79 | * Update orders that have been expired 80 | * AddressWatch 81 | * Watch for on chain payments for orders 82 | * ZeroConf 83 | * Watch for on chain zero conf payments for orders 84 | * Start-Express 85 | * Express Server for routing requests to workers. 86 | * GetOrder 87 | * Handle the get order api endpoint. 88 | * NodeInfo 89 | * Handle the get node info api endpoint 90 | * BuyChannel 91 | * Creates an order. 92 | * FinaliseChannel 93 | * Claim a paid channels 94 | * LnUrlChannel 95 | * Handlers for LNURL Channel. Prowxies to FinaliseChannel endpoint 96 | * Auth.js 97 | * Authenticate Admin endpoints 98 | * ChannelAdmin 99 | * Handle admin endpoints 100 | * Exchange Rate 101 | * Handle currency conversions api endpoint. 102 | 103 | ### Dependent Repos: 104 | * Blocktank-worker-ln 105 | * Worker for interacting with Lightning Network Node 106 | * Blocktank-worker-btc 107 | * Worker for interacting with Bitcoin node 108 | * Blocktank-worker-router 109 | * Handle fee managment and various other routing node features. 110 | 111 | 112 | ### HTTP API Call flow: 113 | 1. When starting application. API endpoints are set by looking at `Endpoints.js` 114 | 2. `Http.js` runs express and listens to HTTP calls 115 | 3. When an API is called, it uses config in `Endpoints.js` to find the microservice worker name and calls it. 116 | 4. If the API call is GET, query parameters are passed to the worker, if POST, body is passed. This is done in `HTTP.js` 117 | 118 | ### Worker to Worker calls 119 | 1. Looking at Controller class in `util/Worker.js` you can see some pre written helper functions for calling popular workers like The bitcoin worker or the LN worker. Most microservice workers extend from the Controller class. 120 | 2. Every worker is running a Grenache server and a client 121 | 3. Grenache server is listening to calls from other workers. 122 | 4. Grenache client is used to call other workers. 123 | 124 | ## Admin API: 125 | These are some admin endpoints that should only be accessed by authorised BT admins. 126 | 127 | ### Create a user 128 | you need to call `createUser` locate in `./src/Auth/Auth.js` with a username and password. and save the output to `./config/auth.json` 129 | 130 | ### `POST: /v1/channel/manual_credit` 131 | 132 | Credit a channel manually, if it hasn't been picked up automatically. 133 | 134 | **Parameters:** 135 | ``` 136 | { 137 | "tx_id":"10a646815e29b0780c6525d39dfcf32b1fc44453a0e38ce4e05d21539831d3a3", // Bitcoin Txid 138 | "order_id":"6147e8ca19d94f8a1226a212" // Order id 139 | } 140 | ``` 141 | 142 | ### `GET: /v1/channel/orders` 143 | 144 | Get a list of orders 145 | 146 | **Parameters:** 147 | ``` 148 | { 149 | "state" : 100, // Get orders in a state 150 | "expired_channels": true, // Get all channels that can be closed 151 | "order_id": "6147e8ca19d94f8a1226a212", // Get a single order 152 | "page": 1, // Pagination 153 | } 154 | ``` 155 | 156 | ### `POST: /v1/channel/close` 157 | 158 | Begin channel closing process. 159 | 160 | **NOTE: When calling this api, you have 30 seconds to stop the process by calling the api again.** 161 | 162 | **Parameters:** 163 | ``` 164 | { 165 | "order_id":"ALL || order id" // pass ALL to close all expired channels, pass order id to close a channel 166 | } 167 | ``` 168 | 169 | 170 | ### `POST: /v1/channel/refund` 171 | 172 | Save refund info and change order state to REFUNDED. 173 | 174 | **Parameters:** 175 | ``` 176 | { 177 | "order_id": "xxx", // Order id 178 | "refund_tx": "xxx", // Transaction id or invoice 179 | } 180 | ``` 181 | 182 | 183 | ### Testing 184 | 185 | 1. In order to start testing you need to install the `devDependencies` and [Mocha](https://mochajs.org/) 186 | 2. Update `test.config.js.example` with your local **Regtest** Bitcoin and Lightning node. 187 | 1. You must make sure you have enough Bitcoin liquidity before running test. 188 | 3. Run `mocha ./test/BuyChannel.e2e.test.js` 189 | 190 | -------------------------------------------------------------------------------- /Start-Admin.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Server = require('./src/Server/Http') 3 | 4 | const s = new Server({ 5 | port: 4001, 6 | endpoint: 'ADMIN_ENDPOINTS' 7 | }) 8 | s.start() 9 | -------------------------------------------------------------------------------- /Start-Express.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Server = require('./src/Server/Http') 3 | const config = require("./config/server.json") 4 | const s = new Server({ 5 | port: config.http.port, 6 | host: config.http.host, 7 | endpoint: 'USER_ENDPOINTS' 8 | }) 9 | s.start() 10 | -------------------------------------------------------------------------------- /Start-LN.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Server = require('./src/Lightning/Worker') 3 | const ln = new Server({}) 4 | ln.start() 5 | -------------------------------------------------------------------------------- /cli/create-inventory-item.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "add_item", 4 | "data" : { 5 | "name" : "test_channel", 6 | "description" : "LN channels", 7 | "product_type" : "LN_CHANNEL", 8 | "product_meta":{ 9 | "max_capacity": 1000000, 10 | "max_capacity_tick": 500000, 11 | "start_ts": 1661298489691, 12 | "ticks": 8.64e+7, 13 | "chan_size": 100000 14 | }, 15 | "stats":{ 16 | "sold_count_tick" : 0, 17 | "capacity_sold_tick" : 0, 18 | "capacity_available_tick" : 500000, 19 | "capacity_total":0, 20 | "capacity_tick":0 21 | }, 22 | "state" : 1 23 | } 24 | } 25 | ] -------------------------------------------------------------------------------- /cli/get-inventory.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Inventory = require('../src/Inventory/Inventory') 4 | 5 | async function main () { 6 | return Inventory.find({}, (err, data) => { 7 | if (err) throw err 8 | console.log(data) 9 | }) 10 | } 11 | 12 | main() 13 | -------------------------------------------------------------------------------- /cli/update-inventory.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Inventory = require('../src/Inventory/Inventory') 4 | const data = require('./create-inventory-item.json') 5 | const { ObjectId } = require("mongodb") 6 | 7 | function update (item) { 8 | return new Promise((resolve, reject) => { 9 | const handle = (err, data) => { 10 | if (err) return reject(err) 11 | resolve(data) 12 | } 13 | 14 | if (item.type === 'add_item') { 15 | console.log('adding') 16 | console.log(item.data) 17 | item.data._id = new ObjectId("625cea4d2c2de64cb734a0d7") 18 | return Inventory.addNewItem(item.data, handle) 19 | } 20 | 21 | if (item.type === 'update_item') { 22 | return Inventory.updateOne(item.id, item.data, handle) 23 | } 24 | 25 | throw new Error('Invalid operation') 26 | }) 27 | } 28 | 29 | async function main () { 30 | console.log(`Running Inventory : ${data.length} Items \n\n\n`) 31 | const res = await Promise.all(data.map(update)) 32 | console.log(res) 33 | console.log('Finished') 34 | } 35 | 36 | main() 37 | -------------------------------------------------------------------------------- /config/auth.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "users":{ 3 | "username_here": { 4 | "token": "secret", 5 | "password": "password_hash", 6 | "username": "username" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /config/server.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "db_url":"mongodb://localhost:27017", 3 | "constants":{ 4 | "min_wallet_balance_buffer":1000, 5 | "order_expiry" : 900000, 6 | "max_attempt_channel_open" : 50, 7 | "product_id": "625cea4d2c2de64cb734a0d7", 8 | "min_chan_expiry": 1, 9 | "max_chan_expiry": 12, 10 | "min_channel_size": 1000000, 11 | "max_channel_size": 200000000, 12 | "channel_size_buffer_sats": 100000, 13 | "max_channel_dollar": 9999, 14 | "min_confirmation": 1, 15 | "block_time": 1000, 16 | "compliance_check": false, 17 | "buy_chan_tx_fee": 5000 18 | }, 19 | "zero_conf": { 20 | "max_amount": 1000000000000, 21 | "max_orders": 10, 22 | "max_total_amount": 500000000, 23 | "quote_expiry": 1000, 24 | "mempool_timer": 5000 25 | }, 26 | "public_uri": "http://localhost:4000", 27 | "node_whitelist": [], 28 | "ip_block_countries":[] 29 | } 30 | 31 | 32 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const DEBUG_FLAG = 'LH:*' 4 | 5 | const settings = { 6 | ignore_watch: 'status', 7 | watch: ['./src', './*js'], 8 | namespace: 'blocktank-server' 9 | } 10 | 11 | module.exports = { 12 | apps: [ 13 | { 14 | name: 'ln:channel-opener', 15 | script: './LN-Channel-Opener.js', 16 | env: { 17 | DEBUG: DEBUG_FLAG 18 | }, 19 | env_production: {}, 20 | ...settings 21 | }, 22 | { 23 | name: 'order:expiry-watch', 24 | script: './Order-Expiry-Watch.js', 25 | env: { 26 | DEBUG: DEBUG_FLAG 27 | }, 28 | env_production: {}, 29 | ...settings 30 | }, 31 | { 32 | name: 'ln:invoice-watch', 33 | script: './LN-Invoice-Watch.js', 34 | env: { 35 | DEBUG: DEBUG_FLAG 36 | }, 37 | env_production: {}, 38 | ...settings 39 | }, 40 | { 41 | name: 'ln:channel-watch', 42 | script: './LN-Channel-Watch.js', 43 | env: { 44 | DEBUG: DEBUG_FLAG 45 | }, 46 | env_production: {}, 47 | ...settings 48 | }, 49 | { 50 | name: 'btc:address-watch', 51 | script: './src/Bitcoin/AddressWatch.js', 52 | env: { 53 | DEBUG: DEBUG_FLAG 54 | }, 55 | env_production: {}, 56 | ...settings 57 | }, 58 | { 59 | name: 'api:btc-zero-conf', 60 | script: './src/Channel/ZeroConf.js', 61 | env: { 62 | DEBUG: DEBUG_FLAG 63 | }, 64 | env_production: {}, 65 | ...settings 66 | }, 67 | { 68 | name: 'server:express', 69 | script: './Start-Express.js', 70 | env: { 71 | DEBUG: DEBUG_FLAG 72 | }, 73 | env_production: {}, 74 | ...settings 75 | }, 76 | { 77 | name: 'api:get-order', 78 | script: './src/Channel/GetOrder.js', 79 | env: { 80 | DEBUG: DEBUG_FLAG 81 | }, 82 | env_production: {}, 83 | ...settings 84 | }, 85 | { 86 | name: 'api:node-info', 87 | script: './src/Channel/NodeInfo.js', 88 | env: { 89 | DEBUG: DEBUG_FLAG 90 | }, 91 | env_production: {}, 92 | ...settings 93 | }, 94 | { 95 | name: 'api:buy-channel', 96 | script: './src/Channel/BuyChannel.js', 97 | env: { 98 | DEBUG: DEBUG_FLAG 99 | }, 100 | env_production: {}, 101 | ...settings 102 | }, 103 | { 104 | name: 'api:finalise-channel', 105 | script: './src/Channel/FinaliseChannel.js', 106 | env: { 107 | DEBUG: DEBUG_FLAG 108 | }, 109 | env_production: {}, 110 | ...settings 111 | }, 112 | { 113 | name: 'api:lnurl-channel', 114 | script: './src/Channel/LnUrlChannel.js', 115 | env: { 116 | DEBUG: DEBUG_FLAG 117 | }, 118 | env_production: {}, 119 | ...settings 120 | }, 121 | { 122 | name: 'api:auth', 123 | script: './src/Auth/Auth.js', 124 | env: { 125 | DEBUG: DEBUG_FLAG 126 | }, 127 | env_production: {}, 128 | ...settings 129 | }, 130 | 131 | { 132 | name: 'server:admin', 133 | script: './Start-Admin.js', 134 | env: { 135 | DEBUG: DEBUG_FLAG 136 | }, 137 | env_production: {}, 138 | ...settings 139 | }, 140 | 141 | { 142 | name: 'api:channel-admin', 143 | script: './src/Admin/ChannelAdmin.js', 144 | env: { 145 | DEBUG: DEBUG_FLAG 146 | }, 147 | env_production: {}, 148 | ...settings 149 | }, 150 | { 151 | name: 'api:exchange-rate', 152 | script: './src/Channel/ExchangeRate.js', 153 | env: { 154 | DEBUG: DEBUG_FLAG 155 | }, 156 | env_production: {}, 157 | ...settings 158 | } 159 | 160 | ] 161 | } 162 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blocktank-server", 3 | "version": "0.0.1", 4 | "description": "Blocktank is an LSP that allows businesses, apps, or online platforms to integrate, automate, and monetize services from your Lightning node. This includes channel configuration, channel purchases, channel info and more.", 5 | "scripts": { 6 | "start": "pm2 start ./ecosystem.config.js", 7 | "restart": "pm2 start ./ecosystem.config.js", 8 | "update:inventory": "node ./cli/update-inventory.js" 9 | }, 10 | "author": "r32a_", 11 | "license": "ISC", 12 | "dependencies": { 13 | "async": "^3.2.0", 14 | "axios": "^0.21.4", 15 | "bcrypt": "^5.0.1", 16 | "bech32": "^2.0.0", 17 | "bignumber.js": "^9.0.1", 18 | "blocktank-worker": "github:synonymdev/blocktank-worker", 19 | "body-parser": "^1.19.0", 20 | "express": "^4.17.1", 21 | "grenache-nodejs-http": "^0.7.12", 22 | "helmet": "^4.6.0", 23 | "ln-service": "^52.14.0", 24 | "lodash": "^4.17.21", 25 | "mongodb": "^3.6.5", 26 | "otplib": "^12.0.1", 27 | "pm2": "^5.1.0", 28 | "uuid": "^8.3.2" 29 | }, 30 | "devDependencies": { 31 | "@synonymdev/blocktank-client": "0.0.45", 32 | "blocktank-dev-net": "github:rbndg/blocktank-dev-net", 33 | "blocktank-worker-btc": "git+https://github.com/rbndg/blocktank-worker-btc.git", 34 | "blocktank-worker-ln": "git+https://github.com/rbndg/blocktank-worker-ln.git", 35 | "mocha": "^9.0.2", 36 | "nock": "^13.2.8" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Admin/ChannelAdmin.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { Worker } = require('blocktank-worker') 3 | const Order = require('../Orders/Order') 4 | const { intersectionWith, get } = require('lodash') 5 | const { promisify } = require('util') 6 | const async = require('async') 7 | const { ORDER_STATES } = require('../Orders/Order') 8 | 9 | class ChannelAdmin extends Worker { 10 | constructor (config) { 11 | super({ 12 | name: 'svc:channel_admin', 13 | port: 5819 14 | }) 15 | this._getOrders = promisify(this.main) 16 | this._timers = new Map() 17 | } 18 | 19 | _closeOrders (orders, cb) { 20 | this.alertSlack('info', 'admin', `Closing ${orders.length} channels`) 21 | console.log(`Closing ${orders.length} channels`) 22 | 23 | async.map(orders, async (order) => { 24 | let closeTx 25 | try { 26 | closeTx = await this.callLn('closeChannel', { id: order.lightning_channel_id }) 27 | } catch (err) { 28 | console.log(err) 29 | return { order, error: true } 30 | } 31 | closeTx.tx = Date.now() 32 | order.channel_close_tx = closeTx 33 | await Order.updateOrder(order._id, { channel_close_tx: closeTx, state: ORDER_STATES.CLOSING }) 34 | return { order, error: false } 35 | }, (err, data) => { 36 | this._stopTimer('chan_close') 37 | if (err) { 38 | console.log(err) 39 | return cb(err) 40 | } 41 | const res = data.map(({ order, error }) => { 42 | const res = { order_id: order._id } 43 | if (!error) { 44 | res.channel_close_tx = order.channel_close_tx 45 | return res 46 | } 47 | res.error = true 48 | return res 49 | }) 50 | cb(null, res) 51 | }) 52 | } 53 | 54 | _stopTimer (n) { 55 | const timer = this._timers.get(n) 56 | if (!timer) throw new Error('Timer not found: ' + n) 57 | clearTimeout(timer) 58 | this._timers.delete(n) 59 | } 60 | 61 | async login (args, options, cb) { 62 | let login 63 | try { 64 | login = await this.callWorker('svc:simple_auth', 'login', args) 65 | } catch (err) { 66 | console.log('Failed to login ', args.username) 67 | return (null, this.errRes('Unauthorised')) 68 | } 69 | if (login.error) { 70 | return cb(null, this.errRes(login.error)) 71 | } 72 | cb(null, { key: login.key }) 73 | } 74 | 75 | async closeChannelsSync (args, options, cb) { 76 | const timerName = 'chan_close' 77 | if (this.channel_closer_timer) { // Channel closure can be stopped 78 | this._stopTimer(timerName) 79 | return cb(null, this.errRes('Stopped channel closing processs')) 80 | } 81 | const closeQuery = args.order_id === 'ALL' ? null : args.order_id 82 | 83 | this.alertSlack('notice', 'admin', `Closing channel. order: ${closeQuery}`) 84 | 85 | let liveChans 86 | try { 87 | liveChans = await this.callLn('listChannels', null) 88 | } catch (err) { 89 | console.log(err) 90 | return cb(null, this.errRes('Failed to get active channels')) 91 | } 92 | 93 | let expiredChans 94 | try { 95 | expiredChans = await this.getOrders({ expired_channels: true, order_id: closeQuery }) 96 | expiredChans = intersectionWith(expiredChans, liveChans, (order, chan) => { 97 | return chan.transaction_id === get(order, 'channel_open_tx.transaction_id') 98 | }) 99 | } catch (err) { 100 | console.log(err) 101 | return cb(null, this.errRes('Failed to get expired channels')) 102 | } 103 | 104 | if (expiredChans.length === 0) { 105 | return cb(null, this.errRes('No channels to close')) 106 | } 107 | 108 | this.alertSlack('info', 'admin', 'Will start to close channels in 30 seconds.') 109 | const timer = setTimeout(() => { 110 | this._closeOrders(expiredChans, cb) 111 | }, 30000) 112 | 113 | this._timers.set(timerName, timer) 114 | } 115 | 116 | async sweepOnchainFunds (args, options, cb) { 117 | // TODO: Sweep onchain funds 118 | } 119 | 120 | main (args, options, cb) { 121 | this.getOrders(args, cb) 122 | } 123 | 124 | getOrdersQuery (query, cb) { 125 | Order.find(query, (err, data) => { 126 | if (err) { 127 | console.log(err) 128 | return cb(null, this.errRes('Failed to query db')) 129 | } 130 | cb(null, data) 131 | }) 132 | } 133 | 134 | getOrders (args, cb) { 135 | const query = { 136 | _sort: { created_at: -1 }, 137 | _limit: 100, 138 | _skip: +args.page || 0 139 | } 140 | 141 | if (args.state) { 142 | query.state = args.state 143 | } 144 | 145 | if (args.expired_channels) { 146 | query.channel_expiry_ts = { $lte: Date.now() } 147 | query.state = Order.ORDER_STATES.OPEN 148 | } 149 | 150 | if (args.opening_channels && args.opened_channels) { 151 | query.state = { $in: [ORDER_STATES.OPENING, ORDER_STATES.OPEN] } 152 | } 153 | 154 | if (args.remote_node) { 155 | query['remote_node.public_key'] = args.remote_node 156 | } 157 | 158 | if (args.order_id) { 159 | query._id = args.order_id 160 | } 161 | 162 | Order.find(query, (err, data) => { 163 | if (err) { 164 | console.log(err) 165 | return cb(null, this.errRes('Failed to query db')) 166 | } 167 | cb(null, data) 168 | }) 169 | } 170 | 171 | refund (args, options, cb) { 172 | if (!args.order_id || !args.refund_tx) { 173 | return cb(null, this.errRes('Invalid args passed')) 174 | } 175 | async.waterfall([ 176 | (next) => { 177 | Order.findOne({ _id: args.order_id }, next) 178 | }, 179 | (order, next) => { 180 | if (!order) return next(new Error('Order not found')) 181 | Order.updateOrder(args.order_id, { 182 | state: ORDER_STATES.REFUNDED, 183 | refund_tx: args.refund_tx, 184 | refunded_at: Date.now() 185 | }, next) 186 | }], (err, data) => { 187 | if (err) { 188 | console.log(err) 189 | return cb(null, this.errRes('Refund failed')) 190 | } 191 | this.alertSlack('notice', 'admin', `Order: ${args.order_id} Refunded`) 192 | return cb(null, { success: true }) 193 | }) 194 | } 195 | 196 | pendingChannelOpens (args, options, cb) { 197 | Order.find({ 198 | state: Order.ORDER_STATES.URI_SET, 199 | created_at: { $gte: Date.now() - 172800000 } 200 | }, (err, data) => { 201 | if (err) { 202 | console.log(err) 203 | return cb(null, this.errRes('Failed to get channel openings')) 204 | } 205 | cb(null, data) 206 | }) 207 | } 208 | } 209 | 210 | module.exports = ChannelAdmin 211 | const n = new ChannelAdmin({}) 212 | -------------------------------------------------------------------------------- /src/Auth/Auth.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { Worker } = require('blocktank-worker') 3 | const bcrypt = require('bcrypt') 4 | const crypto = require('crypto') 5 | const { users } = require('../../config/auth.json') 6 | const { find } = require('lodash') 7 | const { authenticator } = require('otplib') 8 | 9 | const SALT_ROUNDS = 12 10 | 11 | const FAILED_LOGIN = 'Unauthorised' 12 | 13 | class SimpleAuth extends Worker { 14 | constructor (config) { 15 | config.name = 'svc:simple_auth' 16 | config.port = 8487 17 | super(config) 18 | this.loginAttempt = new Map() 19 | this.sessions = new Map() 20 | 21 | setInterval(() => { 22 | this.loginAttempt.forEach((a, user) => { 23 | const delta = Date.now() - a[1] 24 | if (delta >= 30000) { 25 | this.loginAttempt.delete(user) 26 | } 27 | }) 28 | this.sessions.forEach((val, session) => { 29 | const delta = Date.now() - val[1] 30 | if (delta >= 600000) { 31 | this.sessions.delete(session) 32 | } 33 | }) 34 | }, 1000) 35 | } 36 | 37 | isLoggedIn ({ key }, cb) { 38 | const user = this.sessions.get(key) 39 | if (!user) { 40 | return cb(null, { logged_in: false }) 41 | } 42 | return cb(null, { 43 | logged_in: true, 44 | user_name: user[0] 45 | }) 46 | } 47 | 48 | attemptedLogin (username, msg, cb) { 49 | console.log(`Failed to login: ${username} - ${msg}`) 50 | if (!this.loginAttempt.has(username)) { 51 | this.loginAttempt.set(username, [0, Date.now()]) 52 | } 53 | const attempt = this.loginAttempt.get(username) 54 | ++attempt[0] 55 | attempt[1] = Date.now() 56 | this.loginAttempt.set(username, attempt) 57 | return cb(null, this.errRes(FAILED_LOGIN)) 58 | } 59 | 60 | async login (args, cb) { 61 | console.log('New login: ', args.username) 62 | const { username, token, password } = args 63 | 64 | // Check that the user is registered 65 | const user = find(users, { username }) 66 | if (!user) { 67 | return this.attemptedLogin(username, 'bad username', cb) 68 | } 69 | 70 | // Check the users's attempt count 71 | const attempt = this.loginAttempt.get(username) 72 | if (attempt && attempt[0] >= 5) { 73 | return this.attemptedLogin(username, 'too many attempts', cb) 74 | } 75 | 76 | // Check password 77 | if (!bcrypt.compareSync(password, user.password)) { 78 | return this.attemptedLogin(username, 'bad pass', cb) 79 | } 80 | 81 | // Check two factor auth 82 | if (!authenticator.check(token, user.token)) { 83 | return this.attemptedLogin(username, 'bad 2fa', cb) 84 | } 85 | 86 | // Create session key 87 | const key = crypto.randomBytes(256).toString('hex') 88 | this.sessions.set(key, [user.username, Date.now()]) 89 | 90 | // Delete login attempts 91 | this.loginAttempt.delete(username) 92 | return cb(null, { key }) 93 | } 94 | 95 | async createUser (args, cb) { 96 | const secret = authenticator.generateSecret(256) 97 | cb(null, { 98 | username: args.username, 99 | token: secret, 100 | password: bcrypt.hashSync(args.password, SALT_ROUNDS) 101 | }) 102 | } 103 | } 104 | 105 | module.exports = SimpleAuth 106 | 107 | const n = new SimpleAuth({}) 108 | -------------------------------------------------------------------------------- /src/Bitcoin/AddressWatch.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | const Bignumber = require('bignumber.js') 4 | const async = require('async') 5 | const { find } = require('lodash') 6 | const { Worker } = require('blocktank-worker') 7 | const Order = require('../Orders/Order') 8 | const { ORDER_STATES } = require('../Orders/Order') 9 | const { constants } = require('../../config/server.json') 10 | 11 | class BtcAddressWatch extends Worker { 12 | constructor (config) { 13 | config.name = 'svc:btc_address_watch' 14 | config.port = 8718 15 | super(config) 16 | this.processing = new Map() 17 | } 18 | 19 | async onNewBlock (height, cb) { 20 | cb() 21 | // Look for new order payments in new blocks and store in db 22 | await processNewBlock({}, +height) 23 | // Check payments are confirmed 24 | confirmPayments(+height) 25 | } 26 | 27 | async main (args, options, cb) { 28 | this.manualConfirm(args, cb) 29 | } 30 | 31 | async manualConfirm (args, cb) { 32 | if (this.processing.get(args.order_id)) { 33 | return cb(null, this.errRes('Order is being processed. Please wait')) 34 | } 35 | const done = (err, data) => { 36 | this.processing.delete(args.order_id) 37 | cb(err, data) 38 | } 39 | this.processing.set(args.order_id, Date.now()) 40 | try { 41 | const tx = await api.callBlocks('parseTransaction', { id: args.tx_id }) 42 | const order = await Order.findOne({ 43 | _id: args.order_id, 44 | state: ORDER_STATES.CREATED 45 | }) 46 | 47 | if (!order) { 48 | return done(null, this.errRes('Order not found. Order might be processed already.')) 49 | } 50 | 51 | if (!tx || tx.length === 0) { 52 | return done(null, this.errRes(`Transaction not found or not included in a block yet: ${args.tx_id}`)) 53 | } 54 | const payments = await processOnChainTx([order], tx) 55 | confirmOrder({ 56 | currentHeight: 'SKIP' 57 | }, payments[args.order_id], (err) => { 58 | if (err) { 59 | return done(null, this.errRes('Failed to confirm order')) 60 | } 61 | done(null, { 62 | order_id: args.order_id, 63 | success: true 64 | }) 65 | }) 66 | } catch (err) { 67 | console.log('Error: ', err) 68 | done(err) 69 | } 70 | } 71 | 72 | callBlocks (method, args, cb) { 73 | return new Promise((resolve, reject) => { 74 | this.gClient.send('svc:btc:blocks', { 75 | method, 76 | args 77 | }, (err, data) => { 78 | if (err) { 79 | return cb ? cb(err) : reject(err) 80 | } 81 | cb ? cb(null, data) : resolve(data) 82 | }) 83 | }) 84 | } 85 | } 86 | 87 | function getOrders (args = {}) { 88 | return api.callWorker('svc:get_order', 'getPendingPaymentOrders', args || {}) 89 | } 90 | 91 | function updateOrder (args) { 92 | return api.callWorker('svc:get_order', 'updateOrder', args) 93 | } 94 | 95 | function confirmOrder (config, order, cb) { 96 | const { currentHeight } = config 97 | console.log('Confirming order: ', order._id) 98 | let totalConfirmed = new Bignumber(0) 99 | 100 | async.mapSeries(order.onchain_payments, async (p) => { 101 | const btx = await api.callBlocks('getTransaction', p.hash) 102 | 103 | if(!btx || !btx.blockheight) return p 104 | 105 | if (currentHeight === 'SKIP' || btx.confirmations >= constants.min_confirmation) { 106 | totalConfirmed = totalConfirmed.plus(p.amount_base) 107 | p.confirmed = true 108 | } 109 | 110 | p.height = btx.blockheight 111 | 112 | return p 113 | }, async (err, payments) => { 114 | if (err) { 115 | console.log(err) 116 | return cb(err) 117 | } 118 | order.onchain_payments = payments 119 | if (totalConfirmed.gte(order.total_amount)) { 120 | order.state = ORDER_STATES.PAID 121 | } 122 | order.amount_received = totalConfirmed.toString() 123 | try { 124 | await updateOrder({ id: order._id, update: order }) 125 | } catch (err) { 126 | return cb(err) 127 | } 128 | cb(null) 129 | }) 130 | } 131 | 132 | async function confirmPayments (currentHeight) { 133 | const orders = await getOrders() 134 | async.mapSeries(orders, (order, next) => { 135 | confirmOrder({ currentHeight }, order, next) 136 | }) 137 | } 138 | 139 | async function checkForBlacklistedAddress (blockTx) { 140 | return async.filter(blockTx, async ([order, block]) => { 141 | const res = await api.callWorker('svc:channel_aml', 'isAddressBlacklisted', { 142 | address: block.from 143 | }) 144 | if (res.blacklisted) { 145 | console.log('Order paid from blacklisted address.', block, res) 146 | api.alertSlack('notice', 'compliance', `Detected payment from blacklisted address. Not accepting payment.\nOrder:${order._id}\nTransaction:\n${JSON.stringify(block)}\n${JSON.stringify(res.address)}`) 147 | } 148 | return !res.blacklisted 149 | }) 150 | } 151 | 152 | async function processOnChainTx (orders, block) { 153 | const addr = orders.map((tx) => tx.btc_address).filter(Boolean) 154 | console.log(`Orders pending payment: ${addr.length}`) 155 | const payments = {} 156 | let blockTx = block.map((b) => { 157 | const index = addr.indexOf(b.to) 158 | if (index < 0) return null 159 | return [orders[index], b] 160 | }).filter(Boolean) 161 | blockTx = await checkForBlacklistedAddress(blockTx) 162 | 163 | blockTx.forEach(([order, block]) => { 164 | const orderId = order._id.toString() 165 | let p = payments[orderId] 166 | if (!p) { 167 | p = payments[orderId] = order 168 | } 169 | if (find(p.onchain_payments, { hash: block.hash })) return 170 | payments[orderId].onchain_payments.push(block) 171 | }) 172 | return payments 173 | } 174 | 175 | async function processNewBlock (config, height) { 176 | console.log(`Processing new height: ${height}`) 177 | return new Promise(async (resolve, reject) => { 178 | let block, orders 179 | try { 180 | orders = await getOrders({ state: ORDER_STATES.CREATED }) 181 | const orderAddr = orders.map((o) => o.btc_address) 182 | block = await api.callBlocks('getHeightTransactions', { height, address: orderAddr }) 183 | } catch (err) { 184 | console.log('Failed to process block height: ' + height) 185 | console.log(err) 186 | return reject(err) 187 | } 188 | const payments = await processOnChainTx(orders, block) 189 | const p = Object.keys(payments) 190 | console.log(`Payments to process : ${p.length}`) 191 | async.each(p, async (k, next) => { 192 | const order = payments[k] 193 | return updateOrder({ id: order._id, update: order }) 194 | }, (err) => { 195 | if (err) { 196 | return reject(err) 197 | } 198 | resolve() 199 | }) 200 | }) 201 | } 202 | 203 | const api = new BtcAddressWatch({}) 204 | -------------------------------------------------------------------------------- /src/Channel/BuyChannel.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const lnurl = require('../util/lnurl') 3 | const BN = require('bignumber.js') 4 | const { Worker } = require('blocktank-worker') 5 | const { getBtcUsd } = require('../util/exchange-api') 6 | const { toBtc } = require('../util/sats-convert') 7 | const { waterfall } = require('async') 8 | const { pick, omit } = require('lodash') 9 | const { getChannelPrice } = require('../util/pricing') 10 | const Order = require('../Orders/Order') 11 | const config = require('../../config/server.json') 12 | const { constants } = config 13 | const { public_uri: publicUri, db_url: dbURL } = config 14 | 15 | class BuyChannel extends Worker { 16 | constructor (config) { 17 | config.name = 'svc:buy_channel' 18 | config.port = config.port || 7672 19 | config.db_url = dbURL 20 | super(config) 21 | } 22 | 23 | _getOrderExpiry () { 24 | return Date.now() + constants.order_expiry 25 | } 26 | 27 | async extGeoCheck (args, config, cb) { 28 | cb(null,{ accept: true }) 29 | } 30 | 31 | async checkCapacityLimit (totalCapacity) { 32 | const btcusd = await getBtcUsd() 33 | const usd = new BN(btcusd.price).times(toBtc(totalCapacity)) 34 | if (usd.gte(constants.max_channel_dollar)) { 35 | return { 36 | accept: false, 37 | usd_size: usd.decimalPlaces(2) 38 | } 39 | } 40 | return { accept: true } 41 | } 42 | 43 | _getLnInvoice (id, amount) { 44 | return new Promise((resolve, reject) => { 45 | this.callLn('createHodlInvoice', { 46 | memo: `BlockTank ${id}`, 47 | amount, 48 | expiry: constants.order_expiry 49 | }, (err, invoice) => { 50 | if (err) { 51 | console.log(err) 52 | return reject(new Error('Failed to create invoice')) 53 | } 54 | resolve(invoice) 55 | }) 56 | }) 57 | } 58 | 59 | _calExpiry (expiry) { 60 | return BN(Date.now()).plus(BN(expiry).times(6.048e+8)).toNumber() 61 | } 62 | 63 | _validateOrder ({ channel_expiry: expiry, local_balance: localBalance, remote_balance: remoteBalance }) { 64 | if (!Number.isInteger(expiry) || expiry > constants.max_chan_expiry || expiry < constants.min_chan_expiry) { 65 | return 'Invalid channel expiry' 66 | } 67 | 68 | if (!Number.isInteger(remoteBalance) || !Number.isInteger(localBalance)) { 69 | return 'Invalid channel balance requested' 70 | } 71 | 72 | const totalSize = (remoteBalance + localBalance) 73 | // Hard limit on any channel size 74 | if (totalSize > constants.max_channel_size) { 75 | return 'Requested channel capacity is too large' 76 | } 77 | 78 | // Local balance must always be bigger than remote balance 79 | if (localBalance <= remoteBalance) { 80 | return 'Local balance must be bigger than remote balance' 81 | } 82 | 83 | return false 84 | } 85 | 86 | async main (args, options, cb) { 87 | const order = pick(args, [ 88 | 'product_id', 'local_balance', 'remote_balance', 'channel_expiry' 89 | ]) 90 | 91 | const orderErr = this._validateOrder(order) 92 | if (orderErr) { 93 | return cb(null, this.errRes(orderErr)) 94 | } 95 | 96 | if (!order.product_id) { 97 | return cb(null, this.errRes('Invalid params')) 98 | } 99 | 100 | const db = this.db 101 | let product 102 | try { 103 | product = await db.Inventory.findOne({ 104 | _id: new db.ObjectId(order.product_id) 105 | }) 106 | } catch (err) { 107 | console.log(err) 108 | return cb(null, this.errRes('Failed to find product')) 109 | } 110 | 111 | if (!product) { 112 | return cb(null, this.errRes('Not in stock')) 113 | } 114 | 115 | const capLimit = await this.checkCapacityLimit(order.remote_balance + order.local_balance) 116 | if (!capLimit.accept) { 117 | return cb(null, this.errRes(`Requested channel capacity is too large. Max channel size: $${constants.max_channel_dollar}. Requested channel: $${capLimit.usd_size} `)) 118 | } 119 | 120 | const totalCapacity = new BN(order.local_balance).plus(order.remote_balance).toString() 121 | 122 | // Fee: How much the service is charging for channel opening 123 | // totalAmount: Total amount including local balance and remote balance charges 124 | let price, totalAmount 125 | try { 126 | const p = await getChannelPrice({ 127 | channel_expiry: order.channel_expiry, 128 | local_balance: order.local_balance, 129 | remote_balance: order.remote_balance 130 | }) 131 | price = p.price 132 | totalAmount = p.totalAmount 133 | } catch (err) { 134 | console.log(err) 135 | return cb(null, this.errRes()) 136 | } 137 | 138 | waterfall([ 139 | (next) => { 140 | this.callLn('getOnChainBalance', null, (err, balance) => { 141 | if (err) return next(err) 142 | const minBal = constants.min_wallet_balance_buffer + balance 143 | if (minBal <= totalCapacity) { 144 | const errStr = 'Low onchain bitcoin balance.' 145 | console.log(errStr) 146 | this.alertSlack('warning', errStr) 147 | return next(true, this.errRes('Service is not available at this time.')) 148 | } 149 | next(null, null) 150 | }) 151 | }, 152 | async (res, next) => { 153 | const invoice = await this._getLnInvoice(order.product_id, totalAmount) 154 | order.user_agent = options.user_agent 155 | order.renewals = [] 156 | order.onchain_payments = [] 157 | order.onchain_payment_swept = false 158 | order.channel_expiry_ts = this._calExpiry(order.channel_expiry) 159 | order.order_expiry = Date.now() + constants.order_expiry 160 | order.ln_invoice = invoice 161 | order.total_amount = +totalAmount 162 | order.price = +price 163 | order.product_info = omit(product, ['_id', 'stats']) 164 | return order 165 | }, 166 | (order, next) => { 167 | this.callBtc('getNewAddress', { tag: 'channel_order' }, (err, data) => { 168 | if (err) return next(err) 169 | if (!data.address) { 170 | this.alertSlack('warning', 'Was not able to generate bitcoin address for order') 171 | } 172 | order.btc_address = data.address 173 | next(null, order) 174 | }) 175 | }, 176 | (order, next) => { 177 | Order.newLnChannelOrder(order, (err, data) => { 178 | if (err || !data.insertedId) { 179 | console.log('Failed to create ID') 180 | return next(err || new Error('Failed to save to db')) 181 | } 182 | next(null, { 183 | order_id: data.insertedId, 184 | ln_invoice: order.ln_invoice.request, 185 | price: order.price, 186 | total_amount: order.total_amount, 187 | btc_address: order.btc_address, 188 | lnurl_channel: lnurl.encode(publicUri + '/v1/lnurl/channel?order_id=' + data.insertedId), 189 | order_expiry: order.order_expiry 190 | }) 191 | }) 192 | } 193 | ], (err, data) => { 194 | if (err) { 195 | console.log(err, data) 196 | this.alertSlack('warning', 'Failed to create buy order') 197 | return cb(null, this.errRes()) 198 | } 199 | 200 | if(options.user_agent === "Bitkit") { 201 | this.alertSlack("info", `New order from Bitkit: ${data.order_id}`) 202 | } 203 | 204 | cb(null, data) 205 | }) 206 | } 207 | } 208 | 209 | module.exports = BuyChannel 210 | const n = new BuyChannel({}) 211 | -------------------------------------------------------------------------------- /src/Channel/ExchangeRate.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { Worker } = require('blocktank-worker') 3 | const exchangeAPI = require('../util/exchange-api') 4 | const convert = require('../util/sats-convert') 5 | 6 | class ExchangeRate extends Worker { 7 | constructor (config) { 8 | config.name = 'svc:exchange_rate' 9 | config.port = config.port || 8282 10 | super(config) 11 | } 12 | 13 | async getBtcUsd (args, cb) { 14 | let usd 15 | if(args && args.ts){ 16 | try { 17 | usd = await exchangeAPI.historicalBtcUsd(args.ts) 18 | } catch (err) { 19 | return cb(new Error('Failed to convert get btc usd historical price')) 20 | } 21 | } else { 22 | try { 23 | usd = await exchangeAPI.getBtcUsd() 24 | } catch (err) { 25 | return cb(new Error('Failed to get btc usd price')) 26 | } 27 | } 28 | cb(null, usd) 29 | } 30 | 31 | satsToBtc ({ sats }, cb) { 32 | cb(null, { 33 | sats, 34 | btc: convert.toBtc(sats) 35 | }) 36 | } 37 | 38 | async getRatesFrontend (args, config, cb) { 39 | let rates 40 | try { 41 | rates = await exchangeAPI.getRatesRaw('tBTCUSD,tBTCEUR,tBTCJPY,tBTCGBP') 42 | } catch (err) { 43 | console.log(err) 44 | return cb(null, this.errRes('Failed to get rates at this timej')) 45 | } 46 | cb(null, rates) 47 | } 48 | } 49 | 50 | module.exports = ExchangeRate 51 | 52 | const n = new ExchangeRate({}) 53 | -------------------------------------------------------------------------------- /src/Channel/FinaliseChannel.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { promisify } = require('util') 3 | const { Worker } = require('blocktank-worker') 4 | const { parseUri } = require('../util/lnurl') 5 | const { pick } = require('lodash') 6 | const Order = require('../Orders/Order') 7 | const config = require('../../config/server.json') 8 | 9 | class FinaliseChannel extends Worker { 10 | constructor (config) { 11 | config.name = 'svc:manual_finalise' 12 | config.port = 7671 13 | super(config) 14 | this.checkNodeCompliance = promisify(this.checkNodeCompliance) 15 | this._channel_claims = new Set() 16 | } 17 | 18 | async checkNodeCompliance (pubkey, socket, order, cb) { 19 | if (!config.constants.compliance_check) return cb(null, { aml_pass: true }) 20 | this.gClient.send('svc:channel_aml', { 21 | method: 'amlFiatCapactyCheck', 22 | args: { 23 | action: "channel_selling_request", 24 | node_public_key: pubkey, 25 | node_socket: socket, 26 | order 27 | } 28 | }, (err, data) => { 29 | if (err) { 30 | console.log(err) 31 | return cb(new Error('Failed to check node')) 32 | } 33 | if (data.error) { 34 | return cb(new Error(data.error)) 35 | } 36 | cb(null, data) 37 | }) 38 | } 39 | 40 | async main (args, options, cb) { 41 | const params = pick(args, [ 42 | 'order_id', 'node_uri', 'uri_src', 'private' 43 | ]) 44 | 45 | const end = (err, data) => { 46 | this._channel_claims.delete(params.order_id) 47 | cb(err, data) 48 | } 49 | 50 | if (this._channel_claims.has(params.order_id)) { 51 | return cb(null, this.errRes('Channel is being claimed')) 52 | } 53 | 54 | const db = this.db 55 | 56 | let order 57 | try { 58 | order = await db.LnChannelOrders.findOne({ 59 | _id: new db.ObjectId(params.order_id) 60 | }) 61 | } catch (err) { 62 | console.log(err) 63 | return end(null, this.errRes('Failed to find order')) 64 | } 65 | 66 | if (!order) { 67 | return end(null, this.errRes('Failed to find order')) 68 | } 69 | 70 | if (![Order.ORDER_STATES.PAID, Order.ORDER_STATES.URI_SET].includes(order.state)) { 71 | return end(null, this.errRes('Order not paid or already claimed')) 72 | } 73 | 74 | const uri = parseUri(params.node_uri) 75 | if (uri.err) { 76 | return end(null, this.errRes('Node URI not valid')) 77 | } 78 | 79 | if (config.constants.compliance_check) { 80 | const amlCheck = await this.checkNodeCompliance(uri.public_key, uri.addr, order) 81 | if (!amlCheck.aml_pass) { 82 | this.alertSlack('notice', `Order failed AML check. Order: ${order._id} . Node: ${uri.public_key} . ${amlCheck.reason || ''}`) 83 | return end(null, this.errRes( 84 | 'Failed to claim channel: ' + amlCheck.reason 85 | )) 86 | } 87 | } 88 | 89 | order.remote_node = uri 90 | order.remote_node_src = !params.uri_src ? 'manual' : params.uri_src 91 | if(+params.private === 0){ 92 | params.private_channel = false 93 | } else { 94 | order.private_channel = params.private 95 | } 96 | order.state = Order.ORDER_STATES.URI_SET 97 | Order.updateOrder(params.order_id, order, (err) => { 98 | if (err) { 99 | console.log(err) 100 | return end(err, this.errRes('Failed to claim channel')) 101 | } 102 | end(null, { order_id: params.order_id, node_uri: order.remote_node.public_key }) 103 | }) 104 | } 105 | } 106 | 107 | module.exports = FinaliseChannel 108 | const n = new FinaliseChannel({}) 109 | -------------------------------------------------------------------------------- /src/Channel/GetOrder.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { Worker } = require('blocktank-worker') 3 | const Order = require('../Orders/Order') 4 | const { public_uri: publicUri } = require('../../config/server.json') 5 | const { ORDER_STATES } = require('../Orders/Order') 6 | const { get, find } = require('lodash') 7 | const lnurl = require('../util/lnurl') 8 | 9 | const privateProps = [ 10 | 'remote_node_src', 11 | 'renewal_quote', 12 | 'onchain_payment_swept', 13 | 'order_result', 14 | 'ln_invoice', 15 | 'product_info', 16 | 'onchain_payment_swept', 17 | 'channel_closed_early', 18 | 'renewals', 19 | 'user_agent' 20 | ] 21 | 22 | class GetOrder extends Worker { 23 | constructor (config) { 24 | config.name = 'svc:get_order' 25 | config.port = 8761 26 | super(config) 27 | } 28 | 29 | async _getLnStats (order) { 30 | const nodePub = get(order, 'remote_node.public_key') 31 | const chanId = get(order, 'lightning_channel_id') 32 | let channels 33 | try { 34 | channels = await this.callLn('listChannels', { partner_public_key: nodePub }) 35 | } catch (err) { 36 | console.log(err) 37 | return null 38 | } 39 | const ch = find(channels, { id: chanId }) 40 | if (!ch) return null 41 | return { 42 | remote_balance: ch.remote_balance, 43 | local_balance: ch.local_balance 44 | } 45 | } 46 | 47 | async updateOrder (args, cb) { 48 | console.log('Updating order: ', args.id) 49 | Order.updateOrder(args.id, args.update, cb) 50 | } 51 | 52 | getPendingPaymentOrders (args, cb) { 53 | const query = { 54 | order_expiry: { $lte: Date.now() + 5000 }, 55 | state: ORDER_STATES.CREATED, 56 | ...args 57 | } 58 | Order.getOrdersInState(query, (err, orders) => { 59 | if (err) return cb(err) 60 | cb(null, orders) 61 | }) 62 | } 63 | 64 | markOrdersExpired (args, cb) { 65 | const query = { 66 | $or:[ 67 | { 68 | order_expiry: { $lte: Date.now() }, 69 | state: ORDER_STATES.CREATED, 70 | 'onchain_payments.0': { $exists: false } 71 | }, 72 | { 73 | order_expiry: { $lte: Date.now() - 8.64e+7 }, 74 | state: ORDER_STATES.CREATED, 75 | }, 76 | ] 77 | } 78 | Order.updateOrders(query, { 79 | state: ORDER_STATES.EXPIRED 80 | }, (err, orders) => { 81 | if (err) return cb(err) 82 | cb(null, orders) 83 | }) 84 | } 85 | 86 | _shouldAcceptZeroConf(order){ 87 | 88 | if (order.state !== ORDER_STATES.CREATED || order.zero_conf) return false 89 | 90 | if(order.zero_conf_satvbyte_expiry && order.zero_conf_satvbyte_expiry > Date.now()) return false 91 | 92 | return true 93 | } 94 | 95 | async _formatOrders(nodeInfo, data, fullData){ 96 | data.product_id = data.product_id._id 97 | data.purchase_invoice = data.ln_invoice.request 98 | if (data.state === ORDER_STATES.GIVE_UP) { 99 | const res = data.order_result.pop() 100 | data.channel_open_error = res.error 101 | } 102 | 103 | data.lnurl_decoded = { 104 | uri: nodeInfo.uris.pop(), 105 | k1: data._id, 106 | tag: 'channelRequest' 107 | } 108 | data.lnurl_string = lnurl.encode(`${publicUri}/v1/lnurl/channel?order_id=` + data._id) 109 | data.renewals = data.renewals.map((r) => { 110 | r.ln_invoice = r.ln_invoice.request 111 | return r 112 | }) 113 | 114 | 115 | if(this._shouldAcceptZeroConf(data)) { 116 | data.zero_conf_satvbyte = false 117 | try { 118 | const zc = await this._getZeroConfQuote(data.total_amount) 119 | if (zc.accepted) { 120 | data.zero_conf_satvbyte = zc.minimum_satvbyte 121 | data.zero_conf_satvbyte_expiry = zc.fee_expiry 122 | Order.updateOrder(data._id, { 123 | zero_conf_satvbyte_expiry: data.zero_conf_satvbyte_expiry, 124 | zero_conf_satvbyte: data.zero_conf_satvbyte 125 | }) 126 | } 127 | } catch (err) { 128 | console.log('Failed to get zero conf', err) 129 | } 130 | } 131 | 132 | try { 133 | data.current_channel_info = await this._getLnStats(data) 134 | } catch (err) { 135 | data.channel_info = null 136 | console.log(err) 137 | } 138 | 139 | if(+fullData === 1) { 140 | data.opening_attempts = data.order_result 141 | } 142 | 143 | privateProps.forEach((k) => { 144 | delete data[k] 145 | }) 146 | 147 | return data 148 | } 149 | 150 | async main (args, options, cb) { 151 | const orderId = args.order_id 152 | const fullData = args.full_data 153 | if(!orderId) return this.errRes("Order id not passed") 154 | const nodeInfo = await this.callLn('getInfo', {}) 155 | const orders = orderId.split(",") 156 | if(orders.length >= 50) return this.errRes("too many orders passed. max 50 orders") 157 | Order.find({ _id: orders }, async (err, data) => { 158 | if (err || !data || data?.length === 0) { 159 | console.log(err, data) 160 | return cb(null, this.errRes('Order not found')) 161 | } 162 | const formatted = await Promise.all(data.map((d)=> this._formatOrders(nodeInfo, d, fullData) )) 163 | if(orders.length === 1){ 164 | return cb(null, formatted.pop()) 165 | } 166 | cb(null, formatted) 167 | }) 168 | } 169 | } 170 | 171 | module.exports = GetOrder 172 | 173 | const n = new GetOrder({}) 174 | -------------------------------------------------------------------------------- /src/Channel/LnUrlChannel.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { Worker } = require('blocktank-worker') 3 | const Order = require('../Orders/Order') 4 | const async = require('async') 5 | const { ORDER_STATES } = require('../Orders/Order') 6 | const { public_uri: publicUri } = require('../../config/server.json') 7 | 8 | class LNUrlChannel extends Worker { 9 | constructor (config) { 10 | config.name = 'svc:lnurl_channel' 11 | config.port = 8799 12 | super(config) 13 | } 14 | 15 | getNodeInfo (cb) { 16 | this.callLn('getInfo', {}, cb) 17 | } 18 | 19 | lnurlErr (txt) { 20 | return { 21 | status: 'ERROR', reason: txt || 'Failed to finish process' 22 | } 23 | } 24 | 25 | connectToNode (args, options, cb) { 26 | async.parallel([ 27 | (next) => { 28 | Order.findOne({ _id: args.order_id }, next) 29 | }, 30 | (next) => { 31 | this.getNodeInfo(next) 32 | } 33 | ], (err, [order, nodeinfo]) => { 34 | if (err) { 35 | console.log(err) 36 | return cb(null, this.lnurlErr()) 37 | } 38 | if (!order) return cb(null, this.lnurlErr('Order not found')) 39 | if (order.state !== ORDER_STATES.PAID) return cb(null, this.lnurlErr('Order not in the right state')) 40 | 41 | const uri = nodeinfo.uris.pop() 42 | if (!uri) return cb(null, this.lnurlErr("Node isn't ready")) 43 | 44 | cb(null, { 45 | uri, 46 | callback: publicUri + '/v1/lnurl/channel', 47 | k1: args.order_id, 48 | tag: 'channelRequest' 49 | }) 50 | }) 51 | } 52 | 53 | openChannel (args, options, cb) { 54 | this.gClient.send('svc:manual_finalise', [{ 55 | order_id: args.k1, 56 | node_uri: args.remoteid, 57 | uri_src: 'lnurl', 58 | private: args.private 59 | }, {}], (err, data) => { 60 | if (err) { 61 | return cb(null, this.lnurlErr('Failed to setup channel')) 62 | } 63 | if (data && data.error) { 64 | return cb(null, this.lnurlErr(data.error)) 65 | } 66 | cb(null, { status: 'OK' }) 67 | }) 68 | } 69 | 70 | main (args, options, cb) { 71 | const { 72 | order_id: orderId, 73 | k1, 74 | remoteid 75 | } = args 76 | 77 | if (orderId) return this.connectToNode(args, options, cb) 78 | 79 | if (k1 && remoteid) return this.openChannel(args, options, cb) 80 | 81 | return cb(null, this.lnurlErr('Invalid request')) 82 | } 83 | } 84 | 85 | module.exports = LNUrlChannel 86 | 87 | const n = new LNUrlChannel({}) 88 | -------------------------------------------------------------------------------- /src/Channel/NodeInfo.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { Worker } = require('blocktank-worker') 3 | const { pick } = require('lodash') 4 | const async = require('async') 5 | const { constants, db_url } = require('../../config/server.json') 6 | const Order = require('../Orders/Order') 7 | const exchange = require('../util/exchange-api') 8 | const { BigNumber } = require('bignumber.js') 9 | 10 | class NodeInfo extends Worker { 11 | constructor (config) { 12 | config.name = 'svc:node_info' 13 | config.port = config.port || 7674 14 | config.db_url = db_url 15 | 16 | super(config) 17 | } 18 | 19 | async _calcChanCapacity () { 20 | const maxDollar = constants.max_channel_dollar.toString() 21 | const maxRecieve = await exchange.usdToSats(maxDollar) 22 | const maxSpendSats = BigNumber(maxRecieve).minus(constants.channel_size_buffer_sats).toString() 23 | const maxspendUsd = await exchange.satsToUSD(maxSpendSats) 24 | return { 25 | max_chan_receiving: maxRecieve, 26 | max_chan_receiving_usd: maxDollar, 27 | max_chan_spending: maxSpendSats, 28 | max_chan_spending_usd: maxspendUsd 29 | } 30 | } 31 | 32 | async main (args, options, cb) { 33 | const channelCaps = await this._calcChanCapacity() 34 | async.auto({ 35 | node_info: (next) => { 36 | this.callLn('getInfo', {}, (err, data) => { 37 | if (err) return next(new Error('failed to get node')) 38 | const node = pick(data, [ 39 | 'alias', 'active_channels_count', 'uris', 'public_key' 40 | ]) 41 | next(null, node) 42 | }) 43 | }, 44 | capacity: (next) => { 45 | this.callLn('listChannels', {}, (err, channels) => { 46 | if (err) return next(new Error('Failed to get channels')) 47 | const initVals = { local_balance: 0, remote_balance: 0 } 48 | if (!channels) return next(null, initVals) 49 | const stats = channels.reduce((total, chan) => { 50 | if (!chan.is_active || chan.is_private) return total 51 | total.local_balance += chan.local_balance 52 | total.remote_balance += chan.remote_balance 53 | return total 54 | }, initVals) 55 | next(null, stats) 56 | }) 57 | }, 58 | chainBalance: (next) => { 59 | this.callLn('getOnChainBalance', null, (err, balance) => { 60 | if (err) return next(err) 61 | if (constants.min_wallet_balance_buffer > balance) return next(null, false) 62 | next(null, true) 63 | }) 64 | }, 65 | maxUsdCap: (next) => { 66 | if (!constants.compliance_check) { 67 | return next(null, { max_node_usd_capacity: null }) 68 | } 69 | this.callWorker('svc:channel_aml', 'getMaxOrderUSD', {}, next) 70 | }, 71 | services: ['chainBalance', 'maxUsdCap', ({ chainBalance, maxUsdCap }, next) => { 72 | next(null, [{ 73 | available: chainBalance, 74 | description: 'Channel Liquidity', 75 | product_id: constants.product_id, 76 | min_channel_size: constants.min_channel_size, 77 | max_channel_size: constants.max_channel_size, 78 | min_chan_expiry: constants.min_chan_expiry, 79 | max_chan_expiry: constants.max_chan_expiry, 80 | max_node_usd_capacity: maxUsdCap.max_node_usd_capacity, 81 | order_states: Order.ORDER_STATES, 82 | ...channelCaps 83 | }]) 84 | }] 85 | }, (err, data) => { 86 | if (err) return cb(err) 87 | delete data.chainBalance 88 | delete data.maxUsdCap 89 | cb(null, data) 90 | }) 91 | } 92 | } 93 | 94 | module.exports = NodeInfo 95 | 96 | const n = new NodeInfo({}) 97 | -------------------------------------------------------------------------------- /src/Channel/PromoChannels.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { promisify } = require('util') 3 | const { Worker, DB } = require('blocktank-worker') 4 | const { parseUri } = require('../util/lnurl') 5 | const { pick } = require('lodash') 6 | const async = require('async') 7 | const Inventory = require('../Inventory/Inventory') 8 | const { ORDER_STATES } = require('../Orders/Order') 9 | const config = require('../../config/server.json') 10 | 11 | class PromoChannels extends Worker { 12 | constructor (config) { 13 | config.name = 'svc:promo_channels' 14 | config.port = 7671 15 | super(config) 16 | this.checkNodeCompliance = promisify(this.checkNodeCompliance) 17 | this.product_id = "6305726c806073342fb42e43" 18 | } 19 | 20 | async checkNodeCompliance (pubkey, socket, order, cb) { 21 | if (!config.constants.compliance_check) return cb(null, { aml_pass: true }) 22 | this.gClient.send('svc:channel_aml', { 23 | method: 'amlFiatCapactyCheck', 24 | args: { 25 | node_public_key: pubkey, 26 | node_socket: socket, 27 | order 28 | } 29 | }, (err, data) => { 30 | if (err) { 31 | console.log(err) 32 | return cb(new Error('Failed to check node')) 33 | } 34 | if (data.error) { 35 | return cb(new Error(data.error)) 36 | } 37 | cb(null, data) 38 | }) 39 | } 40 | 41 | async getStats(cb){ 42 | Inventory.find({ 43 | _id : this.product_id 44 | },cb) 45 | } 46 | 47 | async _updateOrder(args,cb){ 48 | return new Promise((resolve,reject)=>{ 49 | this.callWorker('svc:get_order', "updateOrder",args, (err,data) =>{ 50 | if(err){ 51 | return reject(err) 52 | } 53 | resolve(data) 54 | }) 55 | }) 56 | } 57 | 58 | async checkCapcity(){ 59 | let inv 60 | try{ 61 | inv = await promisify(this.getStats.bind(this))() 62 | } catch(err) { 63 | console.log(err) 64 | throw new Error("FAILED_TO_GET_INVENTORY") 65 | } 66 | const newTotal = inv.stats.capacity_available_tick - inv.product_meta.chan_size 67 | if( newTotal >= 0){ 68 | return true 69 | } 70 | return false 71 | } 72 | 73 | _getOrders (query, cb) { 74 | this.callWorker('svc:channel_admin', "getOrders", query, (err, data) => { 75 | if (err) { 76 | return cb(err) 77 | } 78 | cb(null, data) 79 | }) 80 | } 81 | 82 | async processOrders (cb) { 83 | const orders = await promisify(this._getOrders.bind(this))({ 84 | product_id: this.product_id, 85 | state: 0 86 | }) 87 | 88 | async.map(orders,async (order)=>{ 89 | if(!this.checkCapcity()){ 90 | order.state = ORDER_STATES.GIVE_UP 91 | } 92 | //update order 93 | return order 94 | },(err,data)=>{ 95 | console.log(err,data) 96 | if(err){ 97 | return cb(err) 98 | } 99 | cb(null, { 100 | orders_processed: data.length 101 | }) 102 | }) 103 | } 104 | } 105 | 106 | module.exports = PromoChannels 107 | -------------------------------------------------------------------------------- /src/Channel/ZeroConf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { Worker, StatusFile } = require('blocktank-worker') 3 | const Bignumber = require('bignumber.js') 4 | const async = require('async') 5 | const _ = require('lodash') 6 | const Order = require('../Orders/Order') 7 | const { ORDER_STATES } = require('../Orders/Order') 8 | const { zero_conf: zcConfig, db_url: dbURL, constants } = require('../../config/server.json') 9 | 10 | async function main () { 11 | class ZeroConf extends Worker { 12 | constructor (config) { 13 | config.name = 'svc:btc_zero_conf_orders' 14 | config.port = 8768 15 | config.db_url = dbURL 16 | super(config) 17 | } 18 | 19 | checkZeroConfAmount (args, cb) { 20 | const state = statusFile.data 21 | let res = true 22 | if (state.amount_processed >= zcConfig.max_total_amount) res = false 23 | if (state.orders_processed > zcConfig.max_orders) res = false 24 | 25 | const capacity = (zcConfig.max_total_amount - state.amount_processed) - args.amount 26 | if (capacity < 10000) res = false 27 | this.callWorker('svc:btc:mempool', 'getCurrrentFeeThreshold', {}, (err, data) => { 28 | if (err) { 29 | console.log(err) 30 | return cb(null, this.errRes('Failed to get zero conf fee threshold')) 31 | } 32 | cb(null, { 33 | accepted: res, 34 | minimum_satvbyte: data.min_fee, 35 | fee_expiry: data.min_fee_expiry 36 | }) 37 | }) 38 | } 39 | 40 | mempoolNewTransactions (data, cb) { 41 | cb() 42 | pendingOrders(data) 43 | } 44 | 45 | async onNewBlock (height, cb) { 46 | cb() 47 | console.log(`New Block : ${height}`) 48 | await statusFile.updateFile({ 49 | block_height: height, 50 | orders_processed: 0, 51 | amount_processed: 0 52 | }) 53 | } 54 | 55 | _getMempoolTx (filter) { 56 | return this.callWorker('svc:btc:mempool', 'getMempoolTx', filter) 57 | } 58 | } 59 | 60 | function getOrders (state) { 61 | return new Promise((resolve, reject) => { 62 | Order.find({ 63 | state, 64 | zero_conf: { $exists: false }, 65 | total_amount: { $lte: zcConfig.max_amount }, 66 | order_expiry: { $lte: Date.now() + 10800000 } 67 | }, (err, orders) => { 68 | if (err) return reject(err) 69 | resolve(orders) 70 | }) 71 | }) 72 | } 73 | 74 | function checkPayment (payments) { 75 | 76 | // Payment must be less than maximum amount 77 | const isValidPayment = payments.filter((p) => p.amount_base >= zcConfig.max_amount) 78 | if (isValidPayment.length !== 0) { 79 | return 'PAYMENT_TOO_LARGE' 80 | } 81 | 82 | // Check if maximum VALUE per block is reached 83 | const paymentAmount = payments[0].amount_base 84 | const totalValue = new Bignumber(statusFile.data.amount_processed).plus(paymentAmount) 85 | if (totalValue.gte(zcConfig.max_amount)) { 86 | zcWorker.alertSlack('info', 'Maximum amount of Bitcoin zero conf reached for current block') 87 | return 'MAX_VALUE_REACHED' 88 | } 89 | 90 | // Check if maximum COUNT per block is reached 91 | const totalCount = new Bignumber(payments.length).plus(statusFile.data.orders_processed) 92 | 93 | if (totalCount.gt(zcConfig.max_count)) { 94 | zcWorker.alertSlack('info', 'Maximum count of zero conf payments accepted for this block') 95 | return 'MAX_COUNT_REACHED' 96 | } 97 | return null 98 | } 99 | 100 | async function isBlacklistedPayment (payments) { 101 | console.log('Checking blacklisted payments: ', payments.length) 102 | if(!constants.compliance_check) return { blacklisted : false } 103 | const addr = payments.map((tx) => tx.from) 104 | const res = await zcWorker.callWorker('svc:channel_aml', 'isAddressBlacklisted', { 105 | address: addr 106 | }) 107 | return res 108 | } 109 | 110 | async function pendingOrders () { 111 | const orders = await getOrders(ORDER_STATES.CREATED) 112 | console.log(`Pending orders: ${orders.length}`) 113 | const address = orders 114 | .map((tx) => [tx.btc_address, tx.zero_conf_satvbyte] ) 115 | .filter(Boolean) 116 | const mempoolTx = await zcWorker._getMempoolTx({ address }) 117 | return async.map(orders, async (order) => { 118 | let totalAmount = null 119 | // Find transactions that belong to this order 120 | if(order.onchain_payments.length !== 0) return null 121 | 122 | const payments = _.filter(mempoolTx, { to: order.btc_address }) 123 | if (payments.length === 0) return null 124 | 125 | const addrCheck = await isBlacklistedPayment(payments) 126 | if (addrCheck.blacklisted) { 127 | order.state = Order.ORDER_STATES.REJECTED 128 | const str = `Mempool detected blacklisted payment. Rejecting order:\n order: ${order._id} \n${JSON.stringify(payments)}` 129 | zcWorker.alertSlack('notice', str) 130 | console.log(str) 131 | await Order.updateOrder(order._id, order) 132 | return null 133 | } 134 | 135 | // Add payments to list 136 | const alreadyExists = payments.filter((p) => { 137 | return _.find(order.onchain_payments, { hash: p.hash }) 138 | }) 139 | if (alreadyExists.length > 0) return null 140 | order.onchain_payments = order.onchain_payments.concat(payments) 141 | const validZeroConf = _.filter(payments, { zero_conf: true }) 142 | 143 | // Verify that payment is a valid Zero conf payment so we can process it. 144 | if (validZeroConf.length === payments.length && !checkPayment(payments)) { 145 | order.zero_conf = true 146 | 147 | totalAmount = order.onchain_payments.reduce((current, tx) => { 148 | return current.plus(tx.amount_base) 149 | }, new Bignumber(0)) 150 | 151 | if (totalAmount.gte(order.total_amount)) { 152 | order.state = Order.ORDER_STATES.PAID 153 | } 154 | console.log('New zero conf payment') 155 | zcWorker.alertSlack('info', `Zero conf payment detected: \n order: ${order._id} \n txid: ${_.map(order.onchain_payments, 'hash').join('\n')}`) 156 | order.amount_received = totalAmount.toString() 157 | } 158 | 159 | await Order.updateOrder(order._id, order) 160 | if (totalAmount) { 161 | await statusFile.updateFile({ 162 | block_height: statusFile.data.block_height, 163 | amount_processed: totalAmount.plus(statusFile.data.amount_processed), 164 | orders_processed: totalAmount.plus(statusFile.data.orders_processed) 165 | }) 166 | } 167 | return null 168 | }) 169 | } 170 | 171 | const statusFile = new StatusFile({ 172 | tag: 'orders', 173 | postfix: 'zero_conf' 174 | }) 175 | 176 | await statusFile.loadFile({ 177 | block_height: 0, 178 | orders_processed: 0, 179 | amount_processed: 0 180 | }) 181 | const zcWorker = new ZeroConf({}) 182 | 183 | let _checkingOrders = false 184 | setInterval(async () => { 185 | if (_checkingOrders) return 186 | _checkingOrders = true 187 | try { 188 | await pendingOrders() 189 | } catch (err) { 190 | console.log('Failed checking for zero conf') 191 | console.log(err) 192 | } 193 | _checkingOrders = false 194 | }, 5000) 195 | 196 | return zcWorker 197 | } 198 | 199 | module.exports = main() 200 | -------------------------------------------------------------------------------- /src/DB/DB.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { MongoClient, ObjectId } = require('mongodb') 4 | const config = require('../../config/server.json') 5 | let _db = null 6 | 7 | function getDb (cb) { 8 | const url = config.db_url 9 | const dbName = 'Lighthouse' 10 | MongoClient.connect(url, { useUnifiedTopology: true }, function (err, client) { 11 | if (err) throw err 12 | const db = client.db(dbName) 13 | _db = { 14 | db, 15 | LnChannelOrders: db.collection('LnChannelOrders'), 16 | Inventory: db.collection('Inventory'), 17 | BtcAddress: db.collection('BtcAddress'), 18 | ObjectId 19 | } 20 | cb(null, _db) 21 | }) 22 | } 23 | 24 | module.exports = (cb) => { 25 | return new Promise((resolve, reject) => { 26 | if (_db) { 27 | return cb ? cb(null, _db) : resolve(_db) 28 | } 29 | getDb((err, db) => { 30 | if (err) { 31 | return cb ? cb(err) : reject(err) 32 | } 33 | cb ? cb(null, db) : resolve(db) 34 | }) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/Inventory/Inventory.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const db = require('./../DB/DB') 3 | const { EventEmitter } = require('events') 4 | 5 | class Item { 6 | constructor (params) { 7 | this.data = params 8 | } 9 | 10 | toDocument () { 11 | return this.data 12 | } 13 | } 14 | 15 | class Inventory extends EventEmitter { 16 | constructor (params) { 17 | super() 18 | this.data = params 19 | this.ready = false 20 | db((err, db) => { 21 | if (err) throw err 22 | this.db = db 23 | this.ready = true 24 | process.nextTick(() => this.emit('ready')) 25 | }) 26 | } 27 | 28 | static addNewItem (params, cb) { 29 | const inv = new Inventory() 30 | inv.on('ready', () => { 31 | inv.db.Inventory.insertMany([new Item(params).toDocument()], cb) 32 | }) 33 | } 34 | 35 | static find (query, cb) { 36 | const inv = new Inventory() 37 | inv.on('ready', () => { 38 | if(query._id){ 39 | query._id = new inv.db.ObjectId(query._id) 40 | } 41 | inv.db.Inventory.find(query).toArray(cb) 42 | }) 43 | } 44 | 45 | static updateOne (id, data, cb) { 46 | const inv = new Inventory() 47 | inv.on('ready', () => { 48 | inv.db.Inventory.updateOne( 49 | { _id: new inv.db.ObjectId(id) }, 50 | { $set: data } 51 | , cb) 52 | }) 53 | } 54 | 55 | static updateSoldStats (id, cb) { 56 | const inv = new Inventory() 57 | inv.on('ready', () => { 58 | inv.db.Inventory.updateOne( 59 | { _id: new inv.db.ObjectId(id) }, 60 | { $inc: { 'stats.sold_count': 1, 'stats.available': -1 } } 61 | , cb) 62 | }) 63 | } 64 | } 65 | 66 | module.exports = Inventory 67 | -------------------------------------------------------------------------------- /src/Orders/Order.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const db = require('../DB/DB') 3 | const { EventEmitter } = require('events') 4 | 5 | const promcb = (resolve, reject, cb) => { 6 | return (err, data) => { 7 | if (err) { 8 | return cb ? cb(err, data) : reject(err) 9 | } 10 | cb ? cb(err, data) : resolve(data) 11 | } 12 | } 13 | 14 | class Order extends EventEmitter { 15 | constructor (params) { 16 | super() 17 | this.data = params 18 | this.ready = false 19 | db((err, db) => { 20 | if (err) throw err 21 | this.db = db 22 | this.ready = true 23 | process.nextTick(() => this.emit('ready')) 24 | }) 25 | } 26 | 27 | static ORDER_STATES = { 28 | CREATED: 0, 29 | PAID: 100, 30 | REFUNDED: 150, 31 | URI_SET: 200, 32 | OPENING: 300, 33 | CLOSING: 350, 34 | GIVE_UP: 400, 35 | EXPIRED: 410, 36 | REJECTED: 450, 37 | CLOSED: 450, 38 | OPEN: 500 39 | } 40 | 41 | static from (params) { 42 | return new Order(params) 43 | } 44 | 45 | static updateOrder(id,data, cb){ 46 | return new Promise((resolve, reject) => { 47 | const order = new Order() 48 | order.on('ready', () => { 49 | if(data._id){ 50 | delete data._id 51 | } 52 | if(data.product_id){ 53 | data.product_id = new order.db.ObjectId(data.product_id) 54 | } 55 | 56 | return order.db.LnChannelOrders.updateOne( 57 | { _id: new order.db.ObjectId(id) }, 58 | { 59 | $set: data 60 | }, promcb(resolve, reject, cb)) 61 | }) 62 | }) 63 | } 64 | 65 | static updateOrders(query,data, cb){ 66 | return new Promise((resolve, reject) => { 67 | const order = new Order() 68 | order.on('ready', () => { 69 | return order.db.LnChannelOrders.update(query, 70 | { $set: data }, promcb(resolve, reject, cb)) 71 | }) 72 | }) 73 | } 74 | 75 | static getOrdersInState(options,cb){ 76 | Order.find({ 77 | ...options, 78 | order_expiry: { $gte: Date.now() } 79 | }, cb) 80 | } 81 | 82 | static newLnChannelOrder (params, cb) { 83 | const order = new Order() 84 | order.on('ready', () => { 85 | order.db.LnChannelOrders.insertOne({ 86 | ...params, 87 | product_id: new order.db.ObjectId(params.product_id), 88 | created_at: Date.now(), 89 | order_result: [], 90 | state: Order.ORDER_STATES.CREATED 91 | }, cb) 92 | }) 93 | } 94 | 95 | static find (query, cb) { 96 | const order = new Order() 97 | order.on('ready', () => { 98 | let limit, sort, skip 99 | if(query._limit){ 100 | limit = query._limit 101 | } 102 | 103 | if(query._sort){ 104 | sort = query._sort 105 | } 106 | 107 | if(query._skip && query._skip > 0 & query._skip <= 100){ 108 | skip = query._skip 109 | } 110 | 111 | try{ 112 | if(query._id) { 113 | if(Array.isArray(query._id)){ 114 | query._id = { $in : query._id.map((id)=> new order.db.ObjectId(id)) } 115 | } else { 116 | query._id = new order.db.ObjectId(query._id) 117 | } 118 | } 119 | } catch(err){ 120 | console.log(err) 121 | return cb(new Error("invalid order id")) 122 | } 123 | 124 | delete query._sort 125 | delete query._skip 126 | delete query._limit 127 | 128 | 129 | let dbCall = order.db.LnChannelOrders.find(query) 130 | 131 | if(sort){ 132 | dbCall = dbCall.sort(sort) 133 | } 134 | 135 | if(skip){ 136 | dbCall = dbCall.skip(skip) 137 | } 138 | 139 | if(limit){ 140 | dbCall = dbCall.limit(limit) 141 | } 142 | 143 | dbCall.toArray(cb) 144 | }) 145 | } 146 | 147 | static findOne (query, cb) { 148 | return new Promise((resolve, reject) => { 149 | const order = new Order() 150 | order.on('ready', () => { 151 | if(query._id){ 152 | query._id = new order.db.ObjectId(query._id) 153 | } 154 | order.db.LnChannelOrders.findOne(query, promcb(resolve, reject, cb)) 155 | }) 156 | }) 157 | } 158 | } 159 | 160 | module.exports = Order 161 | -------------------------------------------------------------------------------- /src/Server/Endpoints.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class BodyParam { 4 | constructor (txt, mandatory) { 5 | this.name = txt 6 | this.is_mandatory = !mandatory 7 | } 8 | 9 | toString () { 10 | return JSON.stringify({ 11 | name: this.name, 12 | mandatory: this.is_mandatory 13 | }) 14 | } 15 | } 16 | 17 | 18 | // 19 | // These are public endpoints 20 | // 21 | class USER_ENDPOINTS { 22 | static version = '/v1' 23 | static endpoints = { 24 | '/channel/order': { 25 | name: "getOrder", 26 | description:`Get current status of order`, 27 | svc: "svc:get_order", 28 | method: "GET", 29 | body:[ 30 | new BodyParam('order_id'), 31 | ], 32 | geoblocked: true 33 | }, 34 | '/channel/buy': { 35 | name: "buyChannel", 36 | description:`Create an order to open a channel`, 37 | svc: "svc:buy_channel", 38 | method: "POST", 39 | body:[ 40 | new BodyParam('product_id'), 41 | new BodyParam('local_amount'), 42 | new BodyParam('remote_amount'), 43 | new BodyParam('channel_expiry') 44 | ], 45 | geoblocked: true 46 | }, 47 | '/channel/geocheck':{ 48 | description:`Check if geo blocked or not `, 49 | name:"getNodeInfo", 50 | method: "GET", 51 | svc: "svc:buy_channel", 52 | svc_fn : "extGeoCheck", 53 | geoblocked: true 54 | }, 55 | '/channel/manual_finalise': { 56 | name: "set node uri of bought channel", 57 | description:`Set node uri to manually open channel.`, 58 | svc: "svc:manual_finalise", 59 | method: "POST", 60 | body:[ 61 | new BodyParam('order_id'), 62 | new BodyParam('node_id',false), 63 | new BodyParam('private',false) 64 | ], 65 | geoblocked: true 66 | }, 67 | '/node/info':{ 68 | description:`Get information about Chain Reactor node and current liquidity parameters `, 69 | name:"getNodeInfo", 70 | method: "GET", 71 | svc: "svc:node_info", 72 | geoblocked: false 73 | }, 74 | 75 | '/lnurl/channel': { 76 | name: "lnurl channel endpoint", 77 | description:`Set node uri to manually open channel.`, 78 | svc: "svc:lnurl_channel", 79 | method: "GET", 80 | geoblocked: true 81 | }, 82 | '/rate': { 83 | name: "exchangeRate", 84 | description:`Get exchange rate for node.`, 85 | svc: "svc:exchange_rate", 86 | svc_fn : "getRatesFrontend", 87 | method: "GET", 88 | geoblocked: false 89 | } 90 | } 91 | } 92 | 93 | 94 | // 95 | // These are ADMIN endpoints. 96 | // 97 | class ADMIN_ENDPOINTS { 98 | static version = '/v1' 99 | static endpoints = { 100 | '/login':{ 101 | name:"adminLogin", 102 | description:"Admin endpoint login.", 103 | svc: "svc:channel_admin", 104 | svc_fn : "login", 105 | method:"POST", 106 | }, 107 | '/channel/manual_credit' : { 108 | private:true, 109 | name:"manualCredit", 110 | description:"Manually credit transaction", 111 | svc: "svc:btc_address_watch", 112 | method:"POST", 113 | body:[ 114 | new BodyParam("order_id"), 115 | new BodyParam("tx_id"), 116 | ] 117 | }, 118 | '/channel/orders' : { 119 | private:true, 120 | name:"getOrders", 121 | description:"Get orders", 122 | svc: "svc:channel_admin", 123 | method:"GET", 124 | }, 125 | '/channel/refund' : { 126 | private:true, 127 | name:"refund", 128 | description:"Change order state and save refund tx info", 129 | svc: "svc:channel_admin", 130 | svc_fn: "refund", 131 | method:"POST", 132 | }, 133 | '/channel/close' : { 134 | private:true, 135 | name:"closeChannels", 136 | description:"Close channels", 137 | svc: "svc:channel_admin", 138 | svc_fn : "closeChannelsSync", 139 | method:"POST", 140 | }, 141 | '/btc/sweep':{ 142 | private:true, 143 | name:"sweepOnchain", 144 | description:"Transfer funds from onchain btc address.", 145 | svc: "svc:channel_admin", 146 | svc_fn : "sweepOnchainFunds", 147 | method:"POST", 148 | } 149 | } 150 | } 151 | 152 | 153 | module.exports = { 154 | USER_ENDPOINTS, 155 | ADMIN_ENDPOINTS, 156 | } -------------------------------------------------------------------------------- /src/Server/Http.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const express = require('express') 3 | const bodyParser = require('body-parser') 4 | const { EventEmitter } = require('events') 5 | const { Client: GrenacheClient } = require('blocktank-worker') 6 | const Endpoints = require('./Endpoints') 7 | const helmet = require('helmet') 8 | const { ip_block_countries: ipBlocks } = require('../../config/server.json') 9 | 10 | const API_VERSION = '//v1' 11 | 12 | class Server extends EventEmitter { 13 | constructor (config) { 14 | super() 15 | if (!config.endpoint || !Endpoints[config.endpoint]) throw new Error('Endpoint config not valid') 16 | this.endpoints = Endpoints[config.endpoint] 17 | this.config = config 18 | this.app = express() 19 | this.app.use(helmet()) 20 | this.app.use(bodyParser.json()) 21 | this.port = config.port || 4000 22 | this.host = config.host || "localhost" 23 | this.gClient = new GrenacheClient(config) 24 | } 25 | 26 | async isLoggedIn (key) { 27 | return new Promise((resolve, reject) => { 28 | this.gClient.send('svc:simple_auth', { 29 | method: 'isLoggedIn', 30 | args: { key } 31 | }, (err, data) => { 32 | if (err || data.error || !data.logged_in) { 33 | return reject(new Error('Unauthorised')) 34 | } 35 | resolve(data) 36 | }) 37 | }) 38 | } 39 | 40 | async handleRequest (endpoint, req, res) { 41 | let args 42 | 43 | if (ipBlocks && ipBlocks.includes(req.headers['cf-ipcountry']) && endpoint.config.geoblocked) { 44 | return res.status(200).send({ error: 'GEO_BLOCKED' }) 45 | } 46 | 47 | if (endpoint.config.method === 'POST') { 48 | args = req.body 49 | } else { 50 | args = req.query 51 | } 52 | 53 | if (endpoint.config.private) { 54 | try { 55 | await this.isLoggedIn(req.headers.authorization) 56 | } catch (err) { 57 | return res.status(403).send() 58 | } 59 | } 60 | 61 | this.gClient.send(endpoint.config.svc, [args, { 62 | endpoint, 63 | user_agent: req.headers["user-agent"] || "NA" 64 | }], (err, data) => { 65 | if (err) { 66 | console.log(err) 67 | return this._genericErr(res) 68 | } 69 | res.status(200).send(data) 70 | }) 71 | } 72 | 73 | _genericErr (res) { 74 | return res.status(500).send('Blocktank server error!') 75 | } 76 | 77 | start () { 78 | const list = this.endpoints 79 | 80 | Object.keys(list.endpoints).forEach((v, k) => { 81 | const api = { 82 | config: list.endpoints[v], 83 | url: v 84 | } 85 | this.app[api.config.method.toLowerCase()](API_VERSION + v, this.handleRequest.bind(this, api)) 86 | }) 87 | this.app.use((err, req, res, next) => { 88 | if (err && err.stack) { 89 | return this._genericErr(res) 90 | } 91 | next() 92 | }) 93 | this.app.listen(this.port, this.host, () => { 94 | console.log(`Express is listening at http://${this.host}:${this.port}`) 95 | }) 96 | } 97 | } 98 | 99 | module.exports = Server 100 | -------------------------------------------------------------------------------- /src/util/StatusFile.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const fs = require('fs/promises') 3 | const path = require('path') 4 | const debug = require('debug')('LH:StatusFile') 5 | 6 | class StatusFile { 7 | constructor (config) { 8 | this.config = config 9 | this.statusFile = path.join(__dirname, `../../status/${config.tag}.${config.postfix}.json`) 10 | this._data = {} 11 | } 12 | 13 | async loadFile (init) { 14 | let f 15 | try { 16 | f = JSON.parse(await fs.readFile(this.statusFile)) 17 | return f 18 | } catch (err) { 19 | debug(`Creating ${this.statusFile}`) 20 | await this.updateFile(init) 21 | } 22 | } 23 | 24 | updateFile (data) { 25 | this._data = data 26 | return fs.writeFile(this.statusFile, JSON.stringify(data)) 27 | } 28 | 29 | get data () { 30 | return this._data || {} 31 | } 32 | } 33 | module.exports = StatusFile 34 | -------------------------------------------------------------------------------- /src/util/channel-opening-errors.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const err = [ 4 | ['PEER_NOT_REACHABLE',{ 5 | giveup: false 6 | }], 7 | [ 'PEER_TOO_MANY_PENDING_CHANNELS',{ 8 | giveup: false 9 | }], 10 | [ 'PEER_REJECT_MULTI_CHAN',{ 11 | giveup: true 12 | }], 13 | [ 'CHAN_SIZE_TOO_BIG',{ 14 | giveup: true 15 | }], 16 | [ 'CHAN_SIZE_TOO_SMALL',{ 17 | giveup: true, 18 | alert: false 19 | }], 20 | [ 'BLOCKTANK_NOT_READY',{ 21 | giveup: false, 22 | alert: true 23 | }], 24 | [ 'SERVICE_FAILED_TO_OPEN_CHANNEL',{ 25 | giveup: true 26 | }], 27 | ["NO_TX_ID",{ 28 | giveup: true, 29 | alert: true 30 | }], 31 | ["FEE_TOO_HIGH", { 32 | giveup: false, 33 | alert: false 34 | }] 35 | ] 36 | 37 | class ChannelOpenError { 38 | constructor(name, config, raw){ 39 | this.giveup = config.giveup 40 | this.alert = config.alert || false 41 | this.name = name 42 | this.raw = this.parseRaw(raw) 43 | this.ts = Date.now() 44 | } 45 | 46 | parseRaw(raw){ 47 | return raw.message || raw 48 | } 49 | 50 | toString(){ 51 | return JSON.stringify({ 52 | error: this.name, 53 | channel_error: this.raw, 54 | giveup: this.giveup, 55 | ts: this.ts 56 | }) 57 | } 58 | } 59 | 60 | 61 | function parseChannelOpenErr (err) { 62 | 63 | const errMsg = (txt)=>{ 64 | if(Array.isArray(txt)){ 65 | return txt.filter((txt)=>{ 66 | return err.message.includes(txt) 67 | }).length > 0 68 | } 69 | return err.message.includes(txt) 70 | } 71 | 72 | if (errMsg(['RemotePeerDisconnected', 'PeerIsNotOnline', 'RemotePeerExited'])) { 73 | return errors.PEER_NOT_REACHABLE(err) 74 | } 75 | if (errMsg('PeerPendingChannelsExceedMaximumAllowable')) { 76 | return errors.PEER_TOO_MANY_PENDING_CHANNELS(err) 77 | } 78 | 79 | if (errMsg('FailedToOpenChannel')) { 80 | if (errMsg('exceeds maximum chan size')) { 81 | return errors.CHAN_SIZE_TOO_BIG(err) 82 | } 83 | if (errMsg('below min chan size')) { 84 | return errors.CHAN_SIZE_TOO_SMALL(err) 85 | } 86 | 87 | if(errMsg("No connection established")){ 88 | return errors.PEER_NOT_REACHABLE(err) 89 | } 90 | } 91 | if (errMsg(['InsufficientFundsToCreateChannel','WalletNotFullySynced'])) { 92 | return errors.BLOCKTANK_NOT_READY(err) 93 | } 94 | 95 | if(errMsg("RemoteNodeDoesNotSupportMultipleChannels")){ 96 | return errors.PEER_REJECT_MULTI_CHAN(err) 97 | } 98 | console.log("UNHANDLED_CHANNEL_OPEN_ERR") 99 | console.log(err.message ? err.message : err) 100 | 101 | return errors.SERVICE_FAILED_TO_OPEN_CHANNEL(err) 102 | } 103 | 104 | 105 | const errors = err.reduce((obj, [name,config])=>{ 106 | obj[name] = (raw) => new ChannelOpenError(name,config,raw) 107 | return obj 108 | },{}) 109 | 110 | module.exports = { 111 | parseChannelOpenErr, 112 | errors, 113 | } 114 | -------------------------------------------------------------------------------- /src/util/common-workers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { Client: GrenacheClient } = require('blocktank-worker') 3 | 4 | const gClient = new GrenacheClient() 5 | 6 | function lnWorker (method, args, cb) { 7 | return new Promise((resolve, reject) => { 8 | gClient.send('svc:ln', { 9 | method, 10 | args: Array.isArray(args) ? args : [args] 11 | }, (err, data) => { 12 | if (err) { 13 | return cb ? cb(err) : reject(err) 14 | } 15 | cb ? cb(null, data) : resolve(data) 16 | }) 17 | }) 18 | } 19 | 20 | function callWorker (svc, method, args, cb) { 21 | return new Promise((resolve, reject) => { 22 | gClient.send(svc, { 23 | method, 24 | args: Array.isArray(args) ? args : [args] 25 | }, (err, data) => { 26 | if (err) { 27 | return cb ? cb(err) : reject(err) 28 | } 29 | cb ? cb(null, data) : resolve(data) 30 | }) 31 | }) 32 | } 33 | 34 | module.exports = { 35 | lnWorker, 36 | callWorker 37 | } 38 | -------------------------------------------------------------------------------- /src/util/exchange-api.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { default: axios } = require('axios') 3 | const { default: BigNumber } = require('bignumber.js') 4 | const { toBtc,SATOSHI } = require('./sats-convert') 5 | 6 | async function callTicker (ticker) { 7 | try { 8 | const res = await axios.get('https://api-pub.bitfinex.com/v2/tickers?symbols=' + ticker) 9 | return res 10 | } catch (err) { 11 | console.log('Failed to get FRR') 12 | console.log(err) 13 | return null 14 | } 15 | } 16 | async function callCandles (ticker) { 17 | try { 18 | const res = await axios.get('https://api-pub.bitfinex.com/v2/candles/trade' + ticker) 19 | return res.data 20 | } catch (err) { 21 | console.log('Failed to get FRR') 22 | console.log(err) 23 | return null 24 | } 25 | } 26 | 27 | 28 | async function getRate (ticker) { 29 | const data = await callTicker(ticker) 30 | let res 31 | if (data.data && data.data[0]) { 32 | res = data.data[0] 33 | } 34 | return { 35 | price: res[7] || null 36 | } 37 | } 38 | 39 | const ExchangeRate = { 40 | satsToUSD: async (sats) => { 41 | if (sats === 0) return 0 42 | const btcUSD = await ExchangeRate.getBtcUsd() 43 | const btc = toBtc(sats) 44 | return BigNumber(btc).times(btcUSD.price).toNumber() 45 | }, 46 | 47 | usdToBtc: async (dollar) => { 48 | if (dollar === 0) return 0 49 | const btcUSD = await ExchangeRate.getBtcUsd() 50 | return BigNumber(dollar).dividedBy(btcUSD.price).dp(8, BigNumber.ROUND_FLOOR).toString() 51 | }, 52 | 53 | usdToSats: async (dollar) => { 54 | const toBtc = await ExchangeRate.usdToBtc(dollar) 55 | return BigNumber(toBtc).times(SATOSHI).toString() 56 | }, 57 | 58 | getBtcUsd: () => { 59 | return getRate('tBTCUSD') 60 | }, 61 | 62 | async getRatesRaw (tickers) { 63 | const res = await callTicker(tickers) 64 | return res.data 65 | }, 66 | 67 | historicalBtcUsd: async (date) =>{ 68 | const res = await callCandles(`:1D:tBTCUSD/last?start=${date}`) 69 | return { 70 | price: res? res[2] : null 71 | } 72 | } 73 | } 74 | module.exports = ExchangeRate 75 | -------------------------------------------------------------------------------- /src/util/lnurl.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { bech32 } = require('bech32') 3 | 4 | const limit = 1023 5 | 6 | function encode (str) { 7 | const words = bech32.toWords(Buffer.from(str, 'utf8')) 8 | return bech32.encode('lnurl', words, 1023) 9 | } 10 | 11 | function decode (lnurl) { 12 | const { words } = bech32.decode(lnurl, { limit }) 13 | return Buffer.from(bech32.fromWords(words), 'utf8').toString() 14 | } 15 | 16 | const isPublicKey = n => !!n && /^0[2-3][0-9A-F]{64}$/i.test(n) 17 | 18 | function parseUri (uri) { 19 | const res = { 20 | err: false, 21 | port: null, 22 | ip: null, 23 | addr: null, 24 | public_key: null 25 | } 26 | uri = uri.split('@') 27 | const isValidKey = isPublicKey(uri[0]) 28 | if (!isValidKey) { 29 | res.err = 'NOT_VALID_KEY' 30 | return res 31 | } 32 | res.public_key = uri[0] 33 | 34 | if (uri.length === 2) { 35 | res.addr = uri[1] 36 | const parsed = uri[1].split(':') 37 | res.ip = parsed[0] 38 | res.port = parsed[1] 39 | } 40 | return res 41 | } 42 | 43 | module.exports = { 44 | parseUri, 45 | encode, 46 | decode 47 | } 48 | -------------------------------------------------------------------------------- /src/util/pricing.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { default: axios } = require('axios') 3 | const BN = require('bignumber.js') 4 | const { get } = require('lodash') 5 | const config = require('../../config/server.json') 6 | const { toBtc, toSatoshi } = require('./sats-convert') 7 | 8 | async function getFRR () { 9 | try { 10 | const res = await axios.get('https://api-pub.bitfinex.com/v2/tickers?symbols=fBTC') 11 | return get(res, 'data[0][1]', null) 12 | } catch (err) { 13 | console.log('Failed to get FRR') 14 | console.log(err) 15 | return null 16 | } 17 | } 18 | 19 | const DUST_LIMIT = BN(546) 20 | const MIN_PRICE = DUST_LIMIT.times(2) 21 | const TX_FEE = config.constants.buy_chan_tx_fee 22 | async function getChannelFee ({ channel_expiry: expiry, local_balance: localBalance }) { 23 | 24 | if (config.constants.free_channels) return 0 25 | 26 | const amount = toBtc(localBalance) 27 | const _FRR = await getFRR() 28 | if (!_FRR) return null 29 | const FRR = BN(_FRR) 30 | // Price = Loan amount x Rate X Duration 31 | // Using: https://support.bitfinex.com/hc/en-us/articles/115004554309-Margin-Funding-interest-on-Bitfinex 32 | const t = BN(expiry).times(604800) 33 | const price = BN(amount).times((FRR)*(t/86400)) 34 | // const price = BN(amount).times(FRR).times(12) 35 | if (price.isNaN() || price.lte(0)) { 36 | throw new Error('Failed to create channel fee') 37 | } 38 | const priceSats = BN(toSatoshi(price)) 39 | if (priceSats.lte(MIN_PRICE)) { 40 | return MIN_PRICE.toString() 41 | } 42 | 43 | return priceSats.toFixed(0) 44 | } 45 | 46 | /** 47 | * @desc Calculate price of a channel 48 | * @param {Object} args 49 | * @param {Number} args.channel_expiry Channel expiry in weeks 50 | * @param {Number} args.local_balance The balance on Blokctank's side in SATOSHI 51 | * @param {Number} args.remote_balance The balance on custuomer's side in SATOSHI 52 | * @returns {Number} price in satoshi. 53 | * @returns {Number} totalAmount in satoshi. The amount the customer must payu 54 | */ 55 | async function getChannelPrice (args) { 56 | const price = await getChannelFee(args) 57 | if (!price) throw new Error('Failed to get price') 58 | console.log(TX_FEE) 59 | const totalAmount = BN(args.remote_balance).plus(price).plus(TX_FEE) 60 | if (totalAmount.isNaN() || totalAmount.lte(0)) throw new Error('Created invalid price') 61 | return { 62 | price, 63 | totalAmount: totalAmount.toFixed(0) 64 | } 65 | } 66 | 67 | module.exports = { 68 | getChannelFee, 69 | getChannelPrice, 70 | MIN_PRICE 71 | } 72 | -------------------------------------------------------------------------------- /src/util/sats-convert.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { BigNumber } = require('bignumber.js') 4 | 5 | module.exports = { 6 | toSatoshi: (amt) => { 7 | return new BigNumber(amt).abs().times(100000000).dp(8, BigNumber.ROUND_FLOOR).toString() 8 | }, 9 | toBtc: (amt) => { 10 | return new BigNumber(amt).abs().div(100000000).dp(8, BigNumber.ROUND_FLOOR).toString() 11 | }, 12 | SATOSHI: 100000000 13 | } 14 | -------------------------------------------------------------------------------- /swagger.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | description: "Blocktank is a Lightning Network service provider (LSP)" 4 | version: "1.0.0" 5 | title: "Blocktank" 6 | # termsOfService: "" 7 | # contact: 8 | # email: "" 9 | host: "blocktank.synonym.to" 10 | basePath: "/api/v1" 11 | tags: 12 | - name: "Channels" 13 | description: "All endpoints related to buying lightning channels" 14 | - name: LNURL 15 | description: "LNURL endpoitns" 16 | schemes: 17 | - "https" 18 | x-readme: 19 | explorer-enabled: true 20 | samples-enabled: true 21 | samples-languages: 22 | - curl 23 | paths: 24 | /node/info: 25 | get: 26 | tags: 27 | - "Channels" 28 | summary: "Service Info" 29 | description: "Returns information about Blocktank Lightning node and services on offer." 30 | operationId: "nodeInfo" 31 | consumes: 32 | - "application/json" 33 | produces: 34 | - "application/json" 35 | responses: 36 | "200": 37 | description: "Node and service info" 38 | /channel/buy: 39 | post: 40 | tags: 41 | - "Channels" 42 | summary: "Request a channel to purchase." 43 | description: "Request a channel to purchase." 44 | operationId: "buyChannel" 45 | produces: 46 | - "application/json" 47 | parameters: 48 | - in: body 49 | name: Channel request 50 | description: Channel to purchase. 51 | schema: 52 | type: object 53 | required: 54 | - product_id 55 | - remote_balance 56 | - local_balance 57 | - channel_expiry 58 | properties: 59 | product_id: 60 | type: string 61 | remote_balance: 62 | type: number 63 | default: 1000000 64 | local_balance: 65 | type: number 66 | default: 2000000 67 | channel_expiry: 68 | type: number 69 | default: 1 70 | responses: 71 | "200": 72 | description: "Channel quote" 73 | schema: 74 | $ref: "#/definitions/ChannelQuote" 75 | /channel/manual_finalise: 76 | post: 77 | tags: 78 | - "Channels" 79 | summary: "Finalise a purchased channel" 80 | description: "Set the node that Blocktank will open a channel to after paying for your channel." 81 | operationId: "finaliseChannel" 82 | produces: 83 | - "application/json" 84 | parameters: 85 | - in: body 86 | name: Channel request 87 | description: Channel to purchase. 88 | schema: 89 | type: object 90 | required: 91 | - product_id 92 | - order_id 93 | - node_uri 94 | - private 95 | properties: 96 | product_id: 97 | type: string 98 | order_id: 99 | type: string 100 | node_uri: 101 | type: string 102 | private: 103 | type: boolean 104 | responses: 105 | "200": 106 | description: "Channel claimed" 107 | /channel/order: 108 | get: 109 | tags: 110 | - "Channels" 111 | summary: "Get an order" 112 | description: "Get all information regarding a channel order" 113 | operationId: "getOrder" 114 | produces: 115 | - "application/json" 116 | parameters: 117 | - in: query 118 | type: string 119 | name: order_id 120 | description: Order id. 121 | responses: 122 | "200": 123 | description: "Channel quote" 124 | schema: 125 | $ref: "#/definitions/ChannelOrder" 126 | /lnurl/channel: 127 | get: 128 | tags: 129 | - LNURL 130 | summary: "LN URL connect to node" 131 | description: "LNURL Connect" 132 | operationId: "lnurlConnect" 133 | produces: 134 | - "application/json" 135 | parameters: 136 | - in: query 137 | type: string 138 | name: order_id 139 | description: Required for LNURL connect 140 | - in: query 141 | type: string 142 | name: k1 143 | description: Required for LNURL callback 144 | - in: query 145 | type: string 146 | name: remote_id 147 | description: Required for LNURL callback. Remote node address of form node_key@ip_address:port_number. IP address and port number is optional 148 | responses: 149 | "200": 150 | description: "LNURL connect " 151 | schema: 152 | $ref: "#/definitions/LNURLConnect" 153 | definitions: 154 | LNURLConnect: 155 | type: object 156 | properties: 157 | k1: 158 | type: string 159 | description: order id 160 | tag: 161 | type: string 162 | default: channelRequest 163 | callback: 164 | type: string 165 | description: A second-level URL which would initiate an OpenChannel message from target LN node 166 | uri: 167 | type: string 168 | description: Blocktank node info 169 | status: 170 | type: string 171 | description: Response status 172 | enum: ["OK", "ERROR"] 173 | reason: 174 | type: string 175 | description: Error reason 176 | 177 | ChannelQuote: 178 | type: "object" 179 | properties: 180 | order_id: 181 | type: string 182 | ln_invoice: 183 | type: string 184 | total_amount: 185 | type: integer 186 | btc_address: 187 | type: string 188 | lnurl_channel: 189 | type: string 190 | ChannelOrder: 191 | type: "object" 192 | properties: 193 | _id: 194 | description: Order id 195 | type: string 196 | local_balance: 197 | type: integer 198 | remote_balance: 199 | type: integer 200 | channel_expiry: 201 | type: integer 202 | description: Channel expiry is in weeks. 203 | channel_expiry_ts: 204 | type: integer 205 | description: Blocktank has the righ to close the channel after this time 206 | order_expiry: 207 | type: integer 208 | description: order is valid until this time 209 | total_amount: 210 | type: integer 211 | description: total amount payable by customer 212 | btc_address: 213 | type: string 214 | description: Destination address for on chain payments 215 | created_at: 216 | type: integer 217 | description: Time that the order was created 218 | amount_received: 219 | type: number 220 | description: how much satoshi orders has recieved 221 | remote_node: 222 | type: object 223 | properties: 224 | err: 225 | type: boolean 226 | port: 227 | type: number 228 | ip: 229 | type: string 230 | addr: 231 | type: string 232 | public_key: 233 | type: string 234 | channel_open_tx: 235 | type: object 236 | properties: 237 | transaction_id: 238 | type: string 239 | transaction_vout: 240 | type: string 241 | purchase_invoice: 242 | type: string 243 | lnurl: 244 | type: object 245 | description: LNUrl channel object 246 | properties: 247 | uri: 248 | type: string 249 | callback: 250 | type: string 251 | k1: 252 | type: string 253 | tag: 254 | default: "channelRequest" 255 | state: 256 | $ref: "#/definitions/OrderStates" 257 | onchain_payments: 258 | type: array 259 | items: 260 | type: object 261 | properties: 262 | height: 263 | type: integer 264 | hash: 265 | type: string 266 | to: 267 | type: string 268 | amount_base: 269 | type: integer 270 | zero_conf: 271 | type: boolean 272 | description: if payment was accepted as zero conf 273 | 274 | xml: 275 | name: "Category" 276 | OrderStates: 277 | type: "object" 278 | description: Order state can be one of the following 279 | properties: 280 | CREATED: 281 | type: number 282 | description: Order has been created 283 | default: 0 284 | PAID: 285 | type: number 286 | description: Order has been paid 287 | default: 100 288 | URI_SET: 289 | type: number 290 | description: Order has been paid and node uri is set 291 | default: 200 292 | OPENING: 293 | type: number 294 | description: Lightning channel is opening 295 | default: 300 296 | CLOSING: 297 | type: number 298 | description: Lightning channel is closing 299 | default: 350 300 | GIVE_UP: 301 | type: number 302 | description: Gave up opening channel 303 | default: 400 304 | CLOSED: 305 | type: number 306 | description: Lightning channel has been closed 307 | default: 450 308 | OPEN: 309 | type: number 310 | description: Lightning channel is open 311 | default: 500 -------------------------------------------------------------------------------- /test/Auth/Auth.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict' 3 | 4 | const Authenticator = require('attest-auth') 5 | const curve = require('noise-handshake/dh') 6 | const serverKeys = require('../../config/auth.json') 7 | const assert = require('assert') 8 | const Auth = require('../../src/Auth/Auth') 9 | 10 | const toBuf = (t) => Buffer.from(t, 'hex') 11 | 12 | describe('Auth', () => { 13 | let auth 14 | 15 | before(() => { 16 | auth = new Auth({ 17 | test_env: true 18 | }) 19 | }) 20 | 21 | let userKeys 22 | beforeEach(() => { 23 | userKeys = { 24 | publicKey: toBuf('0992a3b7b3a7a867210643ea4da9a6d1637a21b3133b39b7c197e88e855a807e'), 25 | secretKey: toBuf('851253d2d813a9e16f6085b0a90d3a419089707bbf9cebd28d334cce2a0e25ce') 26 | } 27 | }) 28 | 29 | it('Can fetch auth challenge', () => { 30 | auth.main({}, {}, (err, data) => { 31 | if (err) throw err 32 | assert.ok(data.challenge) 33 | }) 34 | }) 35 | 36 | it('login', () => { 37 | const metadata = Buffer.from('User meta data.') 38 | auth.main({}, {}, (err, data) => { 39 | if (err) throw err 40 | const trustedLogin = Authenticator.createClientLogin(userKeys, toBuf(serverKeys.server_public), toBuf(data.challenge), { curve, metadata }) 41 | trustedLogin.on('verify', function (info) { 42 | console.log(info.publicKey.slice(0, 8), 'Client logged in!', info) 43 | console.log(Buffer.from(info.metadata, 'base64').toString()) 44 | assert.strictEqual(info.publicKey, userKeys.publicKey.toString('hex'), 'User keys dont match') 45 | }) 46 | const loginRequest = Buffer.from(trustedLogin.request).toString('hex') 47 | auth.main({ 48 | metadata: metadata.toString('hex'), 49 | request: loginRequest 50 | }, {}, (err, data) => { 51 | if (err) throw err 52 | trustedLogin.verify(data.response) 53 | }) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/Channel/BuyChannel.e2e.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict' 3 | const { default: client } = require('@synonymdev/blocktank-client') 4 | const nodeman = require('blocktank-worker-ln') 5 | const { Bitcoin, Converter } = require('blocktank-worker-btc') 6 | const assert = require('assert') 7 | const { promisify } = require('util') 8 | 9 | const { 10 | config, btcConfig, lnConfig 11 | } = require('./test.config') 12 | 13 | function sleep (ms) { 14 | return new Promise(resolve => setTimeout(resolve, ms)) 15 | } 16 | 17 | let nodeInfo 18 | let serviceInfo 19 | let clientLN 20 | let btc 21 | 22 | async function setupBtc (cb) { 23 | console.log('Setting up Bitcoin client') 24 | btc = new Bitcoin(btcConfig) 25 | btc.getHeight({}, async (err, data) => { 26 | if (err) throw err 27 | if (!Number.isInteger(data)) throw new Error('Bitcoin worker not ready') 28 | btc.mineRegtestCoin = promisify(btc.mineRegtestCoin.bind(btc)) 29 | btc.sendToAddr = promisify(btc.sendToAddr.bind(btc)) 30 | // if (block.length !== 6) throw new Error('Blocks not mined') 31 | cb() 32 | }) 33 | } 34 | 35 | async function quickMine(){ 36 | console.log("Mining blocks") 37 | const block = await btc.mineRegtestCoin({ blocks: 3 }) 38 | await sleep(5000) 39 | console.log("Finished mining") 40 | } 41 | 42 | function setupClientLib (cb) { 43 | console.log('Setting up Blocktank lib') 44 | client.host = config.api_host 45 | client.getInfo().then((res) => { 46 | nodeInfo = res 47 | serviceInfo = res.services[0] 48 | cb() 49 | }).catch((err) => { 50 | console.log(err) 51 | throw err 52 | }) 53 | } 54 | 55 | function setupLN (cb) { 56 | console.log('Setting up LN client') 57 | clientLN = nodeman(lnConfig) 58 | clientLN.start((err) => { 59 | if (err) throw err 60 | nodeInfo = clientLN.nodes[0].info 61 | clientLN.pay = promisify(clientLN.pay.bind(clientLN, clientLN.getNode())) 62 | cb() 63 | }) 64 | } 65 | 66 | async function createOrder () { 67 | const orderParams = { 68 | product_id: serviceInfo.product_id, 69 | remote_balance: 0, 70 | local_balance: 2000000, 71 | channel_expiry: 4 72 | } 73 | 74 | console.log('Creating order...') 75 | const order = await client.buyChannel(orderParams) 76 | console.log('Created order') 77 | 78 | assert(order.btc_address) 79 | assert(order.order_id) 80 | assert(order.ln_invoice) 81 | assert(Number.isInteger(order.total_amount)) 82 | assert(Number.isInteger(order.price)) 83 | assert(Number.isInteger(new Date(order.order_expiry).getTime())) 84 | return { 85 | order, orderParams 86 | } 87 | } 88 | 89 | function validatePaidOrder (paidOrder, orderParams) { 90 | assert(paidOrder._id) 91 | assert(paidOrder.state === 100) 92 | assert(paidOrder.remote_balance === orderParams.remote_balance) 93 | assert(paidOrder.local_balance === orderParams.local_balance) 94 | console.log('Order is ok.') 95 | } 96 | 97 | function validateFinalisedChannel (claim, paidOrder) { 98 | assert(claim.order_id === paidOrder._id) 99 | assert(claim.node_uri === nodeInfo.pubkey) 100 | } 101 | 102 | function payOnChain(order, testConf){ 103 | return btc.sendToAddr({ 104 | address: order.btc_address, 105 | tag: 'End to end testing', 106 | amount: Converter.toBtc(order.total_amount), 107 | replaceable: !testConf.zero_conf 108 | }) 109 | } 110 | 111 | async function testOnChain (testConf) { 112 | const { orderParams, order } = await createOrder() 113 | 114 | console.log('Paying order via on on chain...') 115 | await sleep(2000) 116 | const pay = await payOnChain(order, testConf) 117 | console.log('Payed order: ', pay.txid) 118 | 119 | if (!testConf.zero_conf) { 120 | await btc.mineRegtestCoin({ blocks: 3 }) 121 | await sleep(5000) 122 | } 123 | console.log('Fetching order...') 124 | assert(pay.txid) 125 | let paidOrder 126 | for (let x = 0; x <= 50; x++) { 127 | await sleep(5000) 128 | paidOrder = await client.getOrder(order.order_id) 129 | 130 | if (x === 50) throw new Error('Zero conf not detected') 131 | if (paidOrder.state !== 100) { 132 | console.log('Waiting.. state: ', paidOrder.state) 133 | continue 134 | } 135 | validatePaidOrder(paidOrder, orderParams) 136 | break 137 | } 138 | console.log('Claiming order...') 139 | const claim = await client.finalizeChannel({ 140 | order_id: paidOrder._id, 141 | node_uri: nodeInfo.uris[0], 142 | private: false 143 | }) 144 | validateFinalisedChannel(claim, paidOrder) 145 | 146 | console.log('Claimed order') 147 | console.log('Checking order status...') 148 | for (let x = 0; x <= 50; x++) { 149 | if (x === 50) throw new Error('Failed to claim channel') 150 | await sleep(1000) 151 | paidOrder = await client.getOrder(order.order_id) 152 | if (paidOrder.state === 200 && paidOrder.remote_node.public_key === nodeInfo.pubkey) break 153 | } 154 | 155 | let orderClaimed = false 156 | let channelOpen = false 157 | for (let x = 0; x <= 50; x++) { 158 | console.log('Checking...') 159 | await sleep(5000) 160 | paidOrder = await client.getOrder(order.order_id) 161 | if (paidOrder.state === 300) { 162 | console.log('Order status is claimed. Mining blocks') 163 | orderClaimed = true 164 | await btc.mineRegtestCoin({ blocks: 6 }) 165 | continue 166 | } 167 | 168 | 169 | 170 | if (orderClaimed && paidOrder.state === 500) { 171 | console.log('Order status: Channel is now open') 172 | checkOnChainPayConfirmation(paidOrder,orderParams, testConf.zero_conf) 173 | channelOpen = true 174 | break 175 | } 176 | } 177 | if (!orderClaimed || !channelOpen) throw new Error('Order failed to be claimed or channel did not open') 178 | } 179 | 180 | function checkOnChainPayConfirmation(paidOrder, orderParams, isZeroConf){ 181 | const onchain = paidOrder.onchain_payments.forEach((p)=>{ 182 | if(p.total_amount !== paidOrder.amount_base) throw new Error("payment amounts dont match") 183 | if(isZeroConf && p.height) throw new Error("height must be null for zero conf") 184 | if(!isZeroConf && !p.height) throw new Error("height must be set for non zero conf") 185 | if(!p.hash) throw new Error("payment hash not set") 186 | if(isZeroConf && !p.zero_conf) throw new Error("Zero conf not set") 187 | if(p.from.length === 0) throw new Error("Sender address not set") 188 | if(p.fee_base <= 0) throw new Error("Fee is invalid") 189 | }) 190 | } 191 | 192 | describe('End to end test', async function () { 193 | before(function (done) { 194 | this.timeout(100000) 195 | console.log('Setting up libs') 196 | setupClientLib(() => { 197 | setupLN(() => { 198 | setupBtc(done) 199 | }) 200 | }) 201 | }) 202 | 203 | describe('on chain payments', async function () { 204 | 205 | it("On chain zero conf payment array", async function () { 206 | this.timeout(10000) 207 | const { orderParams, order } = await createOrder() 208 | const testConf = {zero_conf: true} 209 | await payOnChain(order, testConf) 210 | await sleep(5000) 211 | let paidOrder = await client.getOrder(order.order_id) 212 | checkOnChainPayConfirmation(paidOrder,orderParams,testConf) 213 | }) 214 | 215 | }) 216 | 217 | it('should create an order for a channel, pay via LN and claim channel', async function () { 218 | await quickMine() 219 | const { orderParams, order } = await createOrder() 220 | 221 | console.log('Paying order via LN...') 222 | await sleep(2000) 223 | const pay = await clientLN.pay({ invoice: order.ln_invoice }) 224 | console.log('Paid order') 225 | 226 | console.log('Fetching order...') 227 | await sleep(10000) 228 | assert(pay.is_confirmed) 229 | let paidOrder = await client.getOrder(order.order_id) 230 | validatePaidOrder(paidOrder, orderParams) 231 | assert(paidOrder.state === 100) 232 | 233 | console.log('Claiming order...') 234 | const claim = await client.finalizeChannel({ 235 | order_id: paidOrder._id, 236 | node_uri: nodeInfo.uris[0], 237 | private: false 238 | }) 239 | validateFinalisedChannel(claim, paidOrder) 240 | assert(paidOrder.onchain_payments.length === 0) 241 | console.log('Claimed order') 242 | console.log('Checking order status...') 243 | await sleep(5000) 244 | paidOrder = await client.getOrder(order.order_id) 245 | assert(paidOrder.state === 300) 246 | assert(paidOrder.remote_node.public_key === nodeInfo.pubkey) 247 | 248 | let orderClaimed = false 249 | let channelOpen = false 250 | for (let x = 0; x <= 50; x++) { 251 | console.log('Checking...') 252 | await sleep(5000) 253 | paidOrder = await client.getOrder(order.order_id) 254 | 255 | if (!orderClaimed && paidOrder.state === 300) { 256 | console.log('Order status claimed') 257 | orderClaimed = true 258 | await btc.mineRegtestCoin({ blocks: 6 }) 259 | continue 260 | } 261 | 262 | if (orderClaimed && paidOrder.state === 500) { 263 | console.log('Order status: Channel is now open') 264 | channelOpen = true 265 | break 266 | } 267 | } 268 | if (!orderClaimed || !channelOpen) throw new Error('Order failed to be claimed or channel did not open') 269 | }).timeout(100000) 270 | 271 | it('Should create an order and pay with zero conf payment, claim channel', async () => { 272 | await testOnChain({ 273 | zero_conf: true 274 | }) 275 | }).timeout(100000) 276 | 277 | it('Should create an order and pay with on chain payment , claim channel', async () => { 278 | await testOnChain({ 279 | zero_conf: false 280 | }) 281 | }).timeout(100000) 282 | }) 283 | -------------------------------------------------------------------------------- /test/Channel/BuyChannel.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict' 3 | const assert = require('assert') 4 | const BuyChannel = require('../../src/Channel/BuyChannel') 5 | const { constants } = require('../../config/server.json') 6 | 7 | describe('BuyChannel', () => { 8 | let buyChannel 9 | 10 | before(function () { 11 | this.timeout(10000) 12 | buyChannel = new BuyChannel({ 13 | test_env: false, 14 | port: (Math.random() * 10000).toFixed(0) 15 | }) 16 | }) 17 | 18 | describe('Validate order', () => { 19 | let order 20 | beforeEach(() => { 21 | order = { 22 | channel_expiry: 1, 23 | local_balance: 100000000, 24 | remote_balance: 10000000 25 | } 26 | }) 27 | 28 | it('accept valid order', () => { 29 | const res = buyChannel._validateOrder(order) 30 | assert.ok(!res) 31 | }) 32 | 33 | it('throw error for invalid channel expiry', () => { 34 | order.channel_expiry = 1.1 35 | const res = buyChannel._validateOrder(order) 36 | assert.ok(res) 37 | }) 38 | 39 | it('throw error for local float channel amounts', () => { 40 | order.local_balance = order.local_balance + 0.1 41 | const res = buyChannel._validateOrder(order) 42 | assert.ok(res) 43 | }) 44 | 45 | it('throw error for remote float channel amounts', () => { 46 | order.remote_balance = order.remote_balance + 0.1 47 | const res = buyChannel._validateOrder(order) 48 | assert.ok(res) 49 | }) 50 | 51 | it('throw error for max remote channel size', () => { 52 | order.local_balance = constants.max_channel_size + 2 53 | order.remote_balance = constants.max_channel_size + 1 54 | const res = buyChannel._validateOrder(order) 55 | assert.ok(res) 56 | }) 57 | }) 58 | 59 | describe('Buy Channel', () => { 60 | const orders = [ 61 | { 62 | txt: 'It should fail if product id is incorrect', 63 | tErr: 'Failed to find product', 64 | args: { 65 | product_id: 'aaaa', 66 | local_balance: 100000, 67 | remote_balance: 100000, 68 | channel_expiry: 4 69 | } 70 | }, 71 | { 72 | txt: 'It should create an order', 73 | tData: (data) => { 74 | assert.ok(data.price > 0) 75 | assert.ok(data.total_amount > 0) 76 | assert.ok(data.order_expiry > 0) 77 | assert.ok(data.btc_address) 78 | assert.ok(data.order_id) 79 | assert.ok(data.ln_invoice) 80 | assert.ok(data.lnurl_channel) 81 | }, 82 | args: { 83 | product_id: constants.product_id, 84 | local_balance: 1000000, 85 | remote_balance: 0, 86 | channel_expiry: 4 87 | } 88 | } 89 | ] 90 | 91 | orders.forEach(({ txt, tErr, tData, args }) => { 92 | it(txt, function (done) { 93 | buyChannel.main(args, {}, (err, data) => { 94 | if (err) return done(err) 95 | if (tErr) { 96 | assert.equal(data.error, tErr) 97 | return done() 98 | } 99 | if (tData) { 100 | tData(data) 101 | return done() 102 | } 103 | done(new Error('Failed test')) 104 | }) 105 | }).timeout(3000) 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /test/Channel/Price.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict' 3 | const assert = require('assert') 4 | const { 5 | getChannelFee, 6 | getChannelPrice, 7 | MIN_PRICE 8 | }= require('../../src/util/pricing') 9 | const { BigNumber } = require('bignumber.js') 10 | const nock = require('nock') 11 | 12 | describe('Pricing', () => { 13 | let buyChannel 14 | 15 | describe('Validate Price', () => { 16 | 17 | it('Should return numbers', async() => { 18 | const p = await getChannelPrice({ 19 | remote_balance: 0, 20 | local_balance: 1, 21 | channel_expiry:12, 22 | }) 23 | assert(p.price === MIN_PRICE.toString() ) 24 | assert(p.totalAmount === MIN_PRICE.toString()) 25 | }) 26 | it('Should return the correct variables', async() => { 27 | 28 | const scope = nock('https://api-pub.bitfinex.com') 29 | .get('/v2/tickers?symbols=fBTC') 30 | .reply(200, [["fBTC",0.00010332054794520548,0.1,120,42.2102839,0.00000371,2,11.833589810000001,0.00000758,0.0758,0.00001,6259.34687856,0.00026,1e-8,null,null,2735.90018801]]) 31 | 32 | const p = await getChannelPrice({ 33 | remote_balance: 0, 34 | local_balance: 500000000, 35 | channel_expiry:12, 36 | }) 37 | assert(+p.price === 4339463) 38 | assert(+p.totalAmount === 4339463) 39 | }) 40 | it('Should have higher totalAmount when channel has remote balance', async() => { 41 | 42 | const scope = nock('https://api-pub.bitfinex.com') 43 | .get('/v2/tickers?symbols=fBTC') 44 | .reply(200, [["fBTC",0.00010332054794520548,0.1,120,42.2102839,0.00000371,2,11.833589810000001,0.00000758,0.0758,0.00001,6259.34687856,0.00026,1e-8,null,null,2735.90018801]]) 45 | 46 | const remoteBal = 100000000 47 | const p = await getChannelPrice({ 48 | remote_balance: remoteBal, 49 | local_balance: 500000000, 50 | channel_expiry:12, 51 | }) 52 | assert(+p.price === 4339463) 53 | assert(+p.totalAmount === 104339463) 54 | }) 55 | after(()=>{ 56 | nock.cleanAll() 57 | }) 58 | }) 59 | 60 | }) 61 | -------------------------------------------------------------------------------- /test/Channel/PromoChannels.test.js: -------------------------------------------------------------------------------- 1 | const { default: client } = require('@synonymdev/blocktank-client') 2 | const assert = require('assert') 3 | const { promisify } = require('util') 4 | const PromoChannels = require('../../src/Channel/PromoChannels') 5 | 6 | 7 | const getPromoChan = ()=>{ 8 | return new PromoChannels({}) 9 | } 10 | 11 | 12 | let promoChan 13 | describe("Promo Channels",()=>{ 14 | 15 | beforeEach( async ()=>{ 16 | promoChan = getPromoChan() 17 | }) 18 | 19 | afterEach( async ()=>{ 20 | await promoChan.stopWorker() 21 | }) 22 | describe('Stats', () => { 23 | it("should get stats for inventory", async ()=>{ 24 | const stats = (await promisify(promoChan.getStats.bind(promoChan))())[0] 25 | const keys = [ 26 | 'sold_count_tick', 27 | 'capacity_sold_tick', 28 | 'capacity_available_tick', 29 | 'capacity_total', 30 | 'capacity_tick' 31 | ] 32 | assert(stats._id) 33 | assert(stats.state > 0) 34 | keys.forEach((k)=>{ 35 | assert(typeof stats.stats[k] === "number") 36 | }) 37 | }) 38 | it("checkCapacity should return true when we have capacity", async ()=>{ 39 | promoChan.getStats = (cb)=>{ 40 | cb(null,{ 41 | product_meta: { 42 | chan_size: 1000 43 | }, 44 | stats : { 45 | capacity_available_tick : 1000000000 46 | } 47 | }) 48 | } 49 | const cap = await promoChan.checkCapcity() 50 | assert(cap === true) 51 | }) 52 | it("checkCapacity should return false when we have capacity", async ()=>{ 53 | promoChan.getStats = (cb)=>{ 54 | cb(null,{ 55 | product_meta: { 56 | chan_size: 1000 57 | }, 58 | stats : { 59 | capacity_available_tick : 1 60 | } 61 | }) 62 | } 63 | const cap = await promoChan.checkCapcity() 64 | assert(cap === false) 65 | }) 66 | }) 67 | 68 | describe("process orders",()=>{ 69 | it("Should do nothing when there is no orders to process", async ()=>{ 70 | promoChan._getOrders = (q,cb)=>{ 71 | cb(null,[{ 72 | _id:"test", 73 | state:0 74 | }]) 75 | } 76 | const cap = await promisify(promoChan.processOrders.bind(promoChan))() 77 | assert(cap.orders_processed === 0) 78 | }) 79 | it("Should give up on order when no capacity", async ()=>{ 80 | promoChan.getStats = (cb)=>{ 81 | cb(null,{ 82 | product_meta: { 83 | chan_size: 1000 84 | }, 85 | stats : { 86 | capacity_available_tick : 1 87 | } 88 | }) 89 | } 90 | promoChan._getOrders = (q,cb)=>{ 91 | cb(null,[]) 92 | } 93 | const cap = await promisify(promoChan.processOrders.bind(promoChan))() 94 | assert(cap.orders_processed === 0) 95 | }) 96 | }) 97 | }) -------------------------------------------------------------------------------- /test/Channel/test.config.js.example: -------------------------------------------------------------------------------- 1 | const config = { 2 | api_host: 'http://localhost:4000//' 3 | } 4 | 5 | const btcConfig = { 6 | bitcoin_node: { 7 | username: 'polaruser', 8 | password: 'polarpass', 9 | url: 'http://127.0.0.1:18444' 10 | }, 11 | db_url: 'mongodb://localhost:27017' 12 | } 13 | 14 | const lnConfig = { 15 | ln_nodes: [{ 16 | cert: 'tls.cert', 17 | macaroon: '.macaroon', 18 | socket: '127.0.0.1:10003', 19 | node_type: 'LND', 20 | node_name: 'lnd' 21 | }], 22 | events: { 23 | htlc_forward_event: [], 24 | channel_acceptor: [], 25 | peer_events: [] 26 | } 27 | } 28 | 29 | module.exports = { 30 | config, btcConfig, lnConfig 31 | } 32 | --------------------------------------------------------------------------------