diff --git a/.env.development b/.env.development index ec30c8eaa..eb6642f6e 100644 --- a/.env.development +++ b/.env.development @@ -61,6 +61,13 @@ LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a434 LND_MACAROON=0201036c6e6402f801030a106cf4e146abffa5d766befbbf4c73b5a31201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e6572617465120472656164000006202c3bfd55c191e925cbffd73712c9d4b9b4a8440410bde5f8a0a6e33af8b3d876 LND_SOCKET=sn_lnd:10009 +# xxd -p -c0 docker/lndk/tls-cert.pem +LNDK_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494942614443434151326741774942416749554f6d7333785a2b704256556e746e4644374a306d374c6c314d5a5977436759494b6f5a497a6a3045417749770a495445664d4230474131554541777757636d4e6e5a573467633256735a69427a615764755a5751675932567964444167467730334e5441784d4445774d4441770a4d444261474138304d446b324d4445774d5441774d4441774d466f77495445664d4230474131554541777757636d4e6e5a573467633256735a69427a615764750a5a575167593256796444425a4d424d4742797147534d34394167454743437147534d3439417745484130494142476475396358554753504979635343626d47620a362f34552b74787645306153767a734d632b704b4669586c422b502f33782f5778594d786c4842306c68396654515538746456694a3241592f516e485677556b0a4f34436a495441664d42304741315564455151574d42534343577876593246736147397a64494948633235666247356b617a414b42676771686b6a4f505151440a41674e4a41444247416945413738556450486764615856797474717432312b7557546c466e344236717565474c2f636d5970516269497343495143777859306e0a783276357a58457750552f624f6e61514e657139463841542b2f346c4b656c48664f4e2f47773d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a +LNDK_MACAROON=0201036c6e6402f801030a106cf4e146abffa5d766befbbf4c73b5a31201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e6572617465120472656164000006202c3bfd55c191e925cbffd73712c9d4b9b4a8440410bde5f8a0a6e33af8b3d876 +LNDK_SOCKET=sn_lndk:7000 + + + # nostr (NIP-57 zap receipts) # openssl rand -hex 32 NOSTR_PRIVATE_KEY=5f30b7e7714360f51f2be2e30c1d93b7fdf67366e730658e85777dfcc4e4245f diff --git a/api/lnd/index.js b/api/lnd/index.js index f09996288..ae347714c 100644 --- a/api/lnd/index.js +++ b/api/lnd/index.js @@ -1,6 +1,7 @@ import { cachedFetcher } from '@/lib/fetch' import { toPositiveNumber } from '@/lib/format' import { authenticatedLndGrpc } from '@/lib/lnd' +import { installLNDK } from '@/lib/lndk' import { getIdentity, getHeight, getWalletInfo, getNode, getPayment } from 'ln-service' import { datePivot } from '@/lib/time' import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' @@ -10,6 +11,11 @@ const lnd = global.lnd || authenticatedLndGrpc({ macaroon: process.env.LND_MACAROON, socket: process.env.LND_SOCKET }).lnd +installLNDK(lnd, { + cert: process.env.LNDK_CERT, + macaroon: process.env.LNDK_MACAROON, + socket: process.env.LNDK_SOCKET +}) if (process.env.NODE_ENV === 'development') global.lnd = lnd diff --git a/api/paidAction/index.js b/api/paidAction/index.js index cc9ced4ae..31033d8f9 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -1,10 +1,12 @@ -import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service' +import { createHodlInvoice, createInvoice } from 'ln-service' import { datePivot } from '@/lib/time' import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { createHmac } from '@/api/resolvers/wallet' import { Prisma } from '@prisma/client' import { createWrappedInvoice, createInvoice as createUserInvoice } from '@/wallets/server' import { assertBelowMaxPendingInvoices, assertBelowMaxPendingDirectPayments } from './lib/assert' +import { parseInvoice } from '@/lib/invoices' +import lnd from '@/api/lnd' import * as ITEM_CREATE from './itemCreate' import * as ITEM_UPDATE from './itemUpdate' @@ -271,7 +273,7 @@ async function performDirectAction (actionType, args, incomingContext) { } const { invoice, wallet } = invoiceObject - const hash = parsePaymentRequest({ request: invoice }).id + const hash = await parseInvoice({ request: invoice, lnd }).id const payment = await models.directPayment.create({ data: { @@ -415,7 +417,7 @@ async function createDbInvoice (actionType, args, context) { } const servedBolt11 = wrappedBolt11 ?? bolt11 - const servedInvoice = parsePaymentRequest({ request: servedBolt11 }) + const servedInvoice = await parseInvoice({ request: servedBolt11, lnd }) const expiresAt = new Date(servedInvoice.expires_at) const invoiceData = { diff --git a/api/payingAction/index.js b/api/payingAction/index.js index 0d2765376..67348a6c0 100644 --- a/api/payingAction/index.js +++ b/api/payingAction/index.js @@ -1,7 +1,7 @@ import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' import { msatsToSats, satsToMsats, toPositiveBigInt } from '@/lib/format' import { Prisma } from '@prisma/client' -import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service' +import { payInvoice, parseInvoice } from '@/lib/invoices' // paying actions are completely distinct from paid actions // and there's only one paying action: send @@ -14,7 +14,7 @@ export default async function performPayingAction ({ bolt11, maxFee, walletId }, throw new Error('You must be logged in to perform this action') } - const decoded = await parsePaymentRequest({ request: bolt11 }) + const decoded = await parseInvoice({ request: bolt11, lnd }) const cost = toPositiveBigInt(toPositiveBigInt(decoded.mtokens) + satsToMsats(maxFee)) console.log('cost', cost) @@ -40,7 +40,7 @@ export default async function performPayingAction ({ bolt11, maxFee, walletId }, }) }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) - payViaPaymentRequest({ + payInvoice({ lnd, request: withdrawal.bolt11, max_fee: msatsToSats(withdrawal.msatsFeePaying), diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index b0f9f7798..707e1010c 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,6 +1,5 @@ import { - getInvoice as getInvoiceFromLnd, deletePayment, getPayment, - parsePaymentRequest + getInvoice as getInvoiceFromLnd, deletePayment, getPayment } from 'ln-service' import crypto, { timingSafeEqual } from 'crypto' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' @@ -24,6 +23,8 @@ import validateWallet from '@/wallets/validate' import { canReceive } from '@/wallets/common' import performPaidAction from '../paidAction' import performPayingAction from '../payingAction' +import { parseInvoice } from '@/lib/invoices' +import lnd from '@/api/lnd' function injectResolvers (resolvers) { console.group('injected GraphQL resolvers:') @@ -713,7 +714,7 @@ export const walletLogger = ({ wallet, models }) => { try { if (context?.bolt11) { // automatically populate context from bolt11 to avoid duplicating this code - const decoded = await parsePaymentRequest({ request: context.bolt11 }) + const decoded = await parseInvoice({ request: context.bolt11, lnd }) context = { ...context, amount: formatMsats(decoded.mtokens), @@ -890,7 +891,7 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model // decode invoice to get amount let decoded, sockets try { - decoded = await parsePaymentRequest({ request: invoice }) + decoded = await parseInvoice({ request: invoice, lnd }) } catch (error) { console.log(error) throw new GqlInputError('could not decode invoice') @@ -990,7 +991,7 @@ export async function fetchLnAddrInvoice ( // decode invoice try { - const decoded = await parsePaymentRequest({ request: res.pr }) + const decoded = await parseInvoice({ request: res.pr, lnd }) const ourPubkey = await getOurPubkey({ lnd }) if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') { // unset lnaddr so we don't trigger another withdrawal with same destination diff --git a/docker/lndk/Dockerfile b/docker/lndk/Dockerfile index a421053a6..75dc3f443 100644 --- a/docker/lndk/Dockerfile +++ b/docker/lndk/Dockerfile @@ -1,8 +1,10 @@ # This image uses fedora 40 because the official pre-built lndk binaries require # glibc 2.39 which is not available on debian or ubuntu images. FROM fedora:40 -RUN useradd -u 1000 -m lndk +ENV INSTALLER_DOWNLOAD_URL="https://github.com/riccardobl/lndk/releases/download/v0.2.0-maxfee" + +RUN useradd -u 1000 -m lndk RUN mkdir -p /home/lndk/.lndk COPY ["./tls-*", "/home/lndk/.lndk"] RUN chown 1000:1000 -Rvf /home/lndk/.lndk && \ @@ -10,7 +12,7 @@ RUN chown 1000:1000 -Rvf /home/lndk/.lndk && \ chmod 600 /home/lndk/.lndk/tls-key.pem USER lndk -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/lndk-org/lndk/releases/download/v0.2.0/lndk-installer.sh | sh +RUN curl --proto '=https' --tlsv1.2 -LsSf $INSTALLER_DOWNLOAD_URL/lndk-installer.sh | sh RUN echo 'source /home/lndk/.cargo/env' >> $HOME/.bashrc WORKDIR /home/lndk EXPOSE 7000 diff --git a/fragments/wallet.js b/fragments/wallet.js index 7fe3fc492..e20d745b6 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -168,6 +168,9 @@ export const WALLET_FIELDS = gql` apiKeyRecv currencyRecv } + ... on WalletBolt12 { + offer + } } } ` diff --git a/lib/bech32b12.js b/lib/bech32b12.js new file mode 100644 index 000000000..611515e06 --- /dev/null +++ b/lib/bech32b12.js @@ -0,0 +1,46 @@ +const ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' + +export function decode (str) { + const b5s = [] + for (const char of str) { + const i = ALPHABET.indexOf(char) + if (i === -1) throw new Error('Invalid bech32 character') + b5s.push(i) + } + const b8s = Buffer.from(converBits(b5s, 5, 8, false)) + return b8s +} + +export function encode (b8s) { + const b5s = converBits(b8s, 8, 5, true) + const str = [] + for (const b5 of b5s) str.push(ALPHABET[b5]) + return str.join('') +} + +function converBits (data, frombits, tobits, pad) { + let acc = 0 + let bits = 0 + const ret = [] + const maxv = (1 << tobits) - 1 + for (let p = 0; p < data.length; ++p) { + const value = data[p] + if (value < 0 || (value >> frombits) !== 0) { + throw new RangeError('input value is outside of range') + } + acc = (acc << frombits) | value + bits += frombits + while (bits >= tobits) { + bits -= tobits + ret.push((acc >> bits) & maxv) + } + } + if (pad) { + if (bits > 0) { + ret.push((acc << (tobits - bits)) & maxv) + } + } else if (bits >= frombits || ((acc << (tobits - bits)) & maxv)) { + throw new RangeError('could not convert bits') + } + return ret +} diff --git a/lib/invoices.js b/lib/invoices.js new file mode 100644 index 000000000..fd58ff7f4 --- /dev/null +++ b/lib/invoices.js @@ -0,0 +1,78 @@ +/* eslint-disable camelcase */ + +import { payViaPaymentRequest, parsePaymentRequest } from 'ln-service' +import { estimateRouteFee } from '@/api/lnd' + +import { payViaBolt12PaymentRequest, parseBolt12Request, estimateBolt12RouteFee } from '@/lib/lndk' + +export function isBolt11 (request) { + return request.startsWith('lnbc') || request.startsWith('lntb') || request.startsWith('lntbs') || request.startsWith('lnbcrt') +} + +export function parseBolt11 ({ request }) { + if (!isBolt11(request)) { + throw new Error('Not a bolt11 invoice') + } + return parsePaymentRequest({ request }) +} + +export function payBolt11 ({ lnd, request, max_fee, ...args }) { + if (!isBolt11(request)) { + throw new Error('Not a bolt11 invoice') + } + return payViaPaymentRequest({ + lnd, + request, + max_fee, + ...args + }) +} + +export function isBolt12Offer (invoice) { + return invoice.startsWith('lno1') +} + +export function isBolt12Invoice (invoice) { + console.log('isBolt12Invoice', invoice) + console.trace() + return invoice.startsWith('lni1') +} + +export async function payBolt12 ({ lnd, request: invoice, max_fee }) { + if (!isBolt12Invoice(invoice)) { + throw new Error('Not a bolt12 invoice') + } + if (!invoice) throw new Error('No invoice in bolt12, please use prefetchBolt12Invoice') + return await payViaBolt12PaymentRequest({ lnd, request: invoice, max_fee }) +} + +export function parseBolt12 ({ lnd, request: invoice }) { + if (!isBolt12Invoice(invoice)) { + throw new Error('Not a bolt12 request') + } + return parseBolt12Request({ lnd, request: invoice }) +} + +export async function payInvoice ({ lnd, request: invoice, max_fee, ...args }) { + if (isBolt12Invoice(invoice)) { + return await payBolt12({ lnd, request: invoice, max_fee, ...args }) + } else { + return await payBolt11({ lnd, request: invoice, max_fee, ...args }) + } +} + +export async function parseInvoice ({ lnd, request }) { + if (isBolt12Invoice(request)) { + return await parseBolt12({ lnd, request }) + } else { + return await parseBolt11({ request }) + } +} + +export async function estimateFees ({ lnd, destination, tokens, mtokens, request, timeout }) { + if (isBolt12Invoice(request)) { + return await estimateBolt12RouteFee({ lnd, destination, tokens, mtokens, request, timeout }) + } else { + return await estimateRouteFee({ lnd, destination, tokens, request, mtokens, timeout }) + } +} diff --git a/lib/lndk.js b/lib/lndk.js new file mode 100644 index 000000000..fb3502470 --- /dev/null +++ b/lib/lndk.js @@ -0,0 +1,268 @@ +import { msatsToSats, toPositiveNumber } from '@/lib/format' +import { loadPackageDefinition } from '@grpc/grpc-js' +import LNDK_RPC_PROTO from '@/lib/lndkrpc-proto' +import protobuf from 'protobufjs' +import grpcCredentials from 'lightning/lnd_grpc/grpc_credentials' +import { defaultSocket, grpcSslCipherSuites } from 'lightning/grpc/index' +import { fromJSON } from '@grpc/proto-loader' +import * as bech32b12 from '@/lib/bech32b12' + +/* eslint-disable camelcase */ +const { GRPC_SSL_CIPHER_SUITES } = process.env + +export function installLNDK (lnd, { cert, macaroon, socket }, withProxy) { + if (lnd.lndk) return // already installed + + // workaround to load from string + const protoArgs = { + keepCase: true, + longs: Number, + defaults: true, + oneofs: true + } + const proto = protobuf.parse(LNDK_RPC_PROTO, protoArgs).root + const packageDefinition = fromJSON(proto.toJSON(), protoArgs) + + const protoDescriptor = loadPackageDefinition(packageDefinition) + const OffersService = protoDescriptor.lndkrpc.Offers + const { credentials } = grpcCredentials({ cert, macaroon }) + const params = { + 'grpc.max_receive_message_length': -1, + 'grpc.max_send_message_length': -1, + 'grpc.enable_http_proxy': withProxy ? 1 : 0 + } + const lndSocket = socket || defaultSocket + + if (!!cert && GRPC_SSL_CIPHER_SUITES !== grpcSslCipherSuites) { + process.env.GRPC_SSL_CIPHER_SUITES = grpcSslCipherSuites + } + + const client = new OffersService(lndSocket, credentials, params) + lnd.lndk = client +} + +export async function fetchBolt12InvoiceFromOffer ({ lnd, offer, amount, description, timeout = 10_000 }) { + const lndk = lnd.lndk + if (!lndk) throw new Error('lndk not installed, please use installLNDK') + return new Promise((resolve, reject) => { + lndk.GetInvoice({ + offer, + amount: toPositiveNumber(amount), + payer_note: description, + response_invoice_timeout: timeout + }, (error, response) => { + if (error) return reject(error) + const bech32invoice = 'lni1' + bech32b12.encode(Buffer.from(response.invoice_hex_str, 'hex')) + resolve(bech32invoice) + }) + }) +} + +export async function payViaBolt12PaymentRequest ({ + lnd, + request: invoice_hex_str, + max_fee +}) { + const lndk = lnd.lndk + if (!lndk) throw new Error('lndk not installed, please use installLNDK') + + const bolt12 = await parseBolt12Request({ lnd, request: invoice_hex_str }) + + const req = { + invoice: bolt12.payment, + amount: toPositiveNumber(bolt12.mtokens), + max_fee + } + + return new Promise((resolve, reject) => { + lndk.PayInvoice(req, (error, response) => { + if (error) { + return reject(error) + } + resolve({ + secret: response.payment_preimage + }) + }) + }) +} + +const featureBitMap = { + 0: { bit: 0, type: 'DATALOSS_PROTECT_REQ', is_required: true }, + 1: { bit: 1, type: 'DATALOSS_PROTECT_OPT', is_required: false }, + 3: { bit: 3, type: 'INITIAL_ROUTING_SYNC', is_required: true }, + 4: { bit: 4, type: 'UPFRONT_SHUTDOWN_SCRIPT_REQ', is_required: true }, + 5: { bit: 5, type: 'UPFRONT_SHUTDOWN_SCRIPT_OPT', is_required: false }, + 6: { bit: 6, type: 'GOSSIP_QUERIES_REQ', is_required: true }, + 7: { bit: 7, type: 'GOSSIP_QUERIES_OPT', is_required: false }, + 8: { bit: 8, type: 'TLV_ONION_REQ', is_required: true }, + 9: { bit: 9, type: 'TLV_ONION_OPT', is_required: false }, + 10: { bit: 10, type: 'EXT_GOSSIP_QUERIES_REQ', is_required: true }, + 11: { bit: 11, type: 'EXT_GOSSIP_QUERIES_OPT', is_required: false }, + 12: { bit: 12, type: 'STATIC_REMOTE_KEY_REQ', is_required: true }, + 13: { bit: 13, type: 'STATIC_REMOTE_KEY_OPT', is_required: false }, + 14: { bit: 14, type: 'PAYMENT_ADDR_REQ', is_required: true }, + 15: { bit: 15, type: 'PAYMENT_ADDR_OPT', is_required: false }, + 16: { bit: 16, type: 'MPP_REQ', is_required: true }, + 17: { bit: 17, type: 'MPP_OPT', is_required: false }, + 18: { bit: 18, type: 'WUMBO_CHANNELS_REQ', is_required: true }, + 19: { bit: 19, type: 'WUMBO_CHANNELS_OPT', is_required: false }, + 20: { bit: 20, type: 'ANCHORS_REQ', is_required: true }, + 21: { bit: 21, type: 'ANCHORS_OPT', is_required: false }, + 22: { bit: 22, type: 'ANCHORS_ZERO_FEE_HTLC_REQ', is_required: true }, + 23: { bit: 23, type: 'ANCHORS_ZERO_FEE_HTLC_OPT', is_required: false }, + 24: { bit: 24, type: 'ROUTE_BLINDING_REQUIRED', is_required: true }, + 25: { bit: 25, type: 'ROUTE_BLINDING_OPTIONAL', is_required: false }, + 30: { bit: 30, type: 'AMP_REQ', is_required: true }, + 31: { bit: 31, type: 'AMP_OPT', is_required: false } +} + +const chainsMap = { + '06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f': 'regtest', + '43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000': 'testnet', + '6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000': 'mainnet' +} +// @returns +// { +// [chain_addresses]: [] +// cltv_delta: +// created_at: +// [description]: +// [description_hash]: +// destination: +// expires_at: +// features: [{ +// bit: +// is_required: +// type: +// }] +// id: +// is_expired: +// [metadata]: +// [mtokens]: (can exceed Number limit) +// network: +// [payment]: +// [routes]: [[{ +// [base_fee_mtokens]: +// [channel]: +// [cltv_delta]: +// [fee_rate]: +// public_key: +// }]] +// [safe_tokens]: +// [tokens]: (note: can differ from mtokens) +// } +export async function parseBolt12Request ({ + lnd, + request +}) { + const lndk = lnd?.lndk + if (!lndk) throw new Error('lndk not installed, please use installLNDK') + + const invoice_hex_str = request.startsWith('lni1') ? bech32b12.decode(request.slice(4)).toString('hex') : request + + const invoice_contents = await new Promise((resolve, reject) => { + lndk.DecodeInvoice({ + invoice: invoice_hex_str + }, (error, response) => { + if (error) return reject(error) + resolve(response) + }) + }) + + const { + amount_msats, + description, + node_id, + chain, + payment_hash, + created_at, + relative_expiry, + features + } = invoice_contents + + // convert from lndk response to ln-service parsePaymentRequest output layout + let minCltvDelta + for (const path of invoice_contents.payment_paths) { + const info = path.blinded_pay_info + if (minCltvDelta === undefined || info.cltv_expiry_delta < minCltvDelta) { + minCltvDelta = info.cltv_expiry_delta + } + } + + const out = { + created_at: new Date(created_at * 1000).toISOString(), + // [chain_addresses] + cltv_delta: minCltvDelta, + description, + // [description_hash] + destination: Buffer.from(node_id.key).toString('hex'), + expires_at: new Date((created_at + relative_expiry) * 1000).toISOString(), + features: features.map(bit => featureBitMap[bit]), + id: Buffer.from(payment_hash.hash).toString('hex'), + is_expired: new Date().getTime() / 1000 > created_at + relative_expiry, + // [metadata] + mtokens: '' + amount_msats, + network: chainsMap[chain], + payment: invoice_hex_str, + routes: invoice_contents.payment_paths.map((path) => { + const info = path.blinded_pay_info + const { introduction_node } = path.blinded_path + return { + base_fee_mtokens: '' + info.fee_base_msat, + cltv_delta: info.cltv_expiry_delta, + public_key: Buffer.from(introduction_node.node_id.key).toString('hex') + } + }), + safe_tokens: Math.round(toPositiveNumber(BigInt(amount_msats)) / 1000), + tokens: Math.floor(toPositiveNumber(BigInt(amount_msats)) / 1000) + } + + // mark as bolt12 invoice so we can differentiate it later (this will be used also to pass bolt12 only data) + out.bolt12 = invoice_contents + + return out +} + +export async function estimateBolt12RouteFee ({ lnd, destination, tokens, mtokens, request, timeout }) { + const lndk = lnd?.lndk + if (!lndk) throw new Error('lndk not installed, please use installLNDK') + const parsedInvoice = request ? await parseBolt12Request({ lnd, request }) : {} + mtokens ??= parsedInvoice.mtokens + destination ??= parsedInvoice.destination + + return await new Promise((resolve, reject) => { + const params = {} + params.dest = Buffer.from(destination, 'hex') + params.amt_sat = null + if (tokens) params.amt_sat = toPositiveNumber(tokens) + else if (mtokens) params.amt_sat = msatsToSats(mtokens) + + if (params.amt_sat === null) { + throw new Error('No tokens or mtokens provided') + } + + lnd.router.estimateRouteFee({ + ...params, + timeout + }, (err, res) => { + if (err) { + if (res?.failure_reason) { + reject(new Error(`Unable to estimate route: ${res.failure_reason}`)) + } else { + reject(err) + } + return + } + + if (res.routing_fee_msat < 0 || res.time_lock_delay <= 0) { + reject(new Error('Unable to estimate route, excessive values: ' + JSON.stringify(res))) + return + } + + resolve({ + routingFeeMsat: toPositiveNumber(res.routing_fee_msat), + timeLockDelay: toPositiveNumber(res.time_lock_delay) + }) + }) + }) +} diff --git a/lib/lndkrpc-proto.js b/lib/lndkrpc-proto.js new file mode 100644 index 000000000..d05fe843a --- /dev/null +++ b/lib/lndkrpc-proto.js @@ -0,0 +1,140 @@ +export default ` +syntax = "proto3"; +package lndkrpc; + +service Offers { + rpc PayOffer (PayOfferRequest) returns (PayOfferResponse); + rpc GetInvoice (GetInvoiceRequest) returns (GetInvoiceResponse); + rpc DecodeInvoice (DecodeInvoiceRequest) returns (Bolt12InvoiceContents); + rpc PayInvoice (PayInvoiceRequest) returns (PayInvoiceResponse); +} + +message PayOfferRequest { + string offer = 1; + optional uint64 amount = 2; + optional string payer_note = 3; + optional uint32 response_invoice_timeout = 4; +} + +message PayOfferResponse { + string payment_preimage = 2; +} + +message GetInvoiceRequest { + string offer = 1; + optional uint64 amount = 2; + optional string payer_note = 3; + optional uint32 response_invoice_timeout = 4; +} + +message DecodeInvoiceRequest { + string invoice = 1; +} + +message GetInvoiceResponse { + string invoice_hex_str = 1; + Bolt12InvoiceContents invoice_contents = 2; +} + +message PayInvoiceRequest { + string invoice = 1; + optional uint64 amount = 2; +} + +message PayInvoiceResponse { + string payment_preimage = 1; +} + +message Bolt12InvoiceContents { + string chain = 1; + optional uint64 quantity = 2; + uint64 amount_msats = 3; + optional string description = 4; + PaymentHash payment_hash = 5; + repeated PaymentPaths payment_paths = 6; + int64 created_at = 7; + uint64 relative_expiry = 8; + PublicKey node_id = 9; + string signature = 10; + repeated FeatureBit features = 11; + optional string payer_note = 12; +} + +message PaymentHash { + bytes hash = 1; +} + +message PublicKey { + bytes key = 1; +} + +message BlindedPayInfo { + uint32 fee_base_msat = 1; + uint32 fee_proportional_millionths = 2; + uint32 cltv_expiry_delta = 3; + uint64 htlc_minimum_msat = 4; + uint64 htlc_maximum_msat = 5; + repeated FeatureBit features = 6; +} + +message BlindedHop { + PublicKey blinded_node_id = 1; + bytes encrypted_payload = 2; +} + +message BlindedPath { + IntroductionNode introduction_node = 1; + PublicKey blinding_point = 2; + repeated BlindedHop blinded_hops = 3; +} + +message PaymentPaths { + BlindedPayInfo blinded_pay_info = 1; + BlindedPath blinded_path = 2; +} + +message IntroductionNode { + optional PublicKey node_id = 1; + optional DirectedShortChannelId directed_short_channel_id = 2; +} + +message DirectedShortChannelId { + Direction direction = 1; + uint64 scid = 2; +} + +enum Direction { + NODE_ONE = 0; + NODE_TWO = 1; +} + +enum FeatureBit { + DATALOSS_PROTECT_REQ = 0; + DATALOSS_PROTECT_OPT = 1; + INITIAL_ROUING_SYNC = 3; + UPFRONT_SHUTDOWN_SCRIPT_REQ = 4; + UPFRONT_SHUTDOWN_SCRIPT_OPT = 5; + GOSSIP_QUERIES_REQ = 6; + GOSSIP_QUERIES_OPT = 7; + TLV_ONION_REQ = 8; + TLV_ONION_OPT = 9; + EXT_GOSSIP_QUERIES_REQ = 10; + EXT_GOSSIP_QUERIES_OPT = 11; + STATIC_REMOTE_KEY_REQ = 12; + STATIC_REMOTE_KEY_OPT = 13; + PAYMENT_ADDR_REQ = 14; + PAYMENT_ADDR_OPT = 15; + MPP_REQ = 16; + MPP_OPT = 17; + WUMBO_CHANNELS_REQ = 18; + WUMBO_CHANNELS_OPT = 19; + ANCHORS_REQ = 20; + ANCHORS_OPT = 21; + ANCHORS_ZERO_FEE_HTLC_REQ = 22; + ANCHORS_ZERO_FEE_HTLC_OPT = 23; + ROUTE_BLINDING_REQUIRED = 24; + ROUTE_BLINDING_OPTIONAL = 25; + AMP_REQ = 30; + AMP_OPT = 31; +} +` diff --git a/pages/api/bolt12.js b/pages/api/bolt12.js new file mode 100644 index 000000000..42283059a --- /dev/null +++ b/pages/api/bolt12.js @@ -0,0 +1,28 @@ +import lnd from '@/api/lnd' +import { fetchBolt12InvoiceFromOffer, payViaBolt12PaymentRequest, parseBolt12Request } from '@/lib/lndk' +import * as bech32b12 from '@/lib/bech32b12' +import { setOfferMeta } from '@/lib/bolt12' + +// import { decodeTlvStream, encodeTlvStream, decodeTlvRecord } from 'bolt01' +export default async function Bolt12 ({ query: { method, args } }, res) { + args = JSON.parse(args ?? '{}') + if (method === 'getBolt12InvoiceFromOffer') { + const { offer, amount, description } = args + const invoice = await fetchBolt12InvoiceFromOffer({ lnd, offer, amount, description }) + return res.status(200).json({ invoice }) + } else if (method === 'payViaBolt12PaymentRequest') { + const { request } = args + const secret = await payViaBolt12PaymentRequest({ lnd, request }) + return res.status(200).json({ secret }) + } else if (method === 'parseBolt12Request') { + const { request } = args + const parsed = await parseBolt12Request({ lnd, request }) + return res.status(200).json({ parsed }) + } else { + const input = 'lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrc2qe3x7mr5xyepvggzy6x8fnq8sdcyzyc5wjypl975jacxhzdzn7fe24w6d5y5kedattcq' + const invoice = await fetchBolt12InvoiceFromOffer({ lnd, offer: input, amount: 10000, description: 'aaa' }) + const bech32invoice = 'lni1' + bech32b12.encode(Buffer.from(invoice, 'hex')) + const newOffer = setOfferMeta(input, { invoice }) + res.status(200).json({ input, bech32invoice, invoice, newOffer }) + } +} diff --git a/prisma/migrations/20241212160430_bolt12_attachment/migration.sql b/prisma/migrations/20241212160430_bolt12_attachment/migration.sql new file mode 100644 index 000000000..cb718f171 --- /dev/null +++ b/prisma/migrations/20241212160430_bolt12_attachment/migration.sql @@ -0,0 +1,25 @@ +-- AlterEnum +ALTER TYPE "WalletType" ADD VALUE 'BOLT12'; + +-- CreateTable +CREATE TABLE "WalletBolt12" ( + "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, + "offer" TEXT, + + CONSTRAINT "WalletBolt12_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletBolt12_walletId_key" ON "WalletBolt12"("walletId"); + +-- AddForeignKey +ALTER TABLE "WalletBolt12" ADD CONSTRAINT "WalletBolt12_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Update wallet json +CREATE TRIGGER wallet_blink_as_jsonb +AFTER INSERT OR UPDATE ON "WalletBolt12" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65b6b7a36..5ef56cd61 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -189,6 +189,7 @@ enum WalletType { BLINK LNC WEBLN + BOLT12 } model Wallet { @@ -216,6 +217,7 @@ model Wallet { walletNWC WalletNWC? walletPhoenixd WalletPhoenixd? walletBlink WalletBlink? + walletBolt12 WalletBolt12? vaultEntries VaultEntry[] @relation("VaultEntries") withdrawals Withdrawl[] @@ -325,6 +327,15 @@ model WalletPhoenixd { secondaryPassword String? } +model WalletBolt12 { + 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") + offer String? +} + model Mute { muterId Int mutedId Int diff --git a/wallets/bolt12/client.js b/wallets/bolt12/client.js new file mode 100644 index 000000000..5d7374099 --- /dev/null +++ b/wallets/bolt12/client.js @@ -0,0 +1 @@ +export * from '@/wallets/bolt12' diff --git a/wallets/bolt12/index.js b/wallets/bolt12/index.js new file mode 100644 index 000000000..8cee7f8ad --- /dev/null +++ b/wallets/bolt12/index.js @@ -0,0 +1,23 @@ +import { string } from '@/lib/yup' + +export const name = 'bolt12' +export const walletType = 'BOLT12' +export const walletField = 'walletBolt12' + +export const fields = [ + { + name: 'offer', + label: 'bolt12 offer', + type: 'text', + placeholder: 'lno....', + hint: 'bolt 12 offer', + clear: true, + serverOnly: true, + validate: string() + } +] + +export const card = { + title: 'Bolt12', + subtitle: 'bolt12' +} diff --git a/wallets/bolt12/server.js b/wallets/bolt12/server.js new file mode 100644 index 000000000..469cf0128 --- /dev/null +++ b/wallets/bolt12/server.js @@ -0,0 +1,11 @@ +import { withTimeout } from '@/lib/time' +export * from '@/wallets/bolt12' + +export async function testCreateInvoice ({ offer }) { + const timeout = 15_000 + return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { offer }), timeout) +} + +export async function createInvoice ({ msats, description, expiry }, { offer }) { + return offer +} diff --git a/wallets/client.js b/wallets/client.js index 8bd44698f..c6b67d5fc 100644 --- a/wallets/client.js +++ b/wallets/client.js @@ -7,5 +7,6 @@ import * as lnd from '@/wallets/lnd/client' import * as webln from '@/wallets/webln/client' import * as blink from '@/wallets/blink/client' import * as phoenixd from '@/wallets/phoenixd/client' +import * as bolt12 from '@/wallets/bolt12/client' -export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd] +export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd, bolt12] diff --git a/wallets/server.js b/wallets/server.js index c329d7670..207020b1e 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -6,6 +6,7 @@ import * as lnbits from '@/wallets/lnbits/server' import * as nwc from '@/wallets/nwc/server' import * as phoenixd from '@/wallets/phoenixd/server' import * as blink from '@/wallets/blink/server' +import * as bolt12 from '@/wallets/bolt12/server' // we import only the metadata of client side wallets import * as lnc from '@/wallets/lnc' @@ -13,18 +14,40 @@ import * as webln from '@/wallets/webln' import { walletLogger } from '@/api/resolvers/wallet' import walletDefs from '@/wallets/server' -import { parsePaymentRequest } from 'ln-service' +import { isBolt12Offer, parseInvoice } from '@/lib/invoices' import { toPositiveBigInt, toPositiveNumber, formatMsats, formatSats, msatsToSats } from '@/lib/format' import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { withTimeout } from '@/lib/time' import { canReceive } from './common' import wrapInvoice from './wrap' +import { fetchBolt12InvoiceFromOffer } from '@/lib/lndk' -export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] +export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln, bolt12] const MAX_PENDING_INVOICES_PER_WALLET = 25 -export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) { +async function checkInvoice (invoice, { msats }, { lnd, logger }) { + const parsedInvoice = await parseInvoice({ lnd, request: invoice }) + console.log('parsedInvoice', parsedInvoice) + logger.info(`created invoice for ${formatSats(msatsToSats(parsedInvoice.mtokens))}`, { + bolt11: invoice + }) + if (BigInt(parsedInvoice.mtokens) !== BigInt(msats)) { + if (BigInt(parsedInvoice.mtokens) > BigInt(msats)) { + throw new Error('invoice invalid: amount too big') + } + if (BigInt(parsedInvoice.mtokens) === 0n) { + throw new Error('invoice invalid: amount is 0 msats') + } + if (BigInt(msats) - BigInt(parsedInvoice.mtokens) >= 1000n) { + throw new Error('invoice invalid: amount too small') + } + + logger.warn('wallet does not support msats') + } +} + +export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models, lnd }) { // get the wallets in order of priority const wallets = await getInvoiceableWallets(userId, { models }) @@ -45,29 +68,13 @@ export async function createInvoice (userId, { msats, description, descriptionHa invoice = await walletCreateInvoice( { wallet, def }, { msats, description, descriptionHash, expiry }, - { logger, models }) + { logger, models, lnd }) } catch (err) { throw new Error('failed to create invoice: ' + err.message) } - const bolt11 = await parsePaymentRequest({ request: invoice }) - - logger.info(`created invoice for ${formatSats(msatsToSats(bolt11.mtokens))}`, { - bolt11: invoice - }) - - if (BigInt(bolt11.mtokens) !== BigInt(msats)) { - if (BigInt(bolt11.mtokens) > BigInt(msats)) { - throw new Error('invoice invalid: amount too big') - } - if (BigInt(bolt11.mtokens) === 0n) { - throw new Error('invoice invalid: amount is 0 msats') - } - if (BigInt(msats) - BigInt(bolt11.mtokens) >= 1000n) { - throw new Error('invoice invalid: amount too small') - } - - logger.warn('wallet does not support msats') + if (!isBolt12Offer(invoice)) { + checkInvoice(invoice, { msats }, { lnd, logger }) } return { invoice, wallet, logger } @@ -84,19 +91,25 @@ export async function createWrappedInvoice (userId, { models, me, lnd }) { let logger, bolt11 try { - const { invoice, wallet } = await createInvoice(userId, { + const innerAmount = toPositiveBigInt(msats) * (100n - feePercent) / 100n + let { invoice, wallet } = await createInvoice(userId, { // this is the amount the stacker will receive, the other (feePercent)% is our fee - msats: toPositiveBigInt(msats) * (100n - feePercent) / 100n, + msats: innerAmount, description, descriptionHash, expiry - }, { models }) + }, { models, lnd }) logger = walletLogger({ wallet, models }) - bolt11 = invoice + + // We need a bolt12 invoice to wrap, so we fetch one + if (isBolt12Offer(invoice)) { + invoice = await fetchBolt12InvoiceFromOffer({ lnd, offer: invoice, amount: innerAmount, description }) + checkInvoice(invoice, { msats: innerAmount }, { lnd, logger }) + } const { invoice: wrappedInvoice, maxFee } = - await wrapInvoice({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd }) + await wrapInvoice({ bolt11: invoice, feePercent }, { msats, description, descriptionHash }, { me, lnd }) return { invoice, @@ -136,7 +149,7 @@ async function walletCreateInvoice ({ wallet, def }, { description, descriptionHash, expiry = 360 -}, { logger, models }) { +}, { logger, models, lnd }) { // check for pending withdrawals const pendingWithdrawals = await models.withdrawl.count({ where: { @@ -171,6 +184,6 @@ async function walletCreateInvoice ({ wallet, def }, { expiry }, wallet.wallet, - { logger } + { logger, lnd } ), 10_000) } diff --git a/wallets/wrap.js b/wallets/wrap.js index 26f83ca53..82d05d786 100644 --- a/wallets/wrap.js +++ b/wallets/wrap.js @@ -1,6 +1,7 @@ -import { createHodlInvoice, parsePaymentRequest } from 'ln-service' -import { estimateRouteFee, getBlockHeight } from '../api/lnd' +import { createHodlInvoice } from 'ln-service' +import { getBlockHeight } from '../api/lnd' import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/format' +import { parseInvoice, estimateFees } from '@/lib/invoices' const MIN_OUTGOING_MSATS = BigInt(900) // the minimum msats we'll allow for the outgoing invoice const MAX_OUTGOING_MSATS = BigInt(900_000_000) // the maximum msats we'll allow for the outgoing invoice @@ -37,7 +38,7 @@ export default async function wrapInvoice ({ bolt11, feePercent }, { msats, desc let outgoingMsat // decode the invoice - const inv = await parsePaymentRequest({ request: bolt11 }) + const inv = await parseInvoice({ lnd, request: bolt11 }) if (!inv) { throw new Error('Unable to decode invoice') } @@ -147,7 +148,7 @@ export default async function wrapInvoice ({ bolt11, feePercent }, { msats, desc // get routing estimates const { routingFeeMsat, timeLockDelay } = - await estimateRouteFee({ + await estimateFees({ lnd, destination: inv.destination, mtokens: inv.mtokens, diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js index c2105d021..deb0954d7 100644 --- a/worker/autowithdraw.js +++ b/worker/autowithdraw.js @@ -42,7 +42,7 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) { if (pendingOrFailed.exists) return - const { invoice, wallet, logger } = await createInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 }, { models }) + const { invoice, wallet, logger } = await createInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 }, { models, lnd }) try { return await createWithdrawal(null, diff --git a/worker/index.js b/worker/index.js index 5543e289c..146049448 100644 --- a/worker/index.js +++ b/worker/index.js @@ -17,6 +17,7 @@ import { computeStreaks, checkStreak } from './streak' import { nip57 } from './nostr' import fetch from 'cross-fetch' import { authenticatedLndGrpc } from '@/lib/lnd' +import { installLNDK } from '@/lib/lndk' import { views, rankViews } from './views' import { imgproxy } from './imgproxy' import { deleteItem } from './ephemeralItems' @@ -67,6 +68,11 @@ async function work () { macaroon: process.env.LND_MACAROON, socket: process.env.LND_SOCKET }) + installLNDK(lnd, { + cert: process.env.LNDK_CERT, + macaroon: process.env.LNDK_MACAROON, + socket: process.env.LNDK_SOCKET + }) const args = { boss, models, apollo, lnd } diff --git a/worker/paidAction.js b/worker/paidAction.js index 70396ddbf..e6d0b1d4c 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -7,11 +7,11 @@ import { datePivot } from '@/lib/time' import { Prisma } from '@prisma/client' import { cancelHodlInvoice, - getInvoice, parsePaymentRequest, - payViaPaymentRequest, settleHodlInvoice + getInvoice, + settleHodlInvoice } from 'ln-service' +import { payInvoice, parseInvoice } from '@/lib/invoices' import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap' - // aggressive finalization retry options const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 } @@ -211,7 +211,7 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode const { expiryHeight, acceptHeight } = hodlInvoiceCltvDetails(lndInvoice) const { bolt11, maxFeeMsats } = invoiceForward - const invoice = await parsePaymentRequest({ request: bolt11 }) + const invoice = await parseInvoice({ request: bolt11, lnd }) // maxTimeoutDelta is the number of blocks left for the outgoing payment to settle const maxTimeoutDelta = toPositiveNumber(expiryHeight) - toPositiveNumber(acceptHeight) - MIN_SETTLEMENT_CLTV_DELTA if (maxTimeoutDelta - toPositiveNumber(invoice.cltv_delta) < 0) { @@ -265,7 +265,7 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode console.log('forwarding with max fee', maxFeeMsats, 'max_timeout_height', maxTimeoutHeight, 'accept_height', acceptHeight, 'expiry_height', expiryHeight) - payViaPaymentRequest({ + payInvoice({ lnd, request: bolt11, max_fee_mtokens: String(maxFeeMsats), @@ -446,7 +446,7 @@ export async function paidActionCanceling ({ data: { invoiceId, ...args }, model if (transitionedInvoice.invoiceForward) { const { wallet, bolt11 } = transitionedInvoice.invoiceForward const logger = walletLogger({ wallet, models }) - const decoded = await parsePaymentRequest({ request: bolt11 }) + const decoded = await parseInvoice({ request: bolt11, lnd }) logger.info(`invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { bolt11 }) } }