diff --git a/fragments/wallet.js b/fragments/wallet.js index fc0f4c66b..ba6214b83 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -141,6 +141,12 @@ export const WALLET = gql` url secondaryPassword } + ... on WalletLnc { + pairingPhraseRecv + localKeyRecv + remoteKeyRecv + serverHostRecv + } } } } @@ -181,6 +187,12 @@ export const WALLET_BY_TYPE = gql` url secondaryPassword } + ... on WalletLnc { + pairingPhraseRecv + localKeyRecv + remoteKeyRecv + serverHostRecv + } } } } diff --git a/lib/validate.js b/lib/validate.js index 506b323bf..a17add36f 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -740,10 +740,15 @@ export const blinkSchema = object({ .oneOf(['USD', 'BTC'], 'must be BTC or USD') }) -export const lncSchema = object({ - pairingPhrase: string() - .test(async (value, context) => { +export const lncSchema = object().shape({ + pairingPhrase: string().when(['pairingPhraseRecv'], ([pairingPhraseRecv], schema) => { + if (!pairingPhraseRecv) return schema.required('required if connection for receiving not set') + return schema.test({ + test: pairingPhrase => pairingPhraseRecv !== pairingPhrase, + message: 'connection for sending cannot be the same as for receiving' + }).test(async (value, context) => { const words = value ? value.trim().split(/[\s]+/) : [] + if (!words.length) return true for (const w of words) { try { await string().oneOf(bip39Words).validate(w) @@ -758,9 +763,35 @@ export const lncSchema = object({ return context.createError({ message: 'max 10 words' }) } return true - }) - .required('required') -}) + } + ) + }), + pairingPhraseRecv: string().when(['pairingPhrase'], ([pairingPhrase], schema) => { + if (!pairingPhrase) return schema.required('required if connection for receiving not set') + return schema.test({ + test: pairingPhraseRecv => pairingPhraseRecv !== pairingPhrase, + message: 'connection for sending cannot be the same as for receiving' + }).test(async (value, context) => { + const words = value ? value.trim().split(/[\s]+/) : [] + if (!words.length) return true + for (const w of words) { + try { + await string().oneOf(bip39Words).validate(w) + } catch { + return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) + } + } + if (words.length < 2) { + return context.createError({ message: 'needs at least two words' }) + } + if (words.length > 10) { + return context.createError({ message: 'max 10 words' }) + } + return true + } + ) + }) +}, ['pairingPhrase', 'pairingPhraseRecv']) export const phoenixdSchema = object().shape({ url: string().url().required('required').trim(), diff --git a/package-lock.json b/package-lock.json index 750708750..97eb3b548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "nprogress": "^0.2.0", "opentimestamps": "^0.4.9", "page-metadata-parser": "^1.1.4", + "pg": "^8.12.0", "pg-boss": "^9.0.3", "piexifjs": "^1.0.6", "prisma": "^5.17.0", @@ -6680,14 +6681,6 @@ "node": ">=0.10" } }, - "node_modules/buffer-writer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", - "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", - "engines": { - "node": ">=4" - } - }, "node_modules/buffers": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", @@ -15279,11 +15272,6 @@ "node": ">=6" } }, - "node_modules/packet-reader": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", - "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" - }, "node_modules/page-metadata-parser": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/page-metadata-parser/-/page-metadata-parser-1.1.4.tgz", @@ -15392,21 +15380,23 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "node_modules/pg": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz", - "integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==", - "dependencies": { - "buffer-writer": "2.0.0", - "packet-reader": "1.0.0", - "pg-connection-string": "^2.5.0", - "pg-pool": "^3.5.2", - "pg-protocol": "^1.5.0", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", + "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.6.4", + "pg-pool": "^3.6.2", + "pg-protocol": "^1.6.1", "pg-types": "^2.1.0", "pgpass": "1.x" }, "engines": { "node": ">= 8.0.0" }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, "peerDependencies": { "pg-native": ">=3.0.1" }, @@ -15441,10 +15431,18 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "license": "MIT", + "optional": true + }, "node_modules/pg-connection-string": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", - "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", + "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==", + "license": "MIT" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -15455,17 +15453,19 @@ } }, "node_modules/pg-pool": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz", - "integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", + "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz", - "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==" + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", + "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==", + "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", diff --git a/package.json b/package.json index 3f483bcc0..c70a3f162 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "nprogress": "^0.2.0", "opentimestamps": "^0.4.9", "page-metadata-parser": "^1.1.4", + "pg": "^8.12.0", "pg-boss": "^9.0.3", "piexifjs": "^1.0.6", "prisma": "^5.17.0", diff --git a/prisma/migrations/20240827182401_lnc_recv/migration.sql b/prisma/migrations/20240827182401_lnc_recv/migration.sql new file mode 100644 index 000000000..dcda11e98 --- /dev/null +++ b/prisma/migrations/20240827182401_lnc_recv/migration.sql @@ -0,0 +1,25 @@ +-- AlterEnum +ALTER TYPE "WalletType" ADD VALUE 'LNC'; + +-- CreateTable +CREATE TABLE "WalletLNC" ( + "id" SERIAL NOT NULL, + "walletId" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "pairingPhraseRecv" TEXT NOT NULL, + "localKeyRecv" TEXT, + "remoteKeyRecv" TEXT, + "serverHostRecv" TEXT, + CONSTRAINT "WalletLNC_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_walletId_key" ON "WalletLNC"("walletId"); + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TRIGGER wallet_lnc_as_jsonb +AFTER INSERT OR UPDATE ON "WalletLNC" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65b4d0f21..11a379b73 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -173,6 +173,7 @@ enum WalletType { LNBITS NWC PHOENIXD + LNC } model Wallet { @@ -199,6 +200,7 @@ model Wallet { walletLNbits WalletLNbits? walletNWC WalletNWC? walletPhoenixd WalletPhoenixd? + walletLNC WalletLNC? withdrawals Withdrawl[] InvoiceForward InvoiceForward[] @@ -267,6 +269,18 @@ model WalletNWC { nwcUrlRecv String } +model WalletLNC { + id Int @id @default(autoincrement()) + walletId Int @unique + wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + pairingPhraseRecv String + localKeyRecv String? + remoteKeyRecv String? + serverHostRecv String? +} + model WalletPhoenixd { id Int @id @default(autoincrement()) walletId Int @unique diff --git a/wallets/lnc/client.js b/wallets/lnc/client.js index 46371866e..a90e5a884 100644 --- a/wallets/lnc/client.js +++ b/wallets/lnc/client.js @@ -1,6 +1,8 @@ import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment' import { bolt11Tags } from '@/lib/bolt11' import { Mutex } from 'async-mutex' +import { computePerms } from 'wallets/lnc' + export * from 'wallets/lnc' async function disconnect (lnc, logger) { @@ -22,9 +24,8 @@ async function disconnect (lnc, logger) { } clearInterval(interval) resolve() - }) - }, 50) - logger.info('disconnected') + }, 50) + }) } catch (err) { logger.error('failed to disconnect from lnc', err) } @@ -41,7 +42,7 @@ export async function testSendPayment (credentials, { logger }) { logger.ok('connected') logger.info('validating permissions ...') - await validateNarrowPerms(lnc) + checkPerms(lnc, computePerms({ canSend: true })) logger.ok('permissions ok') return lnc.credentials.credentials @@ -84,36 +85,24 @@ export async function sendPayment (bolt11, credentials, { logger }) { } async function getLNC (credentials = {}) { - const serverHost = 'mailbox.terminal.lightning.today:443' - // XXX we MUST reuse the same instance of LNC because it references a global Go object - // that holds closures to the first LNC instance it's created with - if (window.lnc) { - window.lnc.credentials.credentials = { - ...window.lnc.credentials.credentials, - ...credentials, - serverHost - } - return window.lnc - } const { default: { default: LNC } } = await import('@lightninglabs/lnc-web') - window.lnc = new LNC({ - credentialStore: new LncCredentialStore({ - ...credentials, - serverHost - }) + return new LNC({ + credentialStore: new LncCredentialStore({ ...credentials, serverHost: 'mailbox.terminal.lightning.today:443' }) }) - return window.lnc } -function validateNarrowPerms (lnc) { - if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) { - throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync') +function checkPerms (lnc, { expectedPerms, unexpectedPerms }) { + for (const perm of expectedPerms) { + if (!lnc.hasPerms(perm)) { + throw new Error('missing permission: ' + perm) + } } - if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) { - throw new Error('too broad permission: lnrpc.Wallet.SendCoins') + + for (const perm of unexpectedPerms) { + if (lnc.hasPerms(perm)) { + throw new Error('unexpected permission: ' + perm) + } } - // TODO: need to check for more narrow permissions - // blocked by https://github.com/lightninglabs/lnc-web/issues/112 } // default credential store can go fuck itself diff --git a/wallets/lnc/index.js b/wallets/lnc/index.js index e349b6dd8..4aeb2a599 100644 --- a/wallets/lnc/index.js +++ b/wallets/lnc/index.js @@ -7,6 +7,8 @@ export const fields = [ name: 'pairingPhrase', label: 'pairing phrase', type: 'password', + optional: 'for sending', + clientOnly: true, 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.', editable: false }, @@ -14,18 +16,51 @@ export const fields = [ name: 'localKey', type: 'text', optional: true, + clientOnly: true, hidden: true }, { name: 'remoteKey', type: 'text', optional: true, + clientOnly: true, hidden: true }, { name: 'serverHost', type: 'text', optional: true, + clientOnly: true, + hidden: true + }, + { + name: 'pairingPhraseRecv', + label: 'pairing phrase', + type: 'password', + optional: 'for receiving', + serverOnly: true, + help: 'We only need permissions for the uri `/lnrpc.Lightning/AddInvoice`\n\nCreate an account with narrow permissions:\n\n```$ litcli accounts create```\n\n```$ litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/AddInvoice```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.', + editable: false + }, + { + name: 'localKeyRecv', + type: 'text', + optional: true, + serverOnly: true, + hidden: true + }, + { + name: 'remoteKeyRecv', + type: 'text', + optional: true, + serverOnly: true, + hidden: true + }, + { + name: 'serverHostRecv', + type: 'text', + optional: true, + serverOnly: true, hidden: true } ] @@ -33,7 +68,35 @@ export const fields = [ export const card = { title: 'LNC', subtitle: 'use Lightning Node Connect for LND payments', - badges: ['send only', 'budgetable'] + badges: ['send & receive', 'budgetable'] } export const fieldValidation = lncSchema + +export const walletType = 'LNC' + +export const walletField = 'walletLNC' + +export function computePerms ({ canSend, canReceive }, strict = true) { + const expectedPerms = [] + const unexpectedPerms = [] + const setPerm = (name, expected) => { + if (expected) { + expectedPerms.push(name) + } else { + unexpectedPerms.push(name) + } + } + if (strict || canSend !== undefined) { + setPerm('lnrpc.Lightning.SendPaymentSync', canSend) + } + if (strict || canReceive !== undefined) { + setPerm('lnrpc.Lightning.AddInvoice', canReceive) + } + if (strict) { + setPerm('lnrpc.Lightning.SendCoins', false) + // ... + } + + return { expectedPerms, unexpectedPerms } +} diff --git a/wallets/lnc/server.js b/wallets/lnc/server.js new file mode 100644 index 000000000..20b53582f --- /dev/null +++ b/wallets/lnc/server.js @@ -0,0 +1,227 @@ +import { withTimeout } from '@/lib/time' +import { computePerms } from 'wallets/lnc' +import { Mutex } from 'async-mutex' +import { fork } from 'child_process' +import pg from 'pg' +import { fileURLToPath } from 'url' +import path from 'path' +import crypto from 'crypto' +export * from 'wallets/lnc' + +// if true = each wallet can run in parallel with others +// if false = wallet requests are serialized +const PARALLEL = true + +// if true = use a single session is used for all calls +// if false = use a new session for each call +const SINGLE_SESSION = true + +export async function testCreateInvoice (credentials) { + const timeout = 60_000 // high timeout due to lnc startup time + return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, credentials), timeout) +} + +export async function createInvoice ({ msats, description, expiry }, credentials) { + const mutex = getMutex(credentials.pairingPhraseRecv) + + return await mutex.runExclusive(async () => { + const lockId = credentials.pairingPhraseRecv + let lock + try { + lock = await waitAndAcquireLock(lockId) + + const { expectedPerms, unexpectedPerms } = computePerms({ canReceive: true }) + + const ipcCall = { + credentials: { + localKey: credentials.localKeyRecv, + remoteKey: credentials.remoteKeyRecv, + pairingPhrase: credentials.pairingPhraseRecv, + serverHost: credentials.serverHostRecv || 'mailbox.terminal.lightning.today:443' + }, + actions: [ + { + action: 'withPerms', + args: expectedPerms + }, + { + action: 'withoutPerms', + args: unexpectedPerms + }, + { + action: 'addInvoice', + args: { memo: description, valueMsat: msats, expiry } + } + ] + } + + const ipcRes = await runInWorker(ipcCall) + if (ipcRes.error) throw new Error(ipcRes.error) + + const newCredentials = ipcRes.credentials + const result = ipcRes.results[2].result + credentials.localKeyRecv = newCredentials.localKey + credentials.remoteKeyRecv = newCredentials.remoteKey + credentials.serverHostRecv = newCredentials.serverHost + if (globalThis.lncInternalContext) throw new Error('Internal context leaked') + const paymentRequest = result.paymentRequest + if (!paymentRequest) throw new Error('No payment request in response') + return paymentRequest + } finally { + if (lock) { + await releaseLock(lock) + } + } + }) +} + +let pgClient +const mutexes = [] + +async function runInWorker (ipcCall, timeout = 15_000) { + return new Promise((resolve, reject) => { + const dirname = path.dirname(fileURLToPath(import.meta.url)) + const workerPath = path.join(dirname, 'worker.cjs') + const worker = fork(workerPath, [], { + cwd: dirname, + stdio: 'inherit' + }) + + const watchdog = setTimeout(() => { + worker.kill('SIGKILL') + reject(new Error('LNC subprocess timed out (killed by watchdog)')) + }, timeout) + + worker.on('message', (msg) => { + clearTimeout(watchdog) + resolve(msg) + }) + worker.on('error', (err) => { + clearTimeout(watchdog) + reject(err) + }) + worker.on('exit', (code) => { + clearTimeout(watchdog) + if (code !== 0) { + reject(new Error(`LNC subprocess exited with code ${code}`)) + } + }) + worker.send(ipcCall) + }) +} + +async function waitAndAcquireLock (key, timeout = 60_000) { + const toAdvKey = (v) => { + const hash = crypto.createHash('sha256').update(v).digest() + const n1 = hash.readInt32BE(0) + const n2 = hash.readInt32BE(4) + return [n1, n2] + } + key = toAdvKey('lnc-' + key) + const connectionUrl = process.env.DATABASE_URL + let client = SINGLE_SESSION ? pgClient : undefined + if (!client) { + client = new pg.Client({ connectionString: connectionUrl }) + + // close the client on nodejs exit + const onExit = async () => { + await client.end() + } + process.on('exit', onExit) + client.on('end', () => { + process.off('exit', onExit) + }) + + await client.connect() + + if (SINGLE_SESSION) { + // reset on end (for reconnection) + client.on('end', () => { + pgClient = undefined + }) + + pgClient = client + } + } + + if (PARALLEL && SINGLE_SESSION) { + // if parallel mode: we use polling to acquire the lock with pg_try_advisory_lock + // this prevents the session from blocking while waiting for the lock + let acquired = false + const startTime = Date.now() + while (!acquired) { + if (Date.now() - startTime > timeout) { + throw new Error('System is busy, please try again later (lock-timeout)') + } + const res = await client.query('SELECT pg_try_advisory_lock($1, $2)', key) + if (res.rows[0].pg_try_advisory_lock) { + acquired = true + } else { + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + } else { + // if serial mode: we use pg_advisory_lock to acquire the lock, we just lock the session until we get it + await client.query('SELECT pg_advisory_lock($1, $2)', key) + } + return { client, key } +} + +async function releaseLock (lock) { + const { client, key } = lock + try { + await client.query('SELECT pg_advisory_unlock($1, $2)', key) + } catch (e) { + // could happen if the client lost the connection + console.error('Error releasing lock', e) + } + + // close the client if not in single session mode + if (!SINGLE_SESSION) { + await client.end() + } +} + +function getMutex (key, mutexTimeout = 120_000) { + if (PARALLEL) { + // in parallel mode we use a list of mutexes + // one mutex per wallet + let mutex + for (let i = 0; i < mutexes.length; i++) { + const v = mutexes[i] + if (!v.mutex.isLocked() && Date.now() - v.lastAccess > mutexTimeout) { + // clear unused mutexes + mutexes.splice(i, 1) + i-- + } else if (v.key === key) { + mutex = v + } + } + if (!mutex) { + mutex = new Mutex() + mutexes.push({ + mutex, + lastAccess: Date.now(), + key + }) + } else { + mutex.lastAccess = Date.now() + mutex = mutex.mutex + } + return mutex + } else { + // in serial mode we use a single mutex + let mutex = mutexes['mutex.sync'] + if (!mutex) { + mutex = new Mutex() + mutexes['mutex.sync'] = { + mutex, + lastAccess: Date.now(), + key + } + } else { + mutex = mutex.mutex + } + return mutex + } +} diff --git a/wallets/lnc/worker.cjs b/wallets/lnc/worker.cjs new file mode 100644 index 000000000..6c5c5d5cd --- /dev/null +++ b/wallets/lnc/worker.cjs @@ -0,0 +1,205 @@ +const path = require('path') +const crypto = require('crypto') +const ws = require('ws') +const { TextEncoder, TextDecoder } = require('util') +const { performance } = require('perf_hooks') + +// Init context +globalThis.WebSocket = ws +globalThis.path = path +globalThis.TextEncoder = TextEncoder +globalThis.TextDecoder = TextDecoder +globalThis.performance = performance +globalThis.crypto = crypto +globalThis.lncInternalContext = true +// --- + +const LNC = require('@lightninglabs/lnc-web').default + +// Send multiple actions per message +// if one action fails, the whole message fails +// message : { +// credentials: {}, +// serverHost: '', +// actions :[ +// { // action 1 +// action: 'withPerms', +// args: {} +// } +// { // action 2 +// action: 'addInvoice', +// args: {} +// } // ... +// } +// response : { +// status: 'ok' | 'error', +// error: 'error message', +// credentials: {}, // updated credentials +// results: [ +// { +// action: 'withPerms', +// result: {} +// }, +// { +// action: 'addInvoice', +// result: {} +// } +// ] +process.on('message', async (message) => { + const { credentials, actions } = message + + const out = { + credentials: {}, + results: [], + status: 'ok', + error: undefined + } + + try { + // initialize LNC connection + const lnc = await connect(credentials) + // transfer updated credentials + out.credentials = lnc.credentials.credentials + + // execute all actions + for (const action of actions) { + const actionOut = { action: action.action } + out.results.push(actionOut) + if (action.action === 'addInvoice') { + actionOut.result = await lnc.lnd.lightning.addInvoice(action.args) + } else if (action.action === 'withPerms') { + actionOut.result = true + for (const perm of action.args) { + actionOut.result &&= lnc.hasPerms(perm) + if (!actionOut.result) { + throw new Error('missing permission: ' + perm) + } + } + } else if (action.action === 'withoutPerms') { + actionOut.result = true + for (const perm of action.args) { + actionOut.result &&= !lnc.hasPerms(perm) + if (!actionOut.result) { + throw new Error('unexpected permission: ' + perm) + } + } + } else { + throw new Error('Unknown action: ' + action.action) + } + } + } catch (error) { + out.status = 'error' + out.error = error.message || error + } + + // send results + process.send(out) + + // finalize + try { + await finalize() + } catch (e) { + console.error('LNC(worker): Error while finalizing: ', e) + } +}) + +async function connect (credentials) { + const lnc = new LNC({ + credentialStore: new LncCredentialStore(credentials) + }) + + await lnc.connect() + while (true) { + if (lnc.isConnected) break + console.info('LNC(worker): LNC is not ready yet...waiting...') + await new Promise(resolve => setTimeout(resolve, 100)) + } + console.info('LNC(worker): LNC is connected') + return lnc +} + +async function finalize (lnc) { + if (lnc && lnc.isConnected) { + try { + console.log('LNC(worker): disconnecting...') + if (lnc.isConnected) lnc.disconnect() + await new Promise((resolve, reject) => { + let counter = 0 + const interval = setInterval(() => { + if (lnc.isConnected) { + if (counter++ > 100) { + console.error('LNC(worker): failed to disconnect from lnc') + clearInterval(interval) + reject(new Error('failed to disconnect from lnc')) + } + return + } + clearInterval(interval) + resolve() + }, 100) + }) + } catch (e) { + console.error('LNC(worker): Error while disconnecting: ', e) + } + } + process.exit(0) +} + +class LncCredentialStore { + credentials = { + localKey: '', + remoteKey: '', + pairingPhrase: '', + serverHost: '' + } + + constructor (credentials = {}) { + this.credentials = { ...this.credentials, ...credentials } + } + + get password () { + return '' + } + + set password (password) { } + + get serverHost () { + return this.credentials.serverHost + } + + set serverHost (host) { + this.credentials.serverHost = host + } + + get pairingPhrase () { + return this.credentials.pairingPhrase + } + + set pairingPhrase (phrase) { + this.credentials.pairingPhrase = phrase + } + + get localKey () { + return this.credentials.localKey + } + + set localKey (key) { + this.credentials.localKey = key + } + + get remoteKey () { + return this.credentials.remoteKey + } + + set remoteKey (key) { + this.credentials.remoteKey = key + } + + get isPaired () { + return !!this.credentials.remoteKey || !!this.credentials.pairingPhrase + } + + clear () { + this.credentials = {} + } +} diff --git a/wallets/server.js b/wallets/server.js index 8f8e8f355..4633ffd8a 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -4,13 +4,15 @@ import * as lnAddr from 'wallets/lightning-address/server' import * as lnbits from 'wallets/lnbits/server' import * as nwc from 'wallets/nwc/server' import * as phoenixd from 'wallets/phoenixd/server' +import * as lnc from 'wallets/lnc/server' import { addWalletLog } from '@/api/resolvers/wallet' import walletDefs from 'wallets/server' import { parsePaymentRequest } from 'ln-service' import { toPositiveNumber } from '@/lib/validate' import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' + import { withTimeout } from '@/lib/time' -export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd] +export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, lnc] const MAX_PENDING_INVOICES_PER_WALLET = 25