From 7b6602e386dc636575a6e436b310d1c86d57d30c Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 24 Jun 2024 12:58:22 +0200 Subject: [PATCH] wip: Add LNC --- components/wallet/index.js | 2 + components/wallet/lnc.js | 176 +++++++++++++++++++++++++++++++++++++ lib/validate.js | 16 ---- 3 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 components/wallet/lnc.js diff --git a/components/wallet/index.js b/components/wallet/index.js index 9b19d455..740daeb4 100644 --- a/components/wallet/index.js +++ b/components/wallet/index.js @@ -9,6 +9,8 @@ import { bolt11Tags } from '@/lib/bolt11' export const WALLET_DEFS = [ await import('@/components/wallet/lnbits'), await import('@/components/wallet/nwc') + // FIXME: this doesn't break the build but it results in infinite page loads for some reason ... + // await import('@/components/wallet/lnc') ] export const Status = { diff --git a/components/wallet/lnc.js b/components/wallet/lnc.js new file mode 100644 index 00000000..b5e1f27b --- /dev/null +++ b/components/wallet/lnc.js @@ -0,0 +1,176 @@ +import bip39Words from '@/lib/bip39-words' +import LNC from '@lightninglabs/lnc-web' +import { Mutex } from 'async-mutex' +import { string, array, object } from 'yup' +import { Form, PasswordInput, SubmitButton } from '@/components/form' +import CancelButton from '@/components/cancel-button' +import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment' +import { bolt11Tags } from '@/lib/bolt11' +import { Status } from '@/components/wallet' + +export const name = 'lnc' + +export const fields = [ + { + name: 'pairingPhrase', + label: 'pairing phrase', + type: 'password', + help: 'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance ```\n\n```$ litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.' + }, + { + name: 'password', + label: 'password', + type: 'password', + hint: 'encrypts your pairing phrase when stored locally', + optional: true + } +] + +export const card = { + title: 'LNC', + subtitle: 'use Lightning Node Connect for LND payments', + badges: ['send only', 'non-custodialish', 'budgetable'] +} + +const XXX_DEFAULT_PASSWORD = 'password' + +export async function validate ({ me, logger, pairingPhrase, password }) { + const lnc = await getLNC({ me }) + try { + lnc.credentials.pairingPhrase = pairingPhrase + await lnc.connect() + await validateNarrowPerms(lnc) + lnc.credentials.password = password || XXX_DEFAULT_PASSWORD + } finally { + lnc.disconnect() + } +} + +export const schema = object({ + pairingPhrase: array() + .transform(function (value, originalValue) { + if (this.isType(value) && value !== null) { + return value + } + return originalValue ? originalValue.split(/[\s]+/) : [] + }) + .of(string().trim().oneOf(bip39Words, ({ value }) => `'${value}' is not a valid pairing phrase word`)) + .min(2, 'needs at least two words') + .max(10, 'max 10 words') + .required('required'), + password: string() +}) + +const mutex = new Mutex() + +export async function unlock ({ lnc, password, status, showModal, logger }) { + if (status === Status.Enabled) return password + + return await new Promise((resolve, reject) => { + const cancelAndReject = async () => { + reject(new Error('password canceled')) + } + showModal(onClose => { + return ( +
{ + try { + lnc.credentials.password = values?.password + logger.ok('wallet enabled') + onClose() + resolve(values.password) + } catch (err) { + logger.error('failed attempt to unlock wallet', err) + throw err + } + }} + > +

Unlock LNC

+ +
+ { onClose(); cancelAndReject() }} /> + unlock +
+ + ) + } + ) + }) +} + +export async function sendPayment ({ bolt11, pairingPhrase, password: configuredPassword, logger }) { + const hash = bolt11Tags(bolt11).payment_hash + + return await mutex.runExclusive(async () => { + let lnc + try { + lnc = await getLNC() + const password = await unlock({ lnc, password: configuredPassword }) + // credentials need to be decrypted before connecting after a disconnect + lnc.credentials.password = password || XXX_DEFAULT_PASSWORD + await lnc.connect() + const { paymentError, paymentPreimage: preimage } = + await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 }) + + if (paymentError) throw new Error(paymentError) + if (!preimage) throw new Error('No preimage in response') + + return { preimage } + } catch (err) { + const msg = err.message || err.toString?.() + if (msg.includes('invoice expired')) { + throw new InvoiceExpiredError(hash) + } + if (msg.includes('canceled')) { + throw new InvoiceCanceledError(hash) + } + throw err + } finally { + try { + lnc.disconnect() + logger.info('disconnecting after:', `payment_hash=${hash}`) + // wait for lnc to disconnect before releasing the mutex + await new Promise((resolve, reject) => { + let counter = 0 + const interval = setInterval(() => { + if (lnc.isConnected) { + if (counter++ > 100) { + logger.error('failed to disconnect from lnc') + clearInterval(interval) + reject(new Error('failed to disconnect from lnc')) + } + return + } + clearInterval(interval) + resolve() + }) + }, 50) + } catch (err) { + logger.error('failed to disconnect from lnc', err) + } + } + }) +} + +function getLNC ({ me }) { + if (window.lnc) return window.lnc + window.lnc = new LNC({ namespace: me?.id ? `stacker:${me.id}` : undefined }) + return window.lnc +} + +async function validateNarrowPerms (lnc) { + if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) { + throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync') + } + if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) { + throw new Error('too broad permission: lnrpc.Wallet.SendCoins') + } + // TODO: need to check for more narrow permissions + // blocked by https://github.com/lightninglabs/lnc-web/issues/112 +} diff --git a/lib/validate.js b/lib/validate.js index 7cb7a221..73547a51 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -12,7 +12,6 @@ import * as subsFragments from '@/fragments/subs' import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon' import { datePivot } from './time' import { decodeRune } from '@/lib/cln' -import bip39Words from './bip39-words' const { SUB } = subsFragments const { NAME_QUERY } = usersFragments @@ -599,21 +598,6 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => return accum }, {}))) -export const lncSchema = object({ - pairingPhrase: array() - .transform(function (value, originalValue) { - if (this.isType(value) && value !== null) { - return value - } - return originalValue ? originalValue.split(/[\s]+/) : [] - }) - .of(string().trim().oneOf(bip39Words, ({ value }) => `'${value}' is not a valid pairing phrase word`)) - .min(2, 'needs at least two words') - .max(10, 'max 10 words') - .required('required'), - password: string() -}) - export const bioSchema = object({ bio: string().required('required').trim() })