diff --git a/.env.development b/.env.development
index 419252140..0b80c9760 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/lib/bolt/bolt11.js b/api/lib/bolt/bolt11.js
new file mode 100644
index 000000000..a8a7cc0da
--- /dev/null
+++ b/api/lib/bolt/bolt11.js
@@ -0,0 +1,28 @@
+/* eslint-disable camelcase */
+import { payViaPaymentRequest, parsePaymentRequest } from 'ln-service'
+import { isBolt11 } from '@/lib/bolt/bolt11-tags'
+import { estimateRouteFee } from '@/api/lnd'
+export { isBolt11 }
+
+export async function parseBolt11 ({ request }) {
+ if (!isBolt11(request)) throw new Error('not a bolt11 invoice')
+ return parsePaymentRequest({ request })
+}
+
+export async function payBolt11 ({ lnd, request, max_fee, max_fee_mtokens, ...args }) {
+ if (!lnd) throw new Error('lnd required') // check if forgot to pass lnd
+ if (!isBolt11(request)) throw new Error('not a bolt11 invoice')
+ return payViaPaymentRequest({
+ lnd,
+ request,
+ max_fee,
+ max_fee_mtokens,
+ ...args
+ })
+}
+
+export async function estimateBolt11RouteFee ({ lnd, destination, tokens, mtokens, request, timeout }) {
+ if (!lnd) throw new Error('lnd required') // check if forgot to pass lnd
+ if (request && !isBolt11(request)) throw new Error('not a bolt11 request')
+ return await estimateRouteFee({ lnd, destination, tokens, mtokens, request, timeout })
+}
diff --git a/api/lib/bolt/bolt12.js b/api/lib/bolt/bolt12.js
new file mode 100644
index 000000000..a8771f2bd
--- /dev/null
+++ b/api/lib/bolt/bolt12.js
@@ -0,0 +1,132 @@
+/* eslint-disable camelcase */
+
+import { payViaBolt12PaymentRequest, decodeBolt12Invoice } from '@/api/lib/lndk'
+import { isBolt12Invoice, isBolt12Offer, isBolt12 } from '@/lib/bolt/bolt12-info'
+import { toPositiveNumber } from '@/lib/format'
+import { estimateRouteFee } from '@/api/lnd'
+export { isBolt12Invoice, isBolt12Offer, isBolt12 }
+
+export async function payBolt12 ({ lndk, request: invoice, max_fee, max_fee_mtokens }) {
+ if (!lndk) throw new Error('lndk required') // check if forgot to pass lndk
+ if (!isBolt12Invoice(invoice)) throw new Error('not a bolt12 invoice')
+ return await payViaBolt12PaymentRequest({ lndk, request: invoice, max_fee, max_fee_mtokens })
+}
+
+export async function parseBolt12 ({ lndk, request: invoice }) {
+ if (!lndk) throw new Error('lndk required') // check if forgot to pass lndk
+ if (!isBolt12Invoice(invoice)) throw new Error('not a bolt12 request')
+ const decodedInvoice = await decodeBolt12Invoice({ lndk, request: invoice })
+ return convertBolt12RequestToLNRequest(decodedInvoice)
+}
+
+export async function estimateBolt12RouteFee ({ lnd, lndk, destination, tokens, mtokens, request, timeout }) {
+ if (!lndk) throw new Error('lndk required') // check if forgot to pass lndk
+ if (!lnd) throw new Error('lnd required') // check if forgot to pass lnd
+ if (request && !isBolt12Invoice(request)) throw new Error('not a bolt12 request')
+
+ const { amount_msats, node_id } = request ? await decodeBolt12Invoice({ lndk, request }) : {}
+
+ // extract mtokens and destination from invoice if they are not provided
+ if (!tokens && !mtokens) mtokens = toPositiveNumber(amount_msats)
+ destination ??= Buffer.from(node_id.key).toString('hex')
+
+ if (!destination) throw new Error('no destination provided')
+ if (!tokens && !mtokens) throw new Error('no tokens amount provided')
+
+ return await estimateRouteFee({ lnd, destination, tokens, mtokens, timeout })
+}
+
+const featureBitTypes = {
+ 0: 'DATALOSS_PROTECT_REQ',
+ 1: 'DATALOSS_PROTECT_OPT',
+ 3: 'INITIAL_ROUTING_SYNC',
+ 4: 'UPFRONT_SHUTDOWN_SCRIPT_REQ',
+ 5: 'UPFRONT_SHUTDOWN_SCRIPT_OPT',
+ 6: 'GOSSIP_QUERIES_REQ',
+ 7: 'GOSSIP_QUERIES_OPT',
+ 8: 'TLV_ONION_REQ',
+ 9: 'TLV_ONION_OPT',
+ 10: 'EXT_GOSSIP_QUERIES_REQ',
+ 11: 'EXT_GOSSIP_QUERIES_OPT',
+ 12: 'STATIC_REMOTE_KEY_REQ',
+ 13: 'STATIC_REMOTE_KEY_OPT',
+ 14: 'PAYMENT_ADDR_REQ',
+ 15: 'PAYMENT_ADDR_OPT',
+ 16: 'MPP_REQ',
+ 17: 'MPP_OPT',
+ 18: 'WUMBO_CHANNELS_REQ',
+ 19: 'WUMBO_CHANNELS_OPT',
+ 20: 'ANCHORS_REQ',
+ 21: 'ANCHORS_OPT',
+ 22: 'ANCHORS_ZERO_FEE_HTLC_REQ',
+ 23: 'ANCHORS_ZERO_FEE_HTLC_OPT',
+ 24: 'ROUTE_BLINDING_REQUIRED',
+ 25: 'ROUTE_BLINDING_OPTIONAL',
+ 30: 'AMP_REQ',
+ 31: 'AMP_OPT'
+}
+
+const chainsMap = {
+ '06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f': 'regtest',
+ '43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000': 'testnet',
+ '6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000': 'mainnet'
+}
+
+async function convertBolt12RequestToLNRequest (decodedInvoice) {
+ const {
+ amount_msats,
+ description,
+ node_id,
+ chain,
+ payment_hash,
+ created_at,
+ relative_expiry,
+ features,
+ payer_note,
+ payment_paths,
+ invoice_hex_str
+ } = decodedInvoice
+
+ // convert from lndk response to ln-service parsePaymentRequest output layout
+ let minCltvDelta
+ for (const path of 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: payer_note || 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 => ({
+ bit,
+ is_required: (bit % 2) === 0,
+ type: featureBitTypes[bit]
+ })),
+ id: Buffer.from(payment_hash.hash).toString('hex'),
+ is_expired: new Date().getTime() / 1000 > created_at + relative_expiry,
+ // [metadata]
+ mtokens: String(amount_msats),
+ network: chainsMap[chain],
+ payment: invoice_hex_str,
+ routes: payment_paths.map((path) => {
+ const info = path.blinded_pay_info
+ const { introduction_node } = path.blinded_path
+ return {
+ base_fee_mtokens: String(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),
+ bolt12: decodedInvoice
+ }
+ return out
+}
diff --git a/api/lib/bolt/index.js b/api/lib/bolt/index.js
new file mode 100644
index 000000000..425f9a238
--- /dev/null
+++ b/api/lib/bolt/index.js
@@ -0,0 +1,39 @@
+/* eslint-disable camelcase */
+import { payBolt12, parseBolt12, isBolt12Invoice, isBolt12Offer, estimateBolt12RouteFee } from '@/api/lib/bolt/bolt12'
+import { payBolt11, parseBolt11, isBolt11, estimateBolt11RouteFee } from '@/api/lib/bolt/bolt11'
+
+export async function payInvoice ({ lnd, lndk, request: invoice, max_fee, max_fee_mtokens, ...args }) {
+ if (isBolt12Invoice(invoice)) {
+ return await payBolt12({ lndk, request: invoice, max_fee, max_fee_mtokens, ...args })
+ } else if (isBolt11(invoice)) {
+ return await payBolt11({ lnd, request: invoice, max_fee, max_fee_mtokens, ...args })
+ } else if (isBolt12Offer(invoice)) {
+ throw new Error('cannot pay bolt12 offer directly, please fetch a bolt12 invoice from the offer first')
+ } else {
+ throw new Error('unknown invoice type')
+ }
+}
+
+export async function parseInvoice ({ lndk, request }) {
+ if (isBolt12Invoice(request)) {
+ return await parseBolt12({ lndk, request })
+ } else if (isBolt11(request)) {
+ return await parseBolt11({ request })
+ } else if (isBolt12Offer(request)) {
+ throw new Error('bolt12 offer instead of invoice, please fetch a bolt12 invoice from the offer first')
+ } else {
+ throw new Error('unknown invoice type')
+ }
+}
+
+export async function estimateFees ({ lnd, lndk, destination, tokens, mtokens, request, timeout }) {
+ if (isBolt12Invoice(request)) {
+ return await estimateBolt12RouteFee({ lnd, lndk, destination, tokens, mtokens, request, timeout })
+ } else if (isBolt11(request)) {
+ return await estimateBolt11RouteFee({ lnd, destination, tokens, request, mtokens, timeout })
+ } else if (isBolt12Offer(request)) {
+ throw new Error('bolt12 offer instead of invoice, please fetch a bolt12 invoice from the offer first')
+ } else {
+ throw new Error('unknown invoice type')
+ }
+}
diff --git a/api/lib/lndk.js b/api/lib/lndk.js
new file mode 100644
index 000000000..f769d2223
--- /dev/null
+++ b/api/lib/lndk.js
@@ -0,0 +1,115 @@
+import { satsToMsats, toPositiveNumber } from '@/lib/format'
+import { loadPackageDefinition } from '@grpc/grpc-js'
+import LNDK_RPC_PROTO from '@/api/lib/lndkrpc-proto'
+import protobuf from 'protobufjs'
+import grpcCredentials from 'lightning/lnd_grpc/grpc_credentials'
+import { 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 authenticatedLndkGrpc ({ cert, macaroon, socket: lndkSocket }, withProxy) {
+ // 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
+ }
+
+ if (!!cert && GRPC_SSL_CIPHER_SUITES !== grpcSslCipherSuites) {
+ process.env.GRPC_SSL_CIPHER_SUITES = grpcSslCipherSuites
+ }
+
+ const client = new OffersService(lndkSocket, credentials, params)
+ return client
+}
+
+export async function decodeBolt12Invoice ({
+ lndk,
+ request
+}) {
+ // decode bech32 bolt12 invoice to hex string
+ if (!request.startsWith('lni1')) throw new Error('not a valid bech32 encoded bolt12 invoice')
+ const invoice_hex_str = bech32b12.decode(request.slice(4)).toString('hex')
+
+ const decodedRequest = await new Promise((resolve, reject) => {
+ lndk.DecodeInvoice({
+ invoice: invoice_hex_str
+ }, (error, response) => {
+ if (error) return reject(error)
+ resolve(response)
+ })
+ })
+
+ return { ...decodedRequest, invoice_hex_str }
+}
+
+export async function fetchBolt12InvoiceFromOffer ({ lndk, offer, msats, description, timeout = 10_000 }) {
+ return new Promise((resolve, reject) => {
+ lndk.GetInvoice({
+ offer,
+ // expects msats https://github.com/lndk-org/lndk/blob/bce93885f5fc97f3ceb15dc470117e10061de018/src/lndk_offers.rs#L182
+ amount: toPositiveNumber(msats),
+ payer_note: description,
+ response_invoice_timeout: timeout
+ }, async (error, response) => {
+ try {
+ if (error) return reject(error)
+ // encode hex string invoice to bech32
+ const bech32invoice = 'lni1' + bech32b12.encode(Buffer.from(response.invoice_hex_str, 'hex'))
+
+ // sanity check
+ const { amount_msats } = await decodeBolt12Invoice({ lndk, request: bech32invoice })
+ if (toPositiveNumber(amount_msats) !== toPositiveNumber(msats)) {
+ return reject(new Error('invalid invoice response'))
+ }
+
+ resolve(bech32invoice)
+ } catch (e) {
+ reject(e)
+ }
+ })
+ })
+}
+
+export async function payViaBolt12PaymentRequest ({
+ lndk,
+ request: invoiceBech32,
+ max_fee,
+ max_fee_mtokens
+}) {
+ const { amount_msats, invoice_hex_str } = await decodeBolt12Invoice({ lndk, request: invoiceBech32 })
+
+ if (!max_fee_mtokens && max_fee) {
+ max_fee_mtokens = toPositiveNumber(satsToMsats(max_fee))
+ }
+
+ // safety check
+ if (!max_fee_mtokens || isNaN(max_fee_mtokens)) throw new Error('invalid max_fee')
+
+ return new Promise((resolve, reject) => {
+ lndk.PayInvoice({
+ invoice: invoice_hex_str,
+ // expects msats amount: https://github.com/lndk-org/lndk/blob/bce93885f5fc97f3ceb15dc470117e10061de018/src/lib.rs#L403
+ amount: toPositiveNumber(amount_msats),
+ max_fee: toPositiveNumber(max_fee_mtokens)
+ }, (error, response) => {
+ if (error) {
+ return reject(error)
+ }
+ resolve({
+ secret: response.payment_preimage
+ })
+ })
+ })
+}
diff --git a/api/lib/lndkrpc-proto.js b/api/lib/lndkrpc-proto.js
new file mode 100644
index 000000000..54bff47bd
--- /dev/null
+++ b/api/lib/lndkrpc-proto.js
@@ -0,0 +1,144 @@
+// https://github.com/stackernews/lndk/blob/561aab3f038f937970d91f02a5b49e2ef1188e8f/proto/lndkrpc.proto
+// diff https://github.com/stackernews/lndk/commit/e36a2c0c8812185e11a51e38ce9d8fcb513e7446
+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;
+ optional uint64 max_fee = 5;
+}
+
+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;
+ optional uint64 max_fee = 3;
+}
+
+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/api/lnd/index.js b/api/lnd/index.js
index f09996288..8ec7439c4 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 { authenticatedLndkGrpc } from '@/api/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
+export const lndk = authenticatedLndkGrpc({
+ 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/README.md b/api/paidAction/README.md
index a32588076..d478b20bf 100644
--- a/api/paidAction/README.md
+++ b/api/paidAction/README.md
@@ -193,6 +193,7 @@ All functions have the following signature: `function(args: Object, context: Obj
- `tx`: the current transaction (for anything that needs to be done atomically with the payment)
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)
- `lnd`: the current lnd client
+- `lndk`: the current lndk client
## Recording Cowboy Credits
diff --git a/api/paidAction/index.js b/api/paidAction/index.js
index ad067a3ca..c1246cd74 100644
--- a/api/paidAction/index.js
+++ b/api/paidAction/index.js
@@ -1,10 +1,11 @@
-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 { parseBolt11 } from '@/api/lib/bolt/bolt11'
import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
@@ -212,7 +213,7 @@ async function beginPessimisticAction (actionType, args, context) {
async function performP2PAction (actionType, args, incomingContext) {
// if the action has an invoiceable peer, we'll create a peer invoice
// wrap it, and return the wrapped invoice
- const { cost, sybilFeePercent, models, lnd, me } = incomingContext
+ const { cost, sybilFeePercent, models, lnd, lndk, me } = incomingContext
if (!sybilFeePercent) {
throw new Error('sybil fee percent is not set for an invoiceable peer action')
}
@@ -232,7 +233,7 @@ async function performP2PAction (actionType, args, incomingContext) {
feePercent: sybilFeePercent,
description,
expiry: INVOICE_EXPIRE_SECS
- }, { models, me, lnd })
+ }, { models, me, lnd, lndk })
context = {
...incomingContext,
@@ -256,7 +257,7 @@ async function performP2PAction (actionType, args, incomingContext) {
// we don't need to use the module for perform-ing outside actions
// because we can't track the state of outside invoices we aren't paid/paying
async function performDirectAction (actionType, args, incomingContext) {
- const { models, lnd, cost } = incomingContext
+ const { models, lnd, cost, lndk } = incomingContext
const { comment, lud18Data, noteStr, description: actionDescription } = args
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext)
@@ -273,15 +274,16 @@ async function performDirectAction (actionType, args, incomingContext) {
invoiceObject = await createUserInvoice(userId, {
msats: cost,
description,
- expiry: INVOICE_EXPIRE_SECS
- }, { models, lnd })
+ expiry: INVOICE_EXPIRE_SECS,
+ supportBolt12: false // direct payment is not supported to bolt12 for compatibility reasons
+ }, { models, lnd, lndk })
} catch (e) {
console.error('failed to create outside invoice', e)
throw new NonInvoiceablePeerError()
}
const { invoice, wallet } = invoiceObject
- const hash = parsePaymentRequest({ request: invoice }).id
+ const hash = await parseBolt11({ request: invoice }).id
const payment = await models.directPayment.create({
data: {
@@ -429,8 +431,9 @@ async function createDbInvoice (actionType, args, context) {
throw new Error('The cost of the action must be at least 1 sat')
}
+ // note: served invoice is always bolt11
const servedBolt11 = wrappedBolt11 ?? bolt11
- const servedInvoice = parsePaymentRequest({ request: servedBolt11 })
+ const servedInvoice = await parseBolt11({ request: servedBolt11 })
const expiresAt = new Date(servedInvoice.expires_at)
const invoiceData = {
diff --git a/api/payingAction/index.js b/api/payingAction/index.js
index 2ff7117a7..c75e5cd13 100644
--- a/api/payingAction/index.js
+++ b/api/payingAction/index.js
@@ -1,12 +1,12 @@
import { LND_PATHFINDING_TIME_PREF_PPM, 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 '@/api/lib/bolt'
// paying actions are completely distinct from paid actions
// and there's only one paying action: send
// ... still we want the api to at least be similar
-export default async function performPayingAction ({ bolt11, maxFee, walletId }, { me, models, lnd }) {
+export default async function performPayingAction ({ bolt11, maxFee, walletId }, { me, models, lnd, lndk }) {
try {
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId)
@@ -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, lndk })
const cost = toPositiveBigInt(toPositiveBigInt(decoded.mtokens) + satsToMsats(maxFee))
console.log('cost', cost)
@@ -40,8 +40,9 @@ export default async function performPayingAction ({ bolt11, maxFee, walletId },
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
- payViaPaymentRequest({
+ payInvoice({
lnd,
+ lndk,
request: withdrawal.bolt11,
max_fee: msatsToSats(withdrawal.msatsFeePaying),
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS,
diff --git a/api/resolvers/item.js b/api/resolvers/item.js
index a2c7ef647..ba45620e4 100644
--- a/api/resolvers/item.js
+++ b/api/resolvers/item.js
@@ -945,7 +945,7 @@ export default {
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd })
},
- act: async (parent, { id, sats, act = 'TIP', hasSendWallet }, { me, models, lnd, headers }) => {
+ act: async (parent, { id, sats, act = 'TIP', hasSendWallet }, { me, models, lnd, lndk, headers }) => {
assertApiKeyNotPermitted({ me })
await validateSchema(actSchema, { sats, act })
await assertGofacYourself({ models, headers })
@@ -979,11 +979,11 @@ export default {
}
if (act === 'TIP') {
- return await performPaidAction('ZAP', { id, sats, hasSendWallet }, { me, models, lnd })
+ return await performPaidAction('ZAP', { id, sats, hasSendWallet }, { me, models, lnd, lndk })
} else if (act === 'DONT_LIKE_THIS') {
- return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
+ return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd, lndk })
} else if (act === 'BOOST') {
- return await performPaidAction('BOOST', { id, sats }, { me, models, lnd })
+ return await performPaidAction('BOOST', { id, sats }, { me, models, lnd, lndk })
} else {
throw new GqlInputError('unknown act')
}
diff --git a/api/resolvers/paidAction.js b/api/resolvers/paidAction.js
index 2b993c1f0..f6785bb15 100644
--- a/api/resolvers/paidAction.js
+++ b/api/resolvers/paidAction.js
@@ -50,7 +50,7 @@ export default {
}
},
Mutation: {
- retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => {
+ retryPaidAction: async (parent, { invoiceId }, { models, me, lnd, lndk }) => {
if (!me) {
throw new Error('You must be logged in')
}
@@ -67,7 +67,7 @@ export default {
throw new Error(`Invoice is not in failed state: ${invoice.actionState}`)
}
- const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd })
+ const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd, lndk })
return {
...result,
diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js
index c71749ac0..b937a8d3e 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'
@@ -14,7 +13,7 @@ import {
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
-import { bolt11Tags } from '@/lib/bolt11'
+import { getInvoiceDescription } from '@/lib/bolt'
import { finalizeHodlInvoice } from '@/worker/wallet'
import walletDefs from '@/wallets/server'
import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
@@ -25,6 +24,10 @@ import validateWallet from '@/wallets/validate'
import { canReceive, getWalletByType } from '@/wallets/common'
import performPaidAction from '../paidAction'
import performPayingAction from '../payingAction'
+import { parseInvoice } from '@/api/lib/bolt'
+import lnd, { lndk } from '@/api/lnd'
+import { isBolt12Offer } from '@/api/lib/bolt/bolt12'
+import { fetchBolt12InvoiceFromOffer } from '@/api/lib/lndk'
import { timeoutSignal, withTimeout } from '@/lib/time'
function injectResolvers (resolvers) {
@@ -32,7 +35,7 @@ function injectResolvers (resolvers) {
for (const walletDef of walletDefs) {
const resolverName = generateResolverName(walletDef.walletField)
console.log(resolverName)
- resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
+ resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models, lnd, lndk }) => {
console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data })
let existingVaultEntries
@@ -71,6 +74,8 @@ function injectResolvers (resolvers) {
? (data) => withTimeout(
walletDef.testCreateInvoice(data, {
logger,
+ lnd,
+ lndk,
signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS)
}),
WALLET_CREATE_INVOICE_TIMEOUT_MS)
@@ -377,7 +382,7 @@ const resolvers = {
f = { ...f, ...f.other }
if (f.bolt11) {
- f.description = bolt11Tags(f.bolt11).description
+ f.description = getInvoiceDescription(f.bolt11)
}
switch (f.type) {
@@ -473,13 +478,13 @@ const resolvers = {
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
},
Mutation: {
- createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => {
+ createInvoice: async (parent, { amount }, { me, models, lnd, lndk, headers }) => {
await validateSchema(amountSchema, { amount })
await assertGofacYourself({ models, headers })
const { invoice, paymentMethod } = await performPaidAction('RECEIVE', {
msats: satsToMsats(amount)
- }, { models, lnd, me })
+ }, { models, lnd, lndk, me })
return {
...invoice,
@@ -489,7 +494,8 @@ const resolvers = {
},
createWithdrawl: createWithdrawal,
sendToLnAddr,
- cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => {
+ sendToBolt12Offer,
+ cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, lndk, boss }) => {
// stackers can cancel their own invoices without hmac
if (me && !hmac) {
const inv = await models.invoice.findUnique({ where: { hash } })
@@ -498,7 +504,7 @@ const resolvers = {
} else {
verifyHmac(hash, hmac)
}
- await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
+ await finalizeHodlInvoice({ data: { hash }, lnd, lndk, models, boss })
return await models.invoice.update({ where: { hash }, data: { userCancel: !!userCancel } })
},
dropBolt11: async (parent, { hash }, { me, models, lnd }) => {
@@ -747,8 +753,8 @@ export const walletLogger = ({ wallet, models }) => {
const log = (level) => async (message, context = {}) => {
try {
if (context?.bolt11) {
- // automatically populate context from bolt11 to avoid duplicating this code
- const decoded = await parsePaymentRequest({ request: context.bolt11 })
+ // automatically populate context from invoice to avoid duplicating this code
+ const decoded = await parseInvoice({ request: context.bolt11, lnd, lndk })
context = {
...context,
amount: formatMsats(decoded.mtokens),
@@ -916,7 +922,7 @@ async function upsertWallet (
return upsertedWallet
}
-export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, wallet, logger }) {
+export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, lndk, headers, wallet, logger }) {
assertApiKeyNotPermitted({ me })
await validateSchema(withdrawlSchema, { invoice, maxFee })
await assertGofacYourself({ models, headers })
@@ -927,7 +933,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, lndk })
} catch (error) {
console.log(error)
throw new GqlInputError('could not decode invoice')
@@ -966,11 +972,11 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
}
- return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd })
+ return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd, lndk })
}
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
- { me, models, lnd, headers }) {
+ { me, models, lnd, lndk, headers }) {
if (!me) {
throw new GqlAuthenticationError()
}
@@ -980,17 +986,30 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...
{
me,
models,
- lnd
+ lnd,
+ lndk
})
// take pr and createWithdrawl
- return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
+ return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, lndk, headers })
+}
+
+export async function sendToBolt12Offer (parent, { offer, amountSats, maxFee, comment }, { me, models, lnd, lndk, headers }) {
+ if (!me) {
+ throw new GqlAuthenticationError()
+ }
+ assertApiKeyNotPermitted({ me })
+ if (!isBolt12Offer(offer)) {
+ throw new GqlInputError('not a bolt12 offer')
+ }
+ const invoice = await fetchBolt12InvoiceFromOffer({ lnd, lndk, offer, msats: satsToMsats(amountSats), description: comment })
+ return await createWithdrawal(parent, { invoice, maxFee }, { me, models, lnd, lndk, headers })
}
export async function fetchLnAddrInvoice (
{ addr, amount, maxFee, comment, ...payer },
{
- me, models, lnd, autoWithdraw = false
+ me, models, lnd, lndk, autoWithdraw = false
}) {
const options = await lnAddrOptions(addr)
await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
@@ -1027,7 +1046,7 @@ export async function fetchLnAddrInvoice (
// decode invoice
try {
- const decoded = await parsePaymentRequest({ request: res.pr })
+ const decoded = await parseInvoice({ request: res.pr, lnd, lndk })
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/api/ssrApollo.js b/api/ssrApollo.js
index 7af73317e..e4cf21f9b 100644
--- a/api/ssrApollo.js
+++ b/api/ssrApollo.js
@@ -5,7 +5,7 @@ import resolvers from './resolvers'
import typeDefs from './typeDefs'
import models from './models'
import { print } from 'graphql'
-import lnd from './lnd'
+import lnd, { lndk } from './lnd'
import search from './search'
import { ME } from '@/fragments/users'
import { PRICE } from '@/fragments/price'
@@ -31,6 +31,7 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
? session.user
: me,
lnd,
+ lndk,
search
}
}),
diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js
index 3006fc7a4..2b7805b66 100644
--- a/api/typeDefs/wallet.js
+++ b/api/typeDefs/wallet.js
@@ -78,6 +78,7 @@ const typeDefs = `
createInvoice(amount: Int!): InvoiceOrDirect!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
+ sendToBolt12Offer(offer: String!, amountSats: Int!, maxFee: Int!, comment: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice!
dropBolt11(hash: String!): Boolean
removeWallet(id: ID!): Boolean
diff --git a/components/bolt11-info.js b/components/bolt11-info.js
index 1dd4dff87..0ce208110 100644
--- a/components/bolt11-info.js
+++ b/components/bolt11-info.js
@@ -1,11 +1,12 @@
import AccordianItem from './accordian-item'
import { CopyInput } from './form'
-import { bolt11Tags } from '@/lib/bolt11'
+import { getInvoiceDescription, getInvoicePaymentHash } from '@/lib/bolt'
export default ({ bolt11, preimage, children }) => {
let description, paymentHash
if (bolt11) {
- ({ description, payment_hash: paymentHash } = bolt11Tags(bolt11))
+ description = getInvoiceDescription(bolt11)
+ paymentHash = getInvoicePaymentHash(bolt11)
}
return (
diff --git a/docker/lndk/Dockerfile b/docker/lndk/Dockerfile
index a421053a6..b19f6ef3f 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/stackernews/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 f75d6547e..a7bfcf2fc 100644
--- a/fragments/wallet.js
+++ b/fragments/wallet.js
@@ -121,6 +121,13 @@ export const SEND_TO_LNADDR = gql`
}
}`
+export const SEND_TO_BOLT12_OFFER = gql`
+ mutation sendToBolt12Offer($offer: String!, $amountSats: Int!, $maxFee: Int!, $comment: String) {
+ sendToBolt12Offer(offer: $offer, amountSats: $amountSats, maxFee: $maxFee, comment: $comment) {
+ id
+ }
+}`
+
export const REMOVE_WALLET =
gql`
mutation removeWallet($id: ID!) {
@@ -169,6 +176,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..dbe501b48
--- /dev/null
+++ b/lib/bech32b12.js
@@ -0,0 +1,49 @@
+// bech32 without the checksum
+// used for bolt12
+
+const ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
+
+export function decode (str) {
+ if (str.length > 2048) throw new Error('input is too long')
+ 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(convertBits(b5s, 5, 8, false))
+ return b8s
+}
+
+export function encode (b8s) {
+ if (b8s.length > 2048) throw new Error('input is too long')
+ const b5s = convertBits(b8s, 8, 5, true)
+ return b5s.map(b5 => ALPHABET[b5]).join('')
+}
+
+function convertBits (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/bolt/bolt11-tags.js b/lib/bolt/bolt11-tags.js
new file mode 100644
index 000000000..fc4b56c98
--- /dev/null
+++ b/lib/bolt/bolt11-tags.js
@@ -0,0 +1,22 @@
+/* eslint-disable camelcase */
+import { decode } from 'bolt11'
+import { bolt11InvoiceSchema } from '@/lib/validate'
+
+export function isBolt11 (request) {
+ return bolt11InvoiceSchema.isValidSync(request)
+}
+
+function bolt11Tags (bolt11) {
+ if (!isBolt11(bolt11)) throw new Error('not a bolt11 invoice')
+ return decode(bolt11).tagsObject
+}
+
+export function getBolt11Description (bolt11) {
+ const { description } = bolt11Tags(bolt11)
+ return description
+}
+
+export function getBolt11PaymentHash (bolt11) {
+ const { payment_hash } = bolt11Tags(bolt11)
+ return payment_hash
+}
diff --git a/lib/bolt/bolt12-info.js b/lib/bolt/bolt12-info.js
new file mode 100644
index 000000000..6d7e88cb0
--- /dev/null
+++ b/lib/bolt/bolt12-info.js
@@ -0,0 +1,44 @@
+import { deserializeTLVStream } from '../tlv'
+import * as bech32b12 from '@/lib/bech32b12'
+
+import { bolt12OfferSchema, bolt12InvoiceSchema } from '../validate'
+
+const TYPE_DESCRIPTION = 10n
+const TYPE_PAYER_NOTE = 89n
+const TYPE_PAYMENT_HASH = 168n
+
+export function isBolt12Offer (invoice) {
+ return bolt12OfferSchema.isValidSync(invoice)
+}
+
+export function isBolt12Invoice (invoice) {
+ return bolt12InvoiceSchema.isValidSync(invoice)
+}
+
+export function isBolt12 (invoice) {
+ return isBolt12Offer(invoice) || isBolt12Invoice(invoice)
+}
+
+export function getBolt12Description (bolt12) {
+ if (!isBolt12(bolt12)) throw new Error('not a bolt12 invoice or offer')
+ const buf = bech32b12.decode(bolt12.substring(4)/* remove lni1 or lno1 prefix */)
+ const tlv = deserializeTLVStream(buf)
+ let description = ''
+ for (const { type, value } of tlv) {
+ if (type === TYPE_DESCRIPTION) {
+ description = value.toString() || description
+ } else if (type === TYPE_PAYER_NOTE) {
+ description = value.toString() || description
+ break
+ }
+ }
+ return description
+}
+
+export function getBolt12PaymentHash (bolt12) {
+ if (!isBolt12(bolt12)) throw new Error('not a bolt12 invoice or offer')
+ const buf = bech32b12.decode(bolt12.substring(4)/* remove lni1 or lno1 prefix */)
+ const tlv = deserializeTLVStream(buf)
+ const paymentHash = tlv.find(({ type }) => type === TYPE_PAYMENT_HASH)
+ return paymentHash?.value?.toString('hex')
+}
diff --git a/lib/bolt/index.js b/lib/bolt/index.js
new file mode 100644
index 000000000..743b8cab3
--- /dev/null
+++ b/lib/bolt/index.js
@@ -0,0 +1,12 @@
+import { getBolt11Description, getBolt11PaymentHash } from '@/lib/bolt/bolt11-tags'
+import { getBolt12Description, getBolt12PaymentHash, isBolt12 } from '@/lib/bolt/bolt12-info'
+
+export function getInvoiceDescription (bolt) {
+ if (isBolt12(bolt)) return getBolt12Description(bolt)
+ return getBolt11Description(bolt)
+}
+
+export function getInvoicePaymentHash (bolt) {
+ if (isBolt12(bolt)) return getBolt12PaymentHash(bolt)
+ return getBolt11PaymentHash(bolt)
+}
diff --git a/lib/bolt11.js b/lib/bolt11.js
deleted file mode 100644
index f04770167..000000000
--- a/lib/bolt11.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { decode } from 'bolt11'
-
-export function bolt11Tags (bolt11) {
- return decode(bolt11).tagsObject
-}
diff --git a/lib/tlv.js b/lib/tlv.js
new file mode 100644
index 000000000..62df3478c
--- /dev/null
+++ b/lib/tlv.js
@@ -0,0 +1,35 @@
+export function deserializeTLVStream (buff) {
+ const tlvs = []
+ let bytePos = 0
+ while (bytePos < buff.length) {
+ const [type, typeLength] = readBigSize(buff, bytePos)
+ bytePos += typeLength
+
+ const [length, lengthLength] = readBigSize(buff, bytePos)
+ bytePos += lengthLength
+
+ if (bytePos + Number(length) > buff.length) {
+ throw new Error('invalid tlv stream')
+ }
+
+ const value = buff.subarray(bytePos, bytePos + Number(length))
+ bytePos += Number(length)
+
+ tlvs.push({ type, length: Number(length), value })
+ }
+ return tlvs
+}
+
+function readBigSize (buf, offset) {
+ if (buf[offset] <= 252) {
+ return [BigInt(buf[offset]), 1]
+ } else if (buf[offset] === 253) {
+ return [BigInt(buf.readUInt16BE(offset + 1)), 3]
+ } else if (buf[offset] === 254) {
+ return [BigInt(buf.readUInt32BE(offset + 1)), 5]
+ } else if (buf[offset] === 255) {
+ return [buf.readBigUInt64BE(offset + 1), 9]
+ } else {
+ throw new Error('Invalid bigsize')
+ }
+}
diff --git a/lib/validate.js b/lib/validate.js
index 12bce77a8..f70c0f8b6 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -223,6 +223,16 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
return accum
}, {})))
+export const bolt11InvoiceSchema = string().trim().matches(process.env.NODE_ENV === 'development' ? /^lnbcrt/ : /^lnbc/, 'invalid bolt11 invoice')
+export const bolt12OfferSchema = string().trim().matches(/^lno1/, 'invalid bolt12 offer')
+export const bolt12InvoiceSchema = string().trim().matches(/^lni1/, 'invalid bolt12 invoice')
+export const bolt12WithdrawSchema = object({
+ offer: bolt12OfferSchema.required('required'),
+ amount: intValidator.required('required').min(1, 'must be at least 1'),
+ maxFee: intValidator.required('required').min(0, 'must be at least 0'),
+ comment: string().max(128, 'must be less than 128')
+})
+
export function bountySchema (args) {
return object({
title: titleValidator,
diff --git a/pages/api/graphql.js b/pages/api/graphql.js
index 9d6626e93..9e8b4375a 100644
--- a/pages/api/graphql.js
+++ b/pages/api/graphql.js
@@ -2,7 +2,7 @@ import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'
import resolvers from '@/api/resolvers'
import models from '@/api/models'
-import lnd from '@/api/lnd'
+import lnd, { lndk } from '@/api/lnd'
import typeDefs from '@/api/typeDefs'
import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from './auth/[...nextauth]'
@@ -74,6 +74,7 @@ export default startServerAndCreateNextHandler(apolloServer, {
models,
headers: req.headers,
lnd,
+ lndk,
me: session
? session.user
: null,
diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js
index fc7e21220..37e0cee3a 100644
--- a/pages/api/lnurlp/[username]/pay.js
+++ b/pages/api/lnurlp/[username]/pay.js
@@ -1,5 +1,5 @@
import models from '@/api/models'
-import lnd from '@/api/lnd'
+import lnd, { lndk } from '@/api/lnd'
import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '@/lib/lnurl'
import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto'
@@ -85,7 +85,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
comment: comment || '',
lud18Data: parsedPayerData,
noteStr
- }, { models, lnd, me: user })
+ }, { models, lnd, lndk, me: user })
if (!invoice?.bolt11) throw new Error('could not generate invoice')
diff --git a/pages/api/lnwith.js b/pages/api/lnwith.js
index ed22d499f..57ed88582 100644
--- a/pages/api/lnwith.js
+++ b/pages/api/lnwith.js
@@ -2,7 +2,7 @@
// send back
import models from '@/api/models'
import { datePivot } from '@/lib/time'
-import lnd from '@/api/lnd'
+import lnd, { lndk } from '@/api/lnd'
import { createWithdrawal } from '@/api/resolvers/wallet'
export default async ({ query, headers }, res) => {
@@ -68,7 +68,7 @@ async function doWithdrawal (query, res, headers) {
try {
const withdrawal = await createWithdrawal(null,
{ invoice: query.pr, maxFee: me.withdrawMaxFeeDefault },
- { me, models, lnd, headers })
+ { me, models, lnd, lndk, headers })
// store withdrawal id lnWith so client can show it
await models.lnWith.update({ where: { k1: query.k1 }, data: { withdrawalId: Number(withdrawal.id) } })
diff --git a/pages/withdraw.js b/pages/withdraw.js
index 1a39386e3..dbb7e241a 100644
--- a/pages/withdraw.js
+++ b/pages/withdraw.js
@@ -5,13 +5,13 @@ import { useRouter } from 'next/router'
import { InputGroup, Nav } from 'react-bootstrap'
import styles from '@/components/user-header.module.css'
import { gql, useMutation, useQuery } from '@apollo/client'
-import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '@/fragments/wallet'
+import { CREATE_WITHDRAWL, SEND_TO_LNADDR, SEND_TO_BOLT12_OFFER } from '@/fragments/wallet'
import { requestProvider } from 'webln'
import { useEffect, useState } from 'react'
import { useMe } from '@/components/me'
import { WithdrawlSkeleton } from './withdrawals/[id]'
import { Checkbox, Form, Input, InputUserSuggest, SubmitButton } from '@/components/form'
-import { lnAddrSchema, withdrawlSchema } from '@/lib/validate'
+import { lnAddrSchema, withdrawlSchema, bolt12WithdrawSchema } from '@/lib/validate'
import { useShowModal } from '@/components/modal'
import { useField } from 'formik'
import { useToast } from '@/components/toast'
@@ -65,6 +65,11 @@ function WithdrawForm () {