From 488e29c5a2605fa360af9c7687098169e0604674 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 30 Apr 2019 23:27:47 -0300 Subject: [PATCH] new interfaces thing: lightningd, eclair, ptarmigan. --- src/background.js | 12 +-- src/components/Home.js | 79 ++++----------- src/components/Invoice.js | 12 +-- src/components/Payment.js | 16 +-- src/content.js | 3 +- src/interfaces/eclair.js | 154 ++++++++++++++++++++++++++++ src/interfaces/index.js | 21 ++++ src/interfaces/lightningd_spark.js | 157 +++++++++++++++++++++++++++++ src/interfaces/ptarmigan.js | 83 +++++++++++++++ src/predefined-behaviors.js | 21 ++-- src/utils.js | 27 +---- 11 files changed, 465 insertions(+), 120 deletions(-) create mode 100644 src/interfaces/eclair.js create mode 100644 src/interfaces/index.js create mode 100644 src/interfaces/lightningd_spark.js create mode 100644 src/interfaces/ptarmigan.js diff --git a/src/background.js b/src/background.js index b2dffd9..4a10e4d 100644 --- a/src/background.js +++ b/src/background.js @@ -9,9 +9,10 @@ import { MENUITEM_BLOCK, MENUITEM_GENERATE } from './constants' -import {rpcCall, sprint, msatsFormat, notify} from './utils' +import {sprint, msatsFormat, notify} from './utils' import {getBehavior} from './predefined-behaviors' import * as current from './current-action' +import handleRPC from './interfaces' // logger service browser.runtime.onMessage.addListener((message, sender) => { @@ -76,16 +77,11 @@ browser.runtime.onMessage.addListener(({setAction, tab}, sender) => { // do an rpc call on behalf of anyone who wants that -- normally the popup browser.runtime.onMessage.addListener( - ({rpc, method, params, behaviors = {}, extra = {}, tab}, sender) => { + ({rpc, behaviors = {}, extra = {}, tab}, sender) => { if (!rpc) return tab = sender.tab || tab - let resPromise = rpcCall(method, params).then(res => { - if (res.code) { - throw new Error(res.message || res.code) - } - return res - }) + let resPromise = handleRPC(rpc) resPromise.then(res => { ;(behaviors.success || []) diff --git a/src/components/Home.js b/src/components/Home.js index 3d03ecf..ff77467 100644 --- a/src/components/Home.js +++ b/src/components/Home.js @@ -10,61 +10,14 @@ import {msatsFormat} from '../utils' export default function Home() { let {tab} = useContext(CurrentContext) - let [invoices, setInvoices] = useState([]) - let [payments, setPayments] = useState([]) - let [nodeInfo, setNodeInfo] = useState({}) - let [balance, setBalance] = useState(0) + let [summary, setSummary] = useState({}) let [blocked, setBlocked] = useState({}) useEffect(() => { - browser.runtime - .sendMessage({tab, rpc: true, method: 'getinfo'}) - .then(({blockheight, id, alias, color, address}) => { - address = - address.length === 0 - ? null - : `${address[0].address}:${address[0].port}` - setNodeInfo({blockheight, id, alias, color, address}) - }) - browser.runtime - .sendMessage({tab, rpc: true, method: 'listfunds'}) - .then(({channels}) => { - let balance = channels.reduce((acc, ch) => acc + ch.channel_sat, 0) - setBalance(balance) - }) - browser.runtime - .sendMessage({tab, rpc: true, method: 'listinvoices'}) - .then(resp => { - setInvoices(resp.invoices.filter(inv => inv.status === 'paid')) - }) - browser.runtime - .sendMessage({tab, rpc: true, method: 'listpayments'}) - .then(resp => { - setPayments(resp.payments.filter(pay => pay.status === 'complete')) - }) + browser.runtime.sendMessage({tab, rpc: {summary: []}}).then(setSummary) browser.runtime.sendMessage({tab, getBlocked: true}).then(setBlocked) }, []) - let transactions = invoices - .map(({paid_at, expires_at, msatoshi, description = ''}) => ({ - date: paid_at || expires_at, - amount: msatoshi, - description - })) - .slice(-15) - .concat( - payments - .map(({created_at, msatoshi, description, payment_preimage}) => ({ - date: created_at, - amount: -msatoshi, - description: description || payment_preimage - })) - .slice(-15) - ) - .sort((a, b) => a.date - b.date) - .slice(-15) - .reverse() - function unblock(e) { e.preventDefault() let domain = e.target.dataset.domain @@ -79,12 +32,12 @@ export default function Home() { return (

Balance

-
{balance} satoshis
+
{summary.balance} satoshi

Latest transactions

- {transactions.map((tx, i) => ( + {summary.transactions.map((tx, i) => (
{formatDate(tx.date)}

Node

-
+
- {['alias', 'id', 'address', 'blockheight'].map(attr => ( - - - - - ))} + {['alias', 'id', 'address', 'blockheight'] + .map(attr => [attr, summary.info[attr]]) + .filter(([_, v]) => v) + .map(([attr, val]) => ( + + + + + ))}
{attr} - {nodeInfo[attr]} -
{attr} + {summary.info[attr]} +
diff --git a/src/components/Invoice.js b/src/components/Invoice.js index 714e061..0173564 100644 --- a/src/components/Invoice.js +++ b/src/components/Invoice.js @@ -45,26 +45,20 @@ export default function Invoice() { function makeInvoice(e) { e.preventDefault() - let label = `KwH.${cuid.slug()}` - browser.runtime .sendMessage({ tab, - rpc: true, - method: 'invoice', - params: [satoshis * 1000, label, desc.replace(/ /g, '').trim()], + rpc: { + makeInvoice: [satoshis * 1000, desc.replace(/ /g, '').trim()] + }, behaviors: { success: [ 'paste-invoice', 'return-invoice', - 'wait-for-invoice', 'cleanup-browser-action', 'save-invoice-to-current-action' ], failure: ['notify-invoice-error', 'cleanup-browser-action'] - }, - extra: { - newInvoiceLabel: label } }) .then(({bolt11}) => { diff --git a/src/components/Payment.js b/src/components/Payment.js index 5ca9689..49b13c1 100644 --- a/src/components/Payment.js +++ b/src/components/Payment.js @@ -23,7 +23,7 @@ export default function Payment() { if (bolt11 === '' || doneTyping === false || paymentPending) return browser.runtime - .sendMessage({tab, rpc: true, method: 'decodepay', params: [bolt11]}) + .sendMessage({tab, rpc: {decode: [bolt11]}}) .then(data => { setInvoiceData(data) }) @@ -56,12 +56,12 @@ export default function Payment() { browser.runtime .sendMessage({ tab, - rpc: true, - method: 'pay', - params: { - bolt11, - msatoshi: satoshiActual ? satoshiActual * 1000 : undefined, - label: invoiceData.description + rpc: { + pay: [ + bolt11, + satoshiActual ? satoshiActual * 1000 : undefined, + invoiceData.description + ] }, behaviors: { success: [ @@ -152,7 +152,7 @@ export default function Payment() { )}{' '} to{' '} - {invoiceData.payee.slice(0, 4)}…{invoiceData.payee.slice(-4)} + {invoiceData.nodeid.slice(0, 4)}…{invoiceData.nodeid.slice(-4)} {invoiceData.description ? ( <> diff --git a/src/content.js b/src/content.js index 6276c12..c0a3f9a 100644 --- a/src/content.js +++ b/src/content.js @@ -130,8 +130,7 @@ if (document) { switch (type) { case REQUEST_GETINFO: return browser.runtime.sendMessage({ - rpc: true, - method: 'getinfo' + rpc: {getInfo: []} }) default: return null diff --git a/src/interfaces/eclair.js b/src/interfaces/eclair.js new file mode 100644 index 0000000..f4092a6 --- /dev/null +++ b/src/interfaces/eclair.js @@ -0,0 +1,154 @@ +/** @format */ + +export function summary() { + return Promise.all([ + rpcCall('getinfo').then( + ({nodeId, alias, blockHeight, publicAddresses}) => ({ + blockheight: blockheight, + id: nodeId, + alias, + address: publicAddresses.length === 0 ? null : publicAddresses[0] + }) + ), + rpcCall('listinvoices'), + rpcCall('listpendinginvoices'), + rpcCall('listpayments'), + rpcCall('channels').then( + channels => + channels.reduce( + (acc, ch) => acc + ch.data.commitments.localCommit.spec.toLocalMsat, + 0 + ) / 1000 + ) + ]).then(([info, pendingInvoices, allInvoices, balance]) => { + let received = allInvoices + .filter(inv => { + for (let i = 0; i < pendingInvoices.length; i++) { + let pinv = pendingInvoices[i] + if (inv.paymentHash === pinv.paymentHash) { + return false + } + } + return true + }) + .slice(0, 15) + + return { + info, + balance, + transactions: received.map(({timestamp, amount, description}) => ({ + date: timestamp, + amount, + description + })) + } + }) +} + +export function pay(bolt11, msatoshi = undefined, description = undefined) { + return rpcCall('payinvoice', {invoice: bolt11, amountMsat: msatoshi}).then( + callId => { + return new Promise(resolve => { + eventCallbacks[callId] = ({amount, feesPaid, paymentPreimage}) => + resolve({ + msatoshi_paid: amount, + msatoshi_fees: feesPaid, + preimage: paymentPreimage + }) + }) + } + ) +} + +export function decode(bolt11) { + return rpcCall('parseinvoice', {}).then( + ({description, amount, nodeId, paymentHash, expiry, timestamp}) => ({ + description, + msatoshi: amount, + nodeid: nodeId, + hash: paymentHash, + creation: timestamp, + expiry + }) + ) +} + +export function makeInvoice(msatoshi, description) { + return rpcCall('createinvoice', { + amountMsat: msatoshi, + description + }).then(({serialized, paymentHash}) => ({ + bolt11: serialized, + hash: paymentHash + })) +} + +var eventCallbacks = {} + +export function listenForEvents(defaultCallback) { + return getRpcParams().then(({endpoint, password}) => { + const ws = new WebSocket(endpoint.trim().replace('://', '://:' + password)) + + ws.onmessage = ev => { + var event + try { + event = JSON.parse(ev.data) + } catch (e) { + console.log('failed to parse websocket event', ev) + return + } + + // specific callbacks registered for this event + if (eventCallbacks[event.id]) { + eventCallbacks[event.id](event) + delete eventCallbacks[event.id] + } + + // here we send normalized data, not the raw event + switch (event.type) { + case 'payment-received': + defaultCallback({}) + } + } + + ws.onclose = ev => { + console.log('websocket closed', ev) + listenForEvents(defaultCallback) + } + + ws.onerror = ev => { + console.log('error on websocket', err) + listenForEvents(defaultCallback) + } + }) +} + +function rpcCall(method, params = {}) { + return getRpcParams().then(({endpoint, password}) => { + let formData = new FormData() + + for (let k in params) { + if (typeof params[k] === 'undefined') continue + + formData.append(k, params[k]) + } + + return fetch(endpoint.trim() + '/' + method, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + Authorization: 'Basic ' + window.btoa(':' + password) + }, + body: formData + }) + .then(r => r.json()) + .then(res => { + if (res.error) { + throw new Error(res.error) + } + + return res + }) + }) +} diff --git a/src/interfaces/index.js b/src/interfaces/index.js new file mode 100644 index 0000000..eed7714 --- /dev/null +++ b/src/interfaces/index.js @@ -0,0 +1,21 @@ +/** @format */ + +import * as lightningd_spark from './lightningd_spark' +import * as eclair from './eclair' +import * as ptarmigan from './ptarmigan' +import {getRpcParams} from '../utils' + +const kinds = { + lightningd_spark, + eclair, + ptarmigan +} + +export default function handleRPC(rpcField = {}) { + return getRpcParams().then(({kind}) => { + for (let method in rpcField) { + let args = rpcField[method] + return kinds[kind][method].apply(null, args) + } + }) +} diff --git a/src/interfaces/lightningd_spark.js b/src/interfaces/lightningd_spark.js new file mode 100644 index 0000000..9ed11d4 --- /dev/null +++ b/src/interfaces/lightningd_spark.js @@ -0,0 +1,157 @@ +/** @format */ + +import cuid from 'cuid' + +import {getRpcParams} from '../utils' + +export function summary() { + return Promise.all([ + rpcCall('getinfo').then(({blockheight, id, alias, color, address}) => { + address = + address.length === 0 ? null : `${address[0].address}:${address[0].port}` + + return {blockheight, id, alias, color, address} + }), + rpcCall('listfunds').then(({channels}) => { + return channels.reduce((acc, ch) => acc + ch.channel_sat, 0) + }), + rpcCall('listinvoices').then(({channels}) => { + return resp.invoices.filter(inv => inv.status === 'paid') + }), + rpcCall('listpayments').then(resp => { + return resp.payments.filter(pay => pay.status === 'complete') + }) + ]).then(([info, balance, invoices, payments]) => { + let transactions = invoices + .map(({paid_at, expires_at, msatoshi, description = ''}) => ({ + date: paid_at || expires_at, + amount: msatoshi, + description + })) + .slice(-15) + .concat( + payments + .map(({created_at, msatoshi, description, payment_preimage}) => ({ + date: created_at, + amount: -msatoshi, + description: description || payment_preimage + })) + .slice(-15) + ) + .sort((a, b) => a.date - b.date) + .slice(-15) + .reverse() + + return {info, balance, transactions} + }) +} + +export function pay(bolt11, msatoshi = undefined, description = undefined) { + return rpcCall('pay', { + bolt11, + msatoshi, + label: description + }).then(({payment_preimage, msatoshi, msatoshi_sent}) => ({ + msatoshi_paid: msatoshi_sent, + msatoshi_fees: msatoshi_sent - msatoshi, + preimage: payment_preimage + })) +} + +export function decode(bolt11) { + return rpcCall('decodepay', [bolt11]).then( + ({description, msatoshi, payee, payment_hash, expiry, created_at}) => ({ + description, + msatoshi, + nodeid: payee, + hash: payment_hash, + creation: created_at, + expiry + }) + ) +} + +export function makeInvoice(msatoshi = 'any', description, label = undefined) { + if (!label) label = `kWh.${cuid.slug()}` + return rpcCall('invoice', {msatoshi, label, description}).then( + ({bolt11}) => bolt11 + ) +} + +export function listenForEvents(defaultCallback) { + return getRpcParams().then(({endpoint, username, password}) => { + const es = new EventSource( + normalizeURL(endpoint) + + '/stream?access-key=' + + makeAccessKey(username, password) + ) + es.onmessage = ev => { + console.log('got message', ev, ev.data) + + var event + + try { + event = JSON.parse(ev.data) + } catch (e) { + console.log('failed to parse websocket event', ev) + return + } + + // specific callbacks registered for this event + if (eventCallbacks[event.id]) { + eventCallbacks[event.id](event) + } + + // here we send normalized data, not the raw event + switch (event.type) { + case 'payment-received': + defaultCallback({}) + } + } + + es.onerror = err => { + console.log('error on eventsource', err) + listenForEvents(defaultCallback) + } + }) +} + +function rpcCall(method, params = []) { + return getRpcParams().then(({endpoint, username, password}) => { + return fetch(normalizeURL(endpoint) + '/rpc', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Requested-With': 'kwh-extension', + 'X-Access': makeAccessKey(username, password) + }, + body: JSON.stringify({method, params}) + }) + .then(r => r.json()) + .then(res => { + if (res.code) { + throw new Error(res.message || res.code) + } + + return res + }) + }) +} + +function normalizeURL(endpoint) { + endpoint = endpoint.trim() + + if (endpoint.slice(-4) === '/rpc') { + return endpoint.slice(0, -4) + } else { + return endpoint + } +} + +function makeAccessKey(username, password) { + return createHmac('sha256', `${username}:${password}`) + .update('access-key') + .digest('base64') + .replace(/\W+/g, '') +} diff --git a/src/interfaces/ptarmigan.js b/src/interfaces/ptarmigan.js new file mode 100644 index 0000000..4685d4f --- /dev/null +++ b/src/interfaces/ptarmigan.js @@ -0,0 +1,83 @@ +/** @format */ + +export function summary() { + return Promise.all([ + rpcCall('getinfo'), + rpcCall('listpayments').then(pmts => + pmts.filter(pmt => pmt.state === 'succeeded').slice(-15) + ) + ]) + .then(([info, payments]) => [ + info, + payments, + Promise.all(payments.map(pmt => pmt.invoice).map(decode)) + ]) + .then(([{node_id, total_local_msat}, payments, decodedpayments]) => ({ + info: { + id: node_id + }, + balance: total_local_msat / 1000, + transactions: payments.map((pmt, i) => ({ + date: decodedpayments[i].creation, + amount: -(pmt.additional_amount_msat + decodedpayments[i].msatoshi), + description: decodedpayments[i].description + })) + })) +} + +export function pay(bolt11, msatoshi = undefined, description = undefined) { + return rpcCall('sendpayment', {bolt11}).then(() => { + /* missing something, this API returns no result */ + }) +} + +export function decode(bolt11) { + return rpcCall('decodeinvoice', {bolt11}).then( + ({ + description_string, + payment_hash, + pubkey, + amount_msat, + expiry, + timestamp + }) => ({ + description: description_string, + msatoshi: amount_msat, + nodeid: pubkey, + hash: payment_hash, + creation: Date.parse(timestamp) / 1000, + expiry + }) + ) +} + +export function makeInvoice(msatoshi, description) { + return rpcCall('createinvoice', {amountMsat: msatoshi}).then( + ({bolt11}) => bolt11 + ) +} + +export function listenForEvents() { + /* not yet supported by ptarmigan rest api */ +} + +function rpcCall(method, params = {}) { + return getRpcParams().then(({endpoint, username, password}) => { + return fetch(endpoint.trim() + '/' + method, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(params) + }) + .then(r => r.json()) + .then(res => { + if (res.error) { + throw new Error(res.error.message || res.error.code) + } + + return res.result + }) + }) +} diff --git a/src/predefined-behaviors.js b/src/predefined-behaviors.js index 0f85f02..57db47b 100644 --- a/src/predefined-behaviors.js +++ b/src/predefined-behaviors.js @@ -4,7 +4,8 @@ import browser from 'webextension-polyfill' import {HOME} from './constants' import {set, cleanupBrowserAction} from './current-action' -import {rpcCall, msatsFormat, notify} from './utils' +import {msatsFormat, notify} from './utils' +import handleRPC from './interfaces' const behaviors = { 'navigate-home': (_, __, tabId) => { @@ -13,14 +14,14 @@ const behaviors = { 'save-pending-to-current-action': (_, [action], tabId) => { set(tabId, {...action, pending: true}) }, - 'return-preimage': ({payment_preimage}, [_, promise]) => { - if (promise) promise.resolve(payment_preimage) + 'return-preimage': ({preimage}, [_, promise]) => { + if (promise) promise.resolve(preimage) }, - 'notify-payment-success': ({msatoshi, msatoshi_sent}, _) => { + 'notify-payment-success': ({msatoshi_paid, msatoshi_fees}, _) => { notify({ title: 'Payment succeeded', - message: `${msatsFormat(msatoshi)} paid with a fee of ${msatsFormat( - msatoshi_sent - msatoshi + message: `${msatsFormat(msatoshi_paid)} paid with a fee of ${msatsFormat( + msatoshi_fees )}.`, iconUrl: '/icon64-active.png' }) @@ -35,7 +36,7 @@ const behaviors = { title: 'Payment error' }) }, - 'paste-invoice': ({bolt11}, [{pasteOn}]) => { + 'paste-invoice': (bolt11, [{pasteOn}]) => { if (pasteOn) { browser.tabs.sendMessage(pasteOn[0], { paste: true, @@ -44,14 +45,14 @@ const behaviors = { }) } }, - 'save-invoice-to-current-action': ({bolt11}, [action, _], tabId) => { + 'save-invoice-to-current-action': (bolt11, [action, _], tabId) => { set(tabId, {...action, invoice: bolt11}) }, - 'return-invoice': ({bolt11}, [_, promise]) => { + 'return-invoice': (bolt11, [_, promise]) => { if (promise) promise.resolve(bolt11) }, 'wait-for-invoice': ({bolt11}, _, tabId, extra) => { - rpcCall('waitinvoice', [extra.newInvoiceLabel]) + handleRPC({invoiceWait: [extra.newInvoiceLabel]}) .then(({status, msatoshi, description}) => { if (status === 'paid') { notify({ diff --git a/src/utils.js b/src/utils.js index a6dd2f7..f420df5 100644 --- a/src/utils.js +++ b/src/utils.js @@ -60,6 +60,7 @@ export function getOriginData() { } export const defaultRpcParams = { + kind: 'lightningd_spark', endpoint: 'http://localhost:9737/rpc', username: '', password: '' @@ -83,37 +84,17 @@ export function rpcParamsAreSet() { }) } -export function rpcCall(method, params = []) { - return getRpcParams().then(({endpoint, username, password}) => { - let accessKey = createHmac('sha256', `${username}:${password}`) - .update('access-key') - .digest('base64') - .replace(/\W+/g, '') - - return fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-Requested-With': 'kwh-extension', - 'X-Access': accessKey - }, - body: JSON.stringify({method, params}) - }).then(r => r.json()) - }) -} - export function msatsFormat(msatoshis) { if (Math.abs(msatoshis) < 1000) { - return `${msatoshis} msat${msatoshis === 1 || msatoshis === -1 ? '' : 's'}` + return `${msatoshis} msat` } - if (msatoshis === 1000) return '1 sat' + if (msatoshis === 1000) return '1 satoshi' for (let prec = 3; prec >= 0; prec--) { let dec = 10 ** prec if (msatoshis / dec === parseInt(msatoshis / dec)) { - return `${(msatoshis / 1000).toFixed(3 - prec)} sats` + return `${(msatoshis / 1000).toFixed(3 - prec)} sat` } } }