import { createHodlInvoice, createInvoice, payViaPaymentRequest, getInvoice as getInvoiceFromLnd, deletePayment, getPayment, parsePaymentRequest } from 'ln-service' import crypto, { timingSafeEqual } from 'crypto' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { SELECT, itemQueryWithMeta } from './item' import { msatsToSats, msatsToSatsDecimal } from '@/lib/format' import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import { datePivot } from '@/lib/time' import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' import { bolt11Tags } from '@/lib/bolt11' import { finalizeHodlInvoice } from 'worker/wallet' import walletDefs from 'wallets/server' import { generateResolverName, generateTypeDefName } from '@/wallets/graphql' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets, getOurPubkey } from '../lnd' import validateWallet from '@/wallets/validate' import { canReceive } from '@/wallets/common' function injectResolvers (resolvers) { console.group('injected GraphQL 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 }) => { console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data }) const validData = await validateWallet(walletDef, { ...data, ...settings, vaultEntries }, { serverSide: true }) if (validData) { data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] }) settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] }) } return await upsertWallet({ wallet: { field: walletDef.walletField, type: walletDef.walletType }, testCreateInvoice: walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data }) ? (data) => walletDef.testCreateInvoice(data, { me, models }) : null }, { settings, data, vaultEntries }, { me, models }) } } console.groupEnd() return resolvers } export async function getInvoice (parent, { id }, { me, models, lnd }) { const inv = await models.invoice.findUnique({ where: { id: Number(id) }, include: { user: true } }) if (!inv) { throw new GqlInputError('invoice not found') } if (inv.user.id === USER_ID.anon) { return inv } if (!me) { throw new GqlAuthenticationError() } if (inv.user.id !== me.id) { throw new GqlInputError('not ur invoice') } try { inv.nostr = JSON.parse(inv.desc) } catch (err) { } try { if (inv.confirmedAt) { inv.confirmedPreimage = inv.preimage ?? (await getInvoiceFromLnd({ id: inv.hash, lnd })).secret } } catch (err) { console.error('error fetching invoice from LND', err) } return inv } export async function getWithdrawl (parent, { id }, { me, models, lnd }) { if (!me) { throw new GqlAuthenticationError() } const wdrwl = await models.withdrawl.findUnique({ where: { id: Number(id) }, include: { user: true, invoiceForward: true } }) if (!wdrwl) { throw new GqlInputError('withdrawal not found') } if (wdrwl.user.id !== me.id) { throw new GqlInputError('not ur withdrawal') } return wdrwl } export function createHmac (hash) { const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex') return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex') } export function verifyHmac (hash, hmac) { const hmac2 = createHmac(hash) if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) { throw new GqlAuthorizationError('bad hmac') } return true } const resolvers = { Query: { invoice: getInvoice, wallet: async (parent, { id }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } return await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) }, include: { vaultEntries: true } }) }, walletByType: async (parent, { type }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } const wallet = await models.wallet.findFirst({ where: { userId: me.id, type }, include: { vaultEntries: true } }) return wallet }, wallets: async (parent, args, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } return await models.wallet.findMany({ include: { vaultEntries: true }, where: { userId: me.id }, orderBy: { priority: 'asc' } }) }, withdrawl: getWithdrawl, numBolt11s: async (parent, args, { me, models, lnd }) => { if (!me) { throw new GqlAuthenticationError() } return await models.withdrawl.count({ where: { userId: me.id, hash: { not: null } } }) }, connectAddress: async (parent, args, { lnd }) => { return process.env.LND_CONNECT_ADDRESS }, walletHistory: async (parent, { cursor, inc }, { me, models, lnd }) => { const decodedCursor = decodeCursor(cursor) if (!me) { throw new GqlAuthenticationError() } const include = new Set(inc?.split(',')) const queries = [] if (include.has('invoice')) { queries.push( `(SELECT id, created_at as "createdAt", COALESCE("msatsReceived", "msatsRequested") as msats, 'invoice' as type, jsonb_build_object( 'bolt11', bolt11, 'status', CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED' WHEN cancelled THEN 'CANCELLED' WHEN "expiresAt" <= $2 AND NOT "isHeld" THEN 'EXPIRED' ELSE 'PENDING' END, 'description', "desc", 'invoiceComment', comment, 'invoicePayerData', "lud18Data") as other FROM "Invoice" WHERE "userId" = $1 AND created_at <= $2)` ) } if (include.has('withdrawal')) { queries.push( `(SELECT "Withdrawl".id, "Withdrawl".created_at as "createdAt", COALESCE("msatsPaid", "msatsPaying") as msats, CASE WHEN bool_and("InvoiceForward".id IS NULL) THEN 'withdrawal' ELSE 'p2p' END as type, jsonb_build_object( 'bolt11', "Withdrawl".bolt11, 'autoWithdraw', "autoWithdraw", 'status', COALESCE(status::text, 'PENDING'), 'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other FROM "Withdrawl" LEFT JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId" WHERE "Withdrawl"."userId" = $1 AND "Withdrawl".created_at <= $2 GROUP BY "Withdrawl".id)` ) } if (include.has('stacked')) { // query1 - get all sats stacked as OP or as a forward queries.push( `(SELECT "Item".id, MAX("ItemAct".created_at) AS "createdAt", FLOOR( SUM("ItemAct".msats) * (CASE WHEN "Item"."userId" = $1 THEN COALESCE(1 - ((SELECT SUM(pct) FROM "ItemForward" WHERE "itemId" = "Item".id) / 100.0), 1) ELSE (SELECT pct FROM "ItemForward" WHERE "itemId" = "Item".id AND "userId" = $1) / 100.0 END) ) AS msats, 'stacked' AS type, NULL::JSONB AS other FROM "ItemAct" JOIN "Item" ON "ItemAct"."itemId" = "Item".id -- only join to with item forward for items where we aren't the OP LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "Item"."userId" <> $1 WHERE "ItemAct".act = 'TIP' AND ("Item"."userId" = $1 OR "ItemForward"."userId" = $1) AND "ItemAct".created_at <= $2 AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') GROUP BY "Item".id)` ) queries.push( `(SELECT min("Earn".id) as id, created_at as "createdAt", sum(msats) as msats, 'earn' as type, NULL::JSONB AS other FROM "Earn" WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2 GROUP BY "userId", created_at)` ) queries.push( `(SELECT id, created_at as "createdAt", msats, 'referral' as type, NULL::JSONB AS other FROM "ReferralAct" WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)` ) queries.push( `(SELECT id, created_at as "createdAt", msats, 'revenue' as type, jsonb_build_object('subName', "SubAct"."subName") as other FROM "SubAct" WHERE "userId" = $1 AND type = 'REVENUE' AND created_at <= $2)` ) } if (include.has('spent')) { queries.push( `(SELECT "Item".id, MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats, 'spent' as type, NULL::JSONB AS other FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id WHERE "ItemAct"."userId" = $1 AND "ItemAct".created_at <= $2 AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') GROUP BY "Item".id)` ) queries.push( `(SELECT id, created_at as "createdAt", sats * 1000 as msats,'donation' as type, NULL::JSONB AS other FROM "Donation" WHERE "userId" = $1 AND created_at <= $2)` ) queries.push( `(SELECT id, created_at as "createdAt", msats, 'billing' as type, jsonb_build_object('subName', "SubAct"."subName") as other FROM "SubAct" WHERE "userId" = $1 AND type = 'BILLING' AND created_at <= $2)` ) } if (queries.length === 0) { return { cursor: null, facts: [] } } let history = await models.$queryRawUnsafe(` ${queries.join(' UNION ALL ')} ORDER BY "createdAt" DESC OFFSET $3 LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset) history = history.map(f => { f = { ...f, ...f.other } if (f.bolt11) { f.description = bolt11Tags(f.bolt11).description } switch (f.type) { case 'withdrawal': f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee) break case 'p2p': f.msats = -1 * Number(f.msats) break case 'spent': case 'donation': case 'billing': f.msats *= -1 break default: break } return f }) return { cursor: history.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, facts: history } }, walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } // we cursoring with the wallet logs on the client // if we have from, don't use cursor // regardless, store the state of the cursor for the next call const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() } let logs = [] let nextCursor if (from) { logs = await models.walletLog.findMany({ where: { userId: me.id, wallet: type ?? undefined, createdAt: { gte: from ? new Date(Number(from)) : undefined, lte: to ? new Date(Number(to)) : undefined } }, orderBy: [ { createdAt: 'desc' }, { id: 'desc' } ] }) nextCursor = nextCursorEncoded(decodedCursor, logs.length) } else { logs = await models.walletLog.findMany({ where: { userId: me.id, wallet: type ?? undefined, createdAt: { lte: decodedCursor.time } }, orderBy: [ { createdAt: 'desc' }, { id: 'desc' } ], take: LIMIT, skip: decodedCursor.offset }) nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null } return { cursor: nextCursor, entries: logs } } }, Wallet: { wallet: async (wallet) => { return { ...wallet.wallet, __resolveType: generateTypeDefName(wallet.type) } } }, WalletDetails: { __resolveType: wallet => wallet.__resolveType }, Mutation: { createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd, headers }) => { await validateSchema(amountSchema, { amount }) await assertGofacYourself({ models, headers }) let expirePivot = { seconds: expireSecs } let invLimit = INV_PENDING_LIMIT let balanceLimit = (hodlInvoice || USER_IDS_BALANCE_NO_LIMIT.includes(Number(me?.id))) ? 0 : BALANCE_LIMIT_MSATS let id = me?.id if (!me) { expirePivot = { seconds: Math.min(expireSecs, 180) } invLimit = ANON_INV_PENDING_LIMIT balanceLimit = ANON_BALANCE_LIMIT_MSATS id = USER_ID.anon } const user = await models.user.findUnique({ where: { id } }) const expiresAt = datePivot(new Date(), expirePivot) const description = `Funding @${user.name} on stacker.news` try { const invoice = await (hodlInvoice ? createHodlInvoice : createInvoice)({ description: user.hideInvoiceDesc ? undefined : description, lnd, tokens: amount, expires_at: expiresAt }) const [inv] = await serialize( models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.secret}::TEXT, ${invoice.request}, ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL, ${invLimit}::INTEGER, ${balanceLimit})`, { models } ) // the HMAC is only returned during invoice creation // this makes sure that only the person who created this invoice // has access to the HMAC const hmac = createHmac(inv.hash) return { ...inv, hmac } } catch (error) { console.log(error) throw error } }, createWithdrawl: createWithdrawal, sendToLnAddr, cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => { verifyHmac(hash, hmac) await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) return await models.invoice.findFirst({ where: { hash } }) }, dropBolt11: async (parent, { id }, { me, models, lnd }) => { if (!me) { throw new GqlAuthenticationError() } const retention = `${INVOICE_RETENTION_DAYS} days` const [invoice] = await models.$queryRaw` WITH to_be_updated AS ( SELECT id, hash, bolt11 FROM "Withdrawl" WHERE "userId" = ${me.id} AND id = ${Number(id)} AND now() > created_at + ${retention}::INTERVAL AND hash IS NOT NULL AND status IS NOT NULL ), updated_rows AS ( UPDATE "Withdrawl" SET hash = NULL, bolt11 = NULL, preimage = NULL FROM to_be_updated WHERE "Withdrawl".id = to_be_updated.id) SELECT * FROM to_be_updated;` if (invoice) { try { await deletePayment({ id: invoice.hash, lnd }) } catch (error) { console.error(error) await models.withdrawl.update({ where: { id: invoice.id }, data: { hash: invoice.hash, bolt11: invoice.bolt11, preimage: invoice.preimage } }) throw new GqlInputError('failed to drop bolt11 from lnd') } } return { id } }, setWalletPriority: async (parent, { id, priority }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } }) return true }, removeWallet: async (parent, { id }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } }) if (!wallet) { throw new GqlInputError('wallet not found') } await models.$transaction([ models.wallet.delete({ where: { userId: me.id, id: Number(id) } }), models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'INFO', message: 'receives disabled' } }), models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet detached for receives' } }) ]) return true }, deleteWalletLogs: async (parent, { wallet }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } await models.walletLog.deleteMany({ where: { userId: me.id, wallet } }) return true } }, Withdrawl: { satsPaying: w => msatsToSats(w.msatsPaying), satsPaid: w => msatsToSats(w.msatsPaid), satsFeePaying: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaying), satsFeePaid: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaid), p2p: w => !!w.invoiceForward?.length, preimage: async (withdrawl, args, { lnd }) => { try { if (withdrawl.status === 'CONFIRMED') { return withdrawl.preimage ?? (await getPayment({ id: withdrawl.hash, lnd })).payment.secret } } catch (err) { console.error('error fetching payment from LND', err) } } }, Invoice: { satsReceived: i => msatsToSats(i.msatsReceived), satsRequested: i => msatsToSats(i.msatsRequested), item: async (invoice, args, { models, me }) => { if (!invoice.actionId) return null switch (invoice.actionType) { case 'ITEM_CREATE': case 'ZAP': case 'DOWN_ZAP': case 'POLL_VOTE': case 'BOOST': return (await itemQueryWithMeta({ me, models, query: ` ${SELECT} FROM "Item" WHERE id = $1` }, Number(invoice.actionId)))?.[0] default: return null } }, itemAct: async (invoice, args, { models, me }) => { const action2act = { ZAP: 'TIP', DOWN_ZAP: 'DONT_LIKE_THIS', POLL_VOTE: 'POLL', BOOST: 'BOOST' } switch (invoice.actionType) { case 'ZAP': case 'DOWN_ZAP': case 'POLL_VOTE': case 'BOOST': return (await models.$queryRaw` SELECT id, act, "invoiceId", "invoiceActionState", msats FROM "ItemAct" WHERE "ItemAct"."invoiceId" = ${Number(invoice.id)}::INTEGER AND "ItemAct"."userId" = ${me?.id}::INTEGER AND act = ${action2act[invoice.actionType]}::"ItemActType"` )?.[0] default: return null } } }, Fact: { item: async (fact, args, { models }) => { if (fact.type !== 'spent' && fact.type !== 'stacked') { return null } const [item] = await models.$queryRawUnsafe(` ${SELECT} FROM "Item" WHERE id = $1`, Number(fact.id)) return item }, sats: fact => msatsToSatsDecimal(fact.msats) } } export default injectResolvers(resolvers) export const addWalletLog = async ({ wallet, level, message }, { models }) => { try { await models.walletLog.create({ data: { userId: wallet.userId, wallet: wallet.type, level, message } }) } catch (err) { console.error('error creating wallet log:', err) } } async function upsertWallet ( { wallet, testCreateInvoice }, { settings, data, vaultEntries }, { me, models }) { if (!me) { throw new GqlAuthenticationError() } assertApiKeyNotPermitted({ me }) if (testCreateInvoice) { try { await testCreateInvoice(data) } catch (err) { console.error(err) const message = 'failed to create test invoice: ' + (err.message || err.toString?.()) wallet = { ...wallet, userId: me.id } await addWalletLog({ wallet, level: 'ERROR', message }, { models }) await addWalletLog({ wallet, level: 'INFO', message: 'receives disabled' }, { models }) throw new GqlInputError(message) } } const { id, enabled, priority, ...walletData } = data const txs = [] if (id) { const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } }) // createMany is the set difference of the new - old // deleteMany is the set difference of the old - new // updateMany is the intersection of the old and new const difference = (a = [], b = [], key = 'key') => a.filter(x => !b.find(y => y[key] === x[key])) const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key])) .map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) })) txs.push( models.wallet.update({ where: { id: Number(id), userId: me.id }, data: { enabled, priority, // client only wallets has no walletData ...(Object.keys(walletData).length > 0 ? { [wallet.field]: { update: { where: { walletId: Number(id) }, data: walletData } } } : {}), ...(vaultEntries ? { vaultEntries: { deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({ userId: me.id, key })), create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({ key, iv, value, userId: me.id })), update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({ where: { userId_key: { userId: me.id, key } }, data: { value, iv } })) } } : {}) }, include: { vaultEntries: true } }) ) } else { txs.push( models.wallet.create({ include: { vaultEntries: true }, data: { enabled, priority, userId: me.id, type: wallet.type, // client only wallets has no walletData ...(Object.keys(walletData).length > 0 ? { [wallet.field]: { create: walletData } } : {}), vaultEntries: { createMany: { data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id })) } } } }) ) } if (settings) { txs.push( models.user.update({ where: { id: me.id }, data: settings }) ) } txs.push( models.walletLog.createMany({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: id ? 'wallet details updated' : 'wallet attached' } }), models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: enabled ? 'SUCCESS' : 'INFO', message: enabled ? 'wallet enabled' : 'wallet disabled' } }) ) const [upsertedWallet] = await models.$transaction(txs) return upsertedWallet } export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) { assertApiKeyNotPermitted({ me }) await validateSchema(withdrawlSchema, { invoice, maxFee }) await assertGofacYourself({ models, headers }) // remove 'lightning:' prefix if present invoice = invoice.replace(/^lightning:/, '') // decode invoice to get amount let decoded, sockets try { decoded = await parsePaymentRequest({ request: invoice }) } catch (error) { console.log(error) throw new GqlInputError('could not decode invoice') } try { sockets = await getNodeSockets({ lnd, public_key: decoded.destination }) } catch (error) { // likely not found if it's an unannounced channel, e.g. phoenix console.log(error) } if (sockets) { for (const { socket } of sockets) { const ip = socket.split(':')[0] await assertGofacYourself({ models, headers, ip }) } } if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) { throw new GqlInputError('your invoice must specify an amount') } if (decoded.mtokens > Number.MAX_SAFE_INTEGER) { throw new GqlInputError('your invoice amount is too large') } const msatsFee = Number(maxFee) * 1000 const user = await models.user.findUnique({ where: { id: me.id } }) const autoWithdraw = !!walletId // create withdrawl transactionally (id, bolt11, amount, fee) const [withdrawl] = await serialize( models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice}, ${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw}, ${walletId}::INTEGER)`, { models } ) payViaPaymentRequest({ lnd, request: invoice, // can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141 max_fee: Number(maxFee), pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS }).catch(console.error) return withdrawl } export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }, { me, models, lnd, headers }) { if (!me) { throw new GqlAuthenticationError() } assertApiKeyNotPermitted({ me }) const res = await fetchLnAddrInvoice({ addr, amount, maxFee, comment, ...payer }, { me, models, lnd }) // take pr and createWithdrawl return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers }) } export async function fetchLnAddrInvoice ( { addr, amount, maxFee, comment, ...payer }, { me, models, lnd, autoWithdraw = false }) { const options = await lnAddrOptions(addr) await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options) if (payer) { payer = { ...payer, identifier: payer.identifier ? `${me.name}@stacker.news` : undefined } payer = Object.fromEntries( Object.entries(payer).filter(([, value]) => !!value) ) } const milliamount = 1000 * amount const callback = new URL(options.callback) callback.searchParams.append('amount', milliamount) if (comment?.length) { callback.searchParams.append('comment', comment) } let stringifiedPayerData = '' if (payer && Object.entries(payer).length) { stringifiedPayerData = JSON.stringify(payer) callback.searchParams.append('payerdata', stringifiedPayerData) } // call callback with amount and conditionally comment const res = await (await fetch(callback.toString())).json() if (res.status === 'ERROR') { throw new Error(res.reason) } // decode invoice try { const decoded = await parsePaymentRequest({ request: res.pr }) 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 await models.wallet.deleteMany({ where: { userId: me.id, type: 'LIGHTNING_ADDRESS' } }) throw new Error('automated withdrawals to other stackers are not allowed') } if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) { throw new Error('invoice has incorrect amount') } } catch (e) { console.log(e) throw e } return res }