diff --git a/api/lnd/index.js b/api/lnd/index.js index abc39e82..f0999628 100644 --- a/api/lnd/index.js +++ b/api/lnd/index.js @@ -1,7 +1,9 @@ import { cachedFetcher } from '@/lib/fetch' -import { toPositiveNumber } from '@/lib/validate' +import { toPositiveNumber } from '@/lib/format' import { authenticatedLndGrpc } from '@/lib/lnd' -import { getIdentity, getHeight, getWalletInfo, getNode } from 'ln-service' +import { getIdentity, getHeight, getWalletInfo, getNode, getPayment } from 'ln-service' +import { datePivot } from '@/lib/time' +import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' const lnd = global.lnd || authenticatedLndGrpc({ cert: process.env.LND_CERT, @@ -88,22 +90,22 @@ export function getPaymentFailureStatus (withdrawal) { throw new Error('withdrawal is not failed') } - if (withdrawal?.failed.is_insufficient_balance) { + if (withdrawal?.failed?.is_insufficient_balance) { return { status: 'INSUFFICIENT_BALANCE', message: 'you didn\'t have enough sats' } - } else if (withdrawal?.failed.is_invalid_payment) { + } else if (withdrawal?.failed?.is_invalid_payment) { return { status: 'INVALID_PAYMENT', message: 'invalid payment' } - } else if (withdrawal?.failed.is_pathfinding_timeout) { + } else if (withdrawal?.failed?.is_pathfinding_timeout) { return { status: 'PATHFINDING_TIMEOUT', message: 'no route found' } - } else if (withdrawal?.failed.is_route_not_found) { + } else if (withdrawal?.failed?.is_route_not_found) { return { status: 'ROUTE_NOT_FOUND', message: 'no route found' @@ -160,4 +162,18 @@ export const getNodeSockets = cachedFetcher(async function fetchNodeSockets ({ l } }) +export async function getPaymentOrNotSent ({ id, lnd, createdAt }) { + try { + return await getPayment({ id, lnd }) + } catch (err) { + if (err[1] === 'SentPaymentNotFound' && + createdAt < datePivot(new Date(), { milliseconds: -LND_PATHFINDING_TIMEOUT_MS * 2 })) { + // if the payment is older than 2x timeout, but not found in LND, we can assume it errored before lnd stored it + return { notSent: true, is_failed: true } + } else { + throw err + } + } +} + export default lnd diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 7ffa0377..f3bb44c4 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -3,8 +3,8 @@ 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 } from '@/wallets/server' -import { assertBelowMaxPendingInvoices } from './lib/assert' +import { createWrappedInvoice, createInvoice as createUserInvoice } from '@/wallets/server' +import { assertBelowMaxPendingInvoices, assertBelowMaxPendingDirectPayments } from './lib/assert' import * as ITEM_CREATE from './itemCreate' import * as ITEM_UPDATE from './itemUpdate' @@ -106,6 +106,17 @@ export default async function performPaidAction (actionType, args, incomingConte } } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) { return await performOptimisticAction(actionType, args, contextWithPaymentMethod) + } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT) { + try { + return await performDirectAction(actionType, args, contextWithPaymentMethod) + } catch (e) { + if (e instanceof NonInvoiceablePeerError) { + console.log('peer cannot be invoiced, skipping') + continue + } + console.error(`${paymentMethod} action failed`, e) + throw e + } } } } @@ -229,6 +240,55 @@ async function performP2PAction (actionType, args, incomingContext) { : await beginPessimisticAction(actionType, args, context) } +// 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 { comment, lud18Data, noteStr, description: actionDescription } = args + + const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext) + if (!userId) { + throw new NonInvoiceablePeerError() + } + + let invoiceObject + + try { + await assertBelowMaxPendingDirectPayments(userId, incomingContext) + + const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext) + invoiceObject = await createUserInvoice(userId, { + msats: cost, + description, + expiry: INVOICE_EXPIRE_SECS + }, { models, lnd }) + } catch (e) { + console.error('failed to create outside invoice', e) + throw new NonInvoiceablePeerError() + } + + const { invoice, wallet } = invoiceObject + const hash = parsePaymentRequest({ request: invoice }).id + + const payment = await models.directPayment.create({ + data: { + comment, + lud18Data, + desc: noteStr, + bolt11: invoice, + msats: cost, + hash, + walletId: wallet.id, + receiverId: userId + } + }) + + return { + invoice: payment, + paymentMethod: 'DIRECT' + } +} + export async function retryPaidAction (actionType, args, incomingContext) { const { models, me } = incomingContext const { invoice: failedInvoice } = args diff --git a/api/paidAction/lib/assert.js b/api/paidAction/lib/assert.js index c0e0d634..8fcc95ba 100644 --- a/api/paidAction/lib/assert.js +++ b/api/paidAction/lib/assert.js @@ -1,7 +1,10 @@ import { BALANCE_LIMIT_MSATS, PAID_ACTION_TERMINAL_STATES, USER_ID, SN_ADMIN_IDS } from '@/lib/constants' import { msatsToSats, numWithUnits } from '@/lib/format' +import { datePivot } from '@/lib/time' const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 +const MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES = 10 +const MAX_PENDING_DIRECT_INVOICES_PER_USER = 100 const USER_IDS_BALANCE_NO_LIMIT = [...SN_ADMIN_IDS, USER_ID.anon, USER_ID.ad] export async function assertBelowMaxPendingInvoices (context) { @@ -20,6 +23,40 @@ export async function assertBelowMaxPendingInvoices (context) { } } +export async function assertBelowMaxPendingDirectPayments (userId, context) { + const { models, me } = context + + if (me?.id !== userId) { + const pendingSenderInvoices = await models.directPayment.count({ + where: { + senderId: me?.id ?? USER_ID.anon, + createdAt: { + gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES }) + } + } + }) + + if (pendingSenderInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) { + throw new Error('You\'ve sent too many direct payments') + } + } + + if (!userId) return + + const pendingReceiverInvoices = await models.directPayment.count({ + where: { + receiverId: userId, + createdAt: { + gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES }) + } + } + }) + + if (pendingReceiverInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) { + throw new Error('Receiver has too many direct payments') + } +} + export async function assertBelowBalanceLimit (context) { const { me, tx } = context if (!me || USER_IDS_BALANCE_NO_LIMIT.includes(me.id)) return diff --git a/api/paidAction/receive.js b/api/paidAction/receive.js index 8c6d8d4d..4bf28e18 100644 --- a/api/paidAction/receive.js +++ b/api/paidAction/receive.js @@ -1,7 +1,6 @@ import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' -import { toPositiveBigInt } from '@/lib/validate' +import { toPositiveBigInt, numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' import { notifyDeposit } from '@/lib/webPush' -import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format' import { getInvoiceableWallets } from '@/wallets/server' import { assertBelowBalanceLimit } from './lib/assert' @@ -9,6 +8,7 @@ export const anonable = false export const paymentMethods = [ PAID_ACTION_PAYMENT_METHODS.P2P, + PAID_ACTION_PAYMENT_METHODS.DIRECT, PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC ] @@ -16,17 +16,17 @@ export async function getCost ({ msats }) { return toPositiveBigInt(msats) } -export async function getInvoiceablePeer (_, { me, models, cost }) { - if (!me?.proxyReceive) return null - const wallets = await getInvoiceableWallets(me.id, { models }) +export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) { + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && !me?.directReceive) return null + if ((cost + me.msats) <= satsToMsats(me.autoWithdrawThreshold)) return null - // if the user has any invoiceable wallets and this action will result in their balance - // being greater than their desired threshold - if (wallets.length > 0 && (cost + me.msats) > satsToMsats(me.autoWithdrawThreshold)) { - return me.id + const wallets = await getInvoiceableWallets(me.id, { models }) + if (wallets.length === 0) { + return null } - return null + return me.id } export async function getSybilFeePercent () { diff --git a/api/payingAction/index.js b/api/payingAction/index.js new file mode 100644 index 00000000..0d276537 --- /dev/null +++ b/api/payingAction/index.js @@ -0,0 +1,63 @@ +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' + +// 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 }) { + try { + console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId) + + if (!me) { + throw new Error('You must be logged in to perform this action') + } + + const decoded = await parsePaymentRequest({ request: bolt11 }) + const cost = toPositiveBigInt(toPositiveBigInt(decoded.mtokens) + satsToMsats(maxFee)) + + console.log('cost', cost) + + const withdrawal = await models.$transaction(async tx => { + await tx.user.update({ + where: { + id: me.id + }, + data: { msats: { decrement: cost } } + }) + + return await tx.withdrawl.create({ + data: { + hash: decoded.id, + bolt11, + msatsPaying: toPositiveBigInt(decoded.mtokens), + msatsFeePaying: satsToMsats(maxFee), + userId: me.id, + walletId, + autoWithdraw: !!walletId + } + }) + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + + payViaPaymentRequest({ + lnd, + request: withdrawal.bolt11, + max_fee: msatsToSats(withdrawal.msatsFeePaying), + pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS + }).catch(console.error) + + return withdrawal + } catch (e) { + if (e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { + throw new Error('insufficient funds') + } + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new Error('you cannot withdraw to the same invoice twice') + } + console.error('performPayingAction failed', e) + throw e + } finally { + console.groupEnd() + } +} diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 2ebcb4a5..e836b22b 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -440,29 +440,37 @@ export default { } if (user.noteWithdrawals) { + const p2pZap = await models.invoice.findFirst({ + where: { + confirmedAt: { + gt: lastChecked + }, + invoiceForward: { + withdrawl: { + userId: me.id, + status: 'CONFIRMED', + updatedAt: { + gt: lastChecked + } + } + } + } + }) + if (p2pZap) { + foundNotes() + return true + } const wdrwl = await models.withdrawl.findFirst({ where: { userId: me.id, status: 'CONFIRMED', + hash: { + not: null + }, updatedAt: { gt: lastChecked }, - OR: [ - { - invoiceForward: { - none: {} - } - }, - { - invoiceForward: { - some: { - invoice: { - actionType: 'ZAP' - } - } - } - } - ] + invoiceForward: { is: null } } }) if (wdrwl) { diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 79765993..b0f9f779 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,15 +1,14 @@ import { - 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 { formatMsats, formatSats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format' +import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format' import { - USER_ID, INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS + USER_ID, INVOICE_RETENTION_DAYS, + PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import assertGofacYourself from './ofac' @@ -24,6 +23,7 @@ import { getNodeSockets, getOurPubkey } from '../lnd' import validateWallet from '@/wallets/validate' import { canReceive } from '@/wallets/common' import performPaidAction from '../paidAction' +import performPayingAction from '../payingAction' function injectResolvers (resolvers) { console.group('injected GraphQL resolvers:') @@ -190,6 +190,18 @@ const resolvers = { }) }, withdrawl: getWithdrawl, + direct: async (parent, { id }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + + return await models.directPayment.findUnique({ + where: { + id: Number(id), + receiverId: me.id + } + }) + }, numBolt11s: async (parent, args, { me, models, lnd }) => { if (!me) { throw new GqlAuthenticationError() @@ -251,6 +263,17 @@ const resolvers = { AND "Withdrawl".created_at <= $2 GROUP BY "Withdrawl".id)` ) + queries.push( + `(SELECT id, created_at as "createdAt", msats, 'direct' as type, + jsonb_build_object( + 'bolt11', bolt11, + 'description', "desc", + 'invoiceComment', comment, + 'invoicePayerData', "lud18Data") as other + FROM "DirectPayment" + WHERE "DirectPayment"."receiverId" = $1 + AND "DirectPayment".created_at <= $2)` + ) } if (include.has('stacked')) { @@ -436,33 +459,32 @@ const resolvers = { WalletDetails: { __resolveType: wallet => wallet.__resolveType }, + InvoiceOrDirect: { + __resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType + }, Mutation: { createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => { await validateSchema(amountSchema, { amount }) await assertGofacYourself({ models, headers }) - const { invoice } = await performPaidAction('RECEIVE', { + const { invoice, paymentMethod } = await performPaidAction('RECEIVE', { msats: satsToMsats(amount) }, { models, lnd, me }) - return invoice + return { + ...invoice, + __resolveType: + paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT ? 'Direct' : 'Invoice' + } }, createWithdrawl: createWithdrawal, sendToLnAddr, cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => { verifyHmac(hash, hmac) - const dbInv = await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) - - if (dbInv?.invoiceForward) { - const { wallet, bolt11 } = dbInv.invoiceForward - const logger = walletLogger({ wallet, models }) - const decoded = await parsePaymentRequest({ request: bolt11 }) - logger.info(`invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { bolt11 }) - } - + await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) return await models.invoice.findFirst({ where: { hash } }) }, - dropBolt11: async (parent, { id }, { me, models, lnd }) => { + dropBolt11: async (parent, { hash }, { me, models, lnd }) => { if (!me) { throw new GqlAuthenticationError() } @@ -470,20 +492,20 @@ const resolvers = { 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;` + WITH to_be_updated AS ( + SELECT id, hash, bolt11 + FROM "Withdrawl" + WHERE "userId" = ${me.id} + AND hash = ${hash} + 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 { @@ -497,7 +519,16 @@ const resolvers = { throw new GqlInputError('failed to drop bolt11 from lnd') } } - return { id } + + await models.$queryRaw` + UPDATE "DirectPayment" + SET hash = NULL, bolt11 = NULL, preimage = NULL + WHERE "receiverId" = ${me.id} + AND hash = ${hash} + AND now() > created_at + ${retention}::INTERVAL + AND hash IS NOT NULL` + + return true }, setWalletPriority: async (parent, { id, priority }, { me, models }) => { if (!me) { @@ -538,11 +569,11 @@ const resolvers = { 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), + satsFeePaying: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaying), + satsFeePaid: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaid), // we never want to fetch the sensitive data full monty in nested resolvers forwardedActionType: async (withdrawl, args, { models }) => { - return (await models.invoiceForward.findFirst({ + return (await models.invoiceForward.findUnique({ where: { withdrawlId: Number(withdrawl.id) }, include: { invoice: true @@ -551,7 +582,7 @@ const resolvers = { }, preimage: async (withdrawl, args, { lnd }) => { try { - if (withdrawl.status === 'CONFIRMED') { + if (withdrawl.status === 'CONFIRMED' && withdrawl.hash) { return withdrawl.preimage ?? (await getPayment({ id: withdrawl.hash, lnd })).payment.secret } } catch (err) { @@ -559,6 +590,17 @@ const resolvers = { } } }, + Direct: { + nostr: async (direct, args, { models }) => { + try { + return JSON.parse(direct.desc) + } catch (err) { + } + + return null + }, + sats: direct => msatsToSats(direct.msats) + }, Invoice: { satsReceived: i => msatsToSats(i.msatsReceived), @@ -876,10 +918,6 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model throw new GqlInputError('invoice amount is too large') } - const msatsFee = Number(maxFee) * 1000 - - const user = await models.user.findUnique({ where: { id: me.id } }) - // check if there's an invoice with same hash that has an invoiceForward // we can't allow this because it creates two outgoing payments from our node // with the same hash @@ -891,23 +929,7 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model throw new GqlInputError('SN cannot pay an invoice that SN is proxying') } - const autoWithdraw = !!wallet?.id - // 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}, ${wallet?.id}::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 + return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd }) } export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }, diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 9452a364..8b100170 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -108,6 +108,7 @@ export default gql` wildWestMode: Boolean! withdrawMaxFeeDefault: Int! proxyReceive: Boolean + directReceive: Boolean } type AuthMethods { @@ -187,6 +188,7 @@ export default gql` vaultKeyHash: String walletsUpdatedAt: Date proxyReceive: Boolean + directReceive: Boolean } type UserOptional { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 20fd7937..97f4f69e 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -64,6 +64,7 @@ const typeDefs = ` extend type Query { invoice(id: ID!): Invoice! withdrawl(id: ID!): Withdrawl! + direct(id: ID!): Direct! numBolt11s: Int! connectAddress: String! walletHistory(cursor: String, inc: String): History @@ -74,16 +75,20 @@ const typeDefs = ` } extend type Mutation { - createInvoice(amount: Int!): Invoice! + 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! cancelInvoice(hash: String!, hmac: String!): Invoice! - dropBolt11(id: ID): Withdrawl + dropBolt11(hash: String!): Boolean removeWallet(id: ID!): Boolean deleteWalletLogs(wallet: String): Boolean setWalletPriority(id: ID!, priority: Int!): Boolean } + interface InvoiceOrDirect { + id: ID! + } + type Wallet { id: ID! createdAt: Date! @@ -101,7 +106,7 @@ const typeDefs = ` autoWithdrawMaxFeeTotal: Int! } - type Invoice { + type Invoice implements InvoiceOrDirect { id: ID! createdAt: Date! hash: String! @@ -141,6 +146,18 @@ const typeDefs = ` forwardedActionType: String } + type Direct implements InvoiceOrDirect { + id: ID! + createdAt: Date! + bolt11: String + hash: String + sats: Int + preimage: String + nostr: JSONObject + comment: String + lud18Data: JSONObject + } + type Fact { id: ID! createdAt: Date! diff --git a/components/autowithdraw-shared.js b/components/autowithdraw-shared.js index 6bdc4045..d0d837b7 100644 --- a/components/autowithdraw-shared.js +++ b/components/autowithdraw-shared.js @@ -2,7 +2,7 @@ import { InputGroup } from 'react-bootstrap' import { Input } from './form' import { useMe } from './me' import { useEffect, useState } from 'react' -import { isNumber } from '@/lib/validate' +import { isNumber } from '@/lib/format' import Link from 'next/link' function autoWithdrawThreshold ({ me }) { diff --git a/components/invoice.js b/components/invoice.js index d74fff45..6ddcb14b 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -103,7 +103,7 @@ export default function Invoice ({ ) } - const { nostr, comment, lud18Data, bolt11, confirmedPreimage } = invoice + const { bolt11, confirmedPreimage } = invoice return ( <> @@ -120,36 +120,7 @@ export default function Invoice ({ {!modal && <> {info &&
{info}
} -
- {nostr - ? - - {JSON.stringify(nostr, null, 2)} - - - } - /> - : null} -
- {lud18Data && -
- } - className='mb-3' - /> -
} - {comment && -
- {comment}} - className='mb-3' - /> -
} + {invoice?.item && } } @@ -157,6 +128,43 @@ export default function Invoice ({ ) } +export function InvoiceExtras ({ nostr, lud18Data, comment }) { + return ( + <> +
+ {nostr + ? + + {JSON.stringify(nostr, null, 2)} + + + } + /> + : null} +
+ {lud18Data && +
+ } + className='mb-3' + /> +
} + {comment && +
+ {comment}} + className='mb-3' + /> +
} + + ) +} + function ActionInfo ({ invoice }) { if (!invoice.actionType) return null diff --git a/components/payment.js b/components/payment.js index be7c9c51..6adbfc51 100644 --- a/components/payment.js +++ b/components/payment.js @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { gql, useApolloClient, useMutation } from '@apollo/client' import { useWallet } from '@/wallets/index' -import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' +import { FAST_POLL_INTERVAL } from '@/lib/constants' import { INVOICE } from '@/fragments/wallet' import Invoice from '@/components/invoice' import { useShowModal } from './modal' @@ -10,17 +10,6 @@ import { InvoiceCanceledError, NoAttachedWalletError, InvoiceExpiredError } from export const useInvoice = () => { const client = useApolloClient() - const [createInvoice] = useMutation(gql` - mutation createInvoice($amount: Int!, $expireSecs: Int!) { - createInvoice(amount: $amount, hodlInvoice: true, expireSecs: $expireSecs) { - id - bolt11 - hash - hmac - expiresAt - satsRequested - } - }`) const [cancelInvoice] = useMutation(gql` mutation cancelInvoice($hash: String!, $hmac: String!) { cancelInvoice(hash: $hash, hmac: $hmac) { @@ -29,15 +18,6 @@ export const useInvoice = () => { } `) - const create = useCallback(async amount => { - const { data, error } = await createInvoice({ variables: { amount, expireSecs: JIT_INVOICE_TIMEOUT_MS / 1000 } }) - if (error) { - throw error - } - const invoice = data.createInvoice - return invoice - }, [createInvoice]) - const isInvoice = useCallback(async ({ id }, that) => { const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } }) if (error) { @@ -73,7 +53,7 @@ export const useInvoice = () => { return inv }, [cancelInvoice]) - return { create, cancel, isInvoice } + return { cancel, isInvoice } } const invoiceController = (id, isInvoice) => { diff --git a/components/wallet-logger.js b/components/wallet-logger.js index c0b8bd0d..57725b98 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -170,7 +170,7 @@ export function useWalletLogger (wallet, setLogs) { const decoded = bolt11Decode(context.bolt11) context = { ...context, - amount: formatMsats(Number(decoded.millisatoshis)), + amount: formatMsats(decoded.millisatoshis), payment_hash: decoded.tagsObject.payment_hash, description: decoded.tagsObject.description, created_at: new Date(decoded.timestamp * 1000).toISOString(), diff --git a/fragments/users.js b/fragments/users.js index bc4893fb..a424b2a1 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -51,6 +51,7 @@ ${STREAK_FIELDS} vaultKeyHash walletsUpdatedAt proxyReceive + directReceive } optional { isContributor @@ -113,6 +114,7 @@ export const SETTINGS_FIELDS = gql` } apiKeyEnabled proxyReceive + directReceive } }` diff --git a/fragments/wallet.js b/fragments/wallet.js index c2bfa3b0..cb2f747f 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -53,6 +53,7 @@ export const WITHDRAWL = gql` id createdAt bolt11 + hash satsPaid satsFeePaying satsFeePaid @@ -63,6 +64,21 @@ export const WITHDRAWL = gql` } }` +export const DIRECT = gql` + query Direct($id: ID!) { + direct(id: $id) { + id + createdAt + bolt11 + hash + sats + preimage + comment + lud18Data + nostr + } + }` + export const WALLET_HISTORY = gql` ${ITEM_FULL_FIELDS} diff --git a/lib/constants.js b/lib/constants.js index 4e734355..6bc88d50 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -7,6 +7,7 @@ export const PAID_ACTION_PAYMENT_METHODS = { FEE_CREDIT: 'FEE_CREDIT', PESSIMISTIC: 'PESSIMISTIC', OPTIMISTIC: 'OPTIMISTIC', + DIRECT: 'DIRECT', P2P: 'P2P' } export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING'] diff --git a/lib/format.js b/lib/format.js index 691db1c2..1d1c172c 100644 --- a/lib/format.js +++ b/lib/format.js @@ -73,7 +73,7 @@ export const msatsToSatsDecimal = msats => { } export const formatSats = (sats) => numWithUnits(sats, { unitSingular: 'sat', unitPlural: 'sats', abbreviate: false }) -export const formatMsats = (msats) => numWithUnits(msats, { unitSingular: 'msat', unitPlural: 'msats', abbreviate: false }) +export const formatMsats = (msats) => numWithUnits(toPositiveNumber(msats), { unitSingular: 'msat', unitPlural: 'msats', abbreviate: false }) export const hexToB64 = hexstring => { return btoa(hexstring.match(/\w{2}/g).map(function (a) { @@ -128,3 +128,79 @@ export function giveOrdinalSuffix (i) { } return i + 'th' } + +// check if something is _really_ a number. +// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity] +export const isNumber = x => typeof x === 'number' && !Number.isNaN(x) + +/** + * + * @param {any | bigint} x + * @param {number} min + * @param {number} max + * @returns {number} + */ +export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => { + if (typeof x === 'undefined') { + throw new Error('value is required') + } + if (typeof x === 'bigint') { + if (x < BigInt(min) || x > BigInt(max)) { + throw new Error(`value ${x} must be between ${min} and ${max}`) + } + return Number(x) + } else { + const n = Number(x) + if (isNumber(n)) { + if (x < min || x > max) { + throw new Error(`value ${x} must be between ${min} and ${max}`) + } + return n + } + } + throw new Error(`value ${x} is not a number`) +} + +/** + * @param {any | bigint} x + * @returns {number} + */ +export const toPositiveNumber = (x) => toNumber(x, 0) + +/** + * @param {any} x + * @param {bigint | number} [min] + * @param {bigint | number} [max] + * @returns {bigint} + */ +export const toBigInt = (x, min, max) => { + if (typeof x === 'undefined') throw new Error('value is required') + + const n = BigInt(x) + if (min !== undefined && n < BigInt(min)) { + throw new Error(`value ${x} must be at least ${min}`) + } + + if (max !== undefined && n > BigInt(max)) { + throw new Error(`value ${x} must be at most ${max}`) + } + + return n +} + +/** + * @param {number|bigint} x + * @returns {bigint} + */ +export const toPositiveBigInt = (x) => { + return toBigInt(x, 0) +} + +/** + * @param {number|bigint} x + * @returns {number|bigint} + */ +export const toPositive = (x) => { + if (typeof x === 'bigint') return toPositiveBigInt(x) + return toPositiveNumber(x) +} diff --git a/lib/validate.js b/lib/validate.js index 62b62d3b..bb4de8c5 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -513,79 +513,3 @@ export const deviceSyncSchema = object().shape({ return true }) }) - -// check if something is _really_ a number. -// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity] -export const isNumber = x => typeof x === 'number' && !Number.isNaN(x) - -/** - * - * @param {any | bigint} x - * @param {number} min - * @param {number} max - * @returns {number} - */ -export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => { - if (typeof x === 'undefined') { - throw new Error('value is required') - } - if (typeof x === 'bigint') { - if (x < BigInt(min) || x > BigInt(max)) { - throw new Error(`value ${x} must be between ${min} and ${max}`) - } - return Number(x) - } else { - const n = Number(x) - if (isNumber(n)) { - if (x < min || x > max) { - throw new Error(`value ${x} must be between ${min} and ${max}`) - } - return n - } - } - throw new Error(`value ${x} is not a number`) -} - -/** - * @param {any | bigint} x - * @returns {number} - */ -export const toPositiveNumber = (x) => toNumber(x, 0) - -/** - * @param {any} x - * @param {bigint | number} [min] - * @param {bigint | number} [max] - * @returns {bigint} - */ -export const toBigInt = (x, min, max) => { - if (typeof x === 'undefined') throw new Error('value is required') - - const n = BigInt(x) - if (min !== undefined && n < BigInt(min)) { - throw new Error(`value ${x} must be at least ${min}`) - } - - if (max !== undefined && n > BigInt(max)) { - throw new Error(`value ${x} must be at most ${max}`) - } - - return n -} - -/** - * @param {number|bigint} x - * @returns {bigint} - */ -export const toPositiveBigInt = (x) => { - return toBigInt(x, 0) -} - -/** - * @param {number|bigint} x - * @returns {number|bigint} - */ -export const toPositive = (x) => { - if (typeof x === 'bigint') return toPositiveBigInt(x) - return toPositiveNumber(x) -} diff --git a/lib/webPush.js b/lib/webPush.js index 5d054aed..b81e7ec7 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -350,12 +350,12 @@ export async function notifyDeposit (userId, invoice) { } } -export async function notifyWithdrawal (userId, wdrwl) { +export async function notifyWithdrawal (wdrwl) { try { - await sendUserNotification(userId, { - title: `${numWithUnits(msatsToSats(wdrwl.payment.mtokens), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`, + await sendUserNotification(wdrwl.userId, { + title: `${numWithUnits(msatsToSats(wdrwl.msatsPaid), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`, tag: 'WITHDRAWAL', - data: { sats: msatsToSats(wdrwl.payment.mtokens) } + data: { sats: msatsToSats(wdrwl.msatsPaid) } }) } catch (err) { console.error(err) diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 7df0cd18..d47f8365 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -4,7 +4,7 @@ import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescrip import { schnorr } from '@noble/curves/secp256k1' import { createHash } from 'crypto' import { LNURLP_COMMENT_MAX_LENGTH, MAX_INVOICE_DESCRIPTION_LENGTH } from '@/lib/constants' -import { validateSchema, lud18PayerDataSchema, toPositiveBigInt } from '@/lib/validate' +import { validateSchema, lud18PayerDataSchema, toPositiveBigInt } from '@/lib/format' import assertGofacYourself from '@/api/resolvers/ofac' import performPaidAction from '@/api/paidAction' @@ -91,7 +91,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa return res.status(200).json({ pr: invoice.bolt11, routes: [], - verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.hash}` + verify: invoice.hash ? `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.hash}` : undefined }) } catch (error) { console.log(error) diff --git a/pages/directs/[id].js b/pages/directs/[id].js new file mode 100644 index 00000000..c419388b --- /dev/null +++ b/pages/directs/[id].js @@ -0,0 +1,66 @@ +import { useQuery } from '@apollo/client' +import { CenterLayout } from '@/components/layout' +import { useRouter } from 'next/router' +import { DIRECT } from '@/fragments/wallet' +import { SSR, FAST_POLL_INTERVAL } from '@/lib/constants' +import Bolt11Info from '@/components/bolt11-info' +import { getGetServerSideProps } from '@/api/ssrApollo' +import { PrivacyOption } from '../withdrawals/[id]' +import { InvoiceExtras } from '@/components/invoice' +import { numWithUnits } from '@/lib/format' +import Qr, { QrSkeleton } from '@/components/qr' +// force SSR to include CSP nonces +export const getServerSideProps = getGetServerSideProps({ query: null }) + +export default function Direct () { + return ( + + + + ) +} + +export function DirectSkeleton ({ status }) { + return ( + <> +
+ +
+
+ +
+ + ) +} + +function LoadDirect () { + const router = useRouter() + const { loading, error, data } = useQuery(DIRECT, SSR + ? {} + : { + variables: { id: router.query.id }, + pollInterval: FAST_POLL_INTERVAL, + nextFetchPolicy: 'cache-and-network' + }) + if (error) return
error
+ if (!data || loading) { + return + } + + return ( + <> + +
+ + +
+ +
+
+ + ) +} diff --git a/pages/satistics/index.js b/pages/satistics/index.js index 67a0b048..55e4cd10 100644 --- a/pages/satistics/index.js +++ b/pages/satistics/index.js @@ -24,7 +24,7 @@ export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, function satusClass (status) { if (!status) { - return '' + return 'text-reset' } switch (status) { diff --git a/pages/settings/index.js b/pages/settings/index.js index d799c432..d5941ea1 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -159,7 +159,8 @@ export default function Settings ({ ssrData }) { diagnostics: settings?.diagnostics, hideIsContributor: settings?.hideIsContributor, noReferralLinks: settings?.noReferralLinks, - proxyReceive: settings?.proxyReceive + proxyReceive: settings?.proxyReceive, + directReceive: settings?.directReceive }} schema={settingsSchema} onSubmit={async ({ @@ -339,7 +340,7 @@ export default function Settings ({ ssrData }) {
proxy deposits to attached wallets
    -
  • Forward deposits directly to your attached wallets if they will cause your balance to exceed your auto-withdraw threshold
  • +
  • Forward deposits directly to your attached wallets if they cause your balance to exceed your auto-withdraw threshold
  • Payments will be wrapped by the SN node to preserve your wallet's privacy
  • This will incur in a 10% fee
@@ -349,6 +350,22 @@ export default function Settings ({ ssrData }) { name='proxyReceive' groupClassName='mb-0' /> + directly deposit to attached wallets + +
    +
  • Directly deposit to your attached wallets if they cause your balance to exceed your auto-withdraw threshold
  • +
  • Senders will be able to see your wallet's lightning node public key
  • +
  • If 'proxy deposits' is also checked, it will take precedence and direct deposits will only be used as a fallback
  • +
  • Because we can't determine if a payment succeeds, you won't be notified about direct deposits
  • +
+
+
+ } + name='directReceive' + groupClassName='mb-0' + /> hide invoice descriptions diff --git a/pages/wallet/index.js b/pages/wallet/index.js index 9150caf4..759e6e52 100644 --- a/pages/wallet/index.js +++ b/pages/wallet/index.js @@ -114,6 +114,7 @@ export function FundForm () { const [createInvoice, { called, error }] = useMutation(gql` mutation createInvoice($amount: Int!) { createInvoice(amount: $amount) { + __typename id } }`) @@ -147,7 +148,11 @@ export function FundForm () { schema={amountSchema} onSubmit={async ({ amount }) => { const { data } = await createInvoice({ variables: { amount: Number(amount) } }) - router.push(`/invoices/${data.createInvoice.id}`) + if (data.createInvoice.__typename === 'Direct') { + router.push(`/directs/${data.createInvoice.id}`) + } else { + router.push(`/invoices/${data.createInvoice.id}`) + } }} >
- +
) } -function PrivacyOption ({ wd }) { - if (!wd.bolt11) return +export function PrivacyOption ({ payment }) { + if (!payment.bolt11) return const { me } = useMe() - const keepUntil = datePivot(new Date(wd.createdAt), { days: INVOICE_RETENTION_DAYS }) + const keepUntil = datePivot(new Date(payment.createdAt), { days: INVOICE_RETENTION_DAYS }) const oldEnough = new Date() >= keepUntil if (!oldEnough) { return ( @@ -143,19 +143,19 @@ function PrivacyOption ({ wd }) { const toaster = useToast() const [dropBolt11] = useMutation( gql` - mutation dropBolt11($id: ID!) { - dropBolt11(id: $id) { - id - } + mutation dropBolt11($hash: String!) { + dropBolt11(hash: $hash) }`, { - update (cache) { - cache.modify({ - id: `Withdrawl:${wd.id}`, - fields: { - bolt11: () => null, - hash: () => null - } - }) + update (cache, { data }) { + if (data.dropBolt11) { + cache.modify({ + id: `${payment.__typename}:${payment.id}`, + fields: { + bolt11: () => null, + hash: () => null + } + }) + } } }) @@ -169,7 +169,7 @@ function PrivacyOption ({ wd }) { onConfirm={async () => { if (me) { try { - await dropBolt11({ variables: { id: wd.id } }) + await dropBolt11({ variables: { hash: payment.hash } }) } catch (err) { toaster.danger('unable to delete invoice: ' + err.message || err.toString?.()) throw err diff --git a/prisma/migrations/20241125205610_send_action/migration.sql b/prisma/migrations/20241125205610_send_action/migration.sql new file mode 100644 index 00000000..f2b2dac9 --- /dev/null +++ b/prisma/migrations/20241125205610_send_action/migration.sql @@ -0,0 +1,53 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "directReceive" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "DirectPayment" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "senderId" INTEGER, + "receiverId" INTEGER, + "preimage" TEXT, + "bolt11" TEXT, + "walletId" INTEGER, + "comment" TEXT, + "desc" TEXT, + "lud18Data" JSONB, + "msats" BIGINT NOT NULL, + "hash" TEXT, + CONSTRAINT "DirectPayment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DirectPayment_preimage_key" ON "DirectPayment"("preimage"); + +-- CreateIndex +CREATE INDEX "DirectPayment_created_at_idx" ON "DirectPayment"("created_at"); + +-- CreateIndex +CREATE INDEX "DirectPayment_senderId_idx" ON "DirectPayment"("senderId"); + +-- CreateIndex +CREATE INDEX "DirectPayment_receiverId_idx" ON "DirectPayment"("receiverId"); + +-- AddForeignKey +ALTER TABLE "DirectPayment" ADD CONSTRAINT "DirectPayment_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DirectPayment" ADD CONSTRAINT "DirectPayment_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DirectPayment" ADD CONSTRAINT "DirectPayment_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- drop dead functions replaced by paid/paying action state machines +DROP FUNCTION IF EXISTS confirm_invoice; +DROP FUNCTION IF EXISTS create_withdrawl; +DROP FUNCTION IF EXISTS confirm_withdrawl; +DROP FUNCTION IF EXISTS reverse_withdrawl; + +-- CreateIndex +CREATE UNIQUE INDEX "InvoiceForward_withdrawlId_key" ON "InvoiceForward"("withdrawlId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DirectPayment_hash_key" ON "DirectPayment"("hash"); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5fd08192..cd726fcd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -141,6 +141,9 @@ model User { walletsUpdatedAt DateTime? vaultEntries VaultEntry[] @relation("VaultEntries") proxyReceive Boolean @default(false) + directReceive Boolean @default(false) + DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") + DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -217,6 +220,7 @@ model Wallet { vaultEntries VaultEntry[] @relation("VaultEntries") withdrawals Withdrawl[] InvoiceForward InvoiceForward[] + DirectPayment DirectPayment[] @@index([userId]) @@index([priority]) @@ -940,6 +944,29 @@ model Invoice { @@index([actionState]) } +model DirectPayment { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + senderId Int? + receiverId Int? + preimage String? @unique + bolt11 String? + hash String? @unique + desc String? + comment String? + lud18Data Json? + msats BigInt + walletId Int? + sender User? @relation("DirectPaymentSent", fields: [senderId], references: [id], onDelete: Cascade) + receiver User? @relation("DirectPaymentReceived", fields: [receiverId], references: [id], onDelete: Cascade) + wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) + + @@index([createdAt]) + @@index([senderId]) + @@index([receiverId]) +} + model InvoiceForward { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") @@ -954,7 +981,7 @@ model InvoiceForward { // we get these values when the outgoing invoice is settled invoiceId Int @unique - withdrawlId Int? + withdrawlId Int? @unique invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) @@ -982,7 +1009,7 @@ model Withdrawl { walletId Int? user User @relation(fields: [userId], references: [id], onDelete: Cascade) wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) - invoiceForward InvoiceForward[] + invoiceForward InvoiceForward? @@index([createdAt], map: "Withdrawl.created_at_index") @@index([userId], map: "Withdrawl.userId_index") diff --git a/sndev b/sndev index 414d8b31..5d3ad800 100755 --- a/sndev +++ b/sndev @@ -261,7 +261,7 @@ sndev__withdraw() { if [ "$1" = "--cln" ]; then shift label=$(date +%s) - sndev__cli -t cln invoice "$1" "$label" sndev | jq -j '.bolt11'; echo + sndev__cli -t cln invoice "$1"sats "$label" sndev | jq -j '.bolt11'; echo else sndev__cli lnd addinvoice --amt "$@" | jq -j '.payment_request'; echo fi diff --git a/wallets/server.js b/wallets/server.js index c4559ded..c329d767 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -14,11 +14,10 @@ import * as webln from '@/wallets/webln' import { walletLogger } from '@/api/resolvers/wallet' import walletDefs from '@/wallets/server' import { parsePaymentRequest } from 'ln-service' -import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate' +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 { formatMsats, formatSats, msatsToSats } from '@/lib/format' import wrapInvoice from './wrap' export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] diff --git a/wallets/wrap.js b/wallets/wrap.js index 26fe35d4..26f83ca5 100644 --- a/wallets/wrap.js +++ b/wallets/wrap.js @@ -1,6 +1,6 @@ import { createHodlInvoice, parsePaymentRequest } from 'ln-service' import { estimateRouteFee, getBlockHeight } from '../api/lnd' -import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/validate' +import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/format' 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 diff --git a/worker/autoDropBolt11.js b/worker/autoDropBolt11.js new file mode 100644 index 00000000..43248736 --- /dev/null +++ b/worker/autoDropBolt11.js @@ -0,0 +1,43 @@ +import { deletePayment } from 'ln-service' +import { INVOICE_RETENTION_DAYS } from '@/lib/constants' + +export async function autoDropBolt11s ({ models, lnd }) { + const retention = `${INVOICE_RETENTION_DAYS} days` + + // This query will update the withdrawls and return what the hash and bol11 values were before the update + const invoices = await models.$queryRaw` + WITH to_be_updated AS ( + SELECT id, hash, bolt11 + FROM "Withdrawl" + WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s") + 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 (invoices.length > 0) { + for (const invoice of invoices) { + try { + await deletePayment({ id: invoice.hash, lnd }) + } catch (error) { + console.error(`Error removing invoice with hash ${invoice.hash}:`, error) + await models.withdrawl.update({ + where: { id: invoice.id }, + data: { hash: invoice.hash, bolt11: invoice.bolt11, preimage: invoice.preimage } + }) + } + } + } + + await models.$queryRaw` + UPDATE "DirectPayment" + SET hash = NULL, bolt11 = NULL, preimage = NULL + WHERE "receiverId" IN (SELECT id FROM users WHERE "autoDropBolt11s") + AND now() > created_at + ${retention}::INTERVAL + AND hash IS NOT NULL` +} diff --git a/worker/index.js b/worker/index.js index 1d8bcf43..5543e289 100644 --- a/worker/index.js +++ b/worker/index.js @@ -3,7 +3,7 @@ import './loadenv' import PgBoss from 'pg-boss' import createPrisma from '@/lib/create-prisma' import { - autoDropBolt11s, checkInvoice, checkPendingDeposits, checkPendingWithdrawals, + checkInvoice, checkPendingDeposits, checkPendingWithdrawals, checkWithdrawal, finalizeHodlInvoice, subscribeToWallet } from './wallet' @@ -35,6 +35,8 @@ import { thisDay } from './thisDay' import { isServiceEnabled } from '@/lib/sndev' import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts' import { expireBoost } from './expireBoost' +import { payingActionConfirmed, payingActionFailed } from './payingAction' +import { autoDropBolt11s } from './autoDropBolt11' async function work () { const boss = new PgBoss(process.env.DATABASE_URL) @@ -102,6 +104,9 @@ async function work () { await boss.work('paidActionCanceling', jobWrapper(paidActionCanceling)) await boss.work('paidActionFailed', jobWrapper(paidActionFailed)) await boss.work('paidActionPaid', jobWrapper(paidActionPaid)) + // payingAction jobs + await boss.work('payingActionFailed', jobWrapper(payingActionFailed)) + await boss.work('payingActionConfirmed', jobWrapper(payingActionConfirmed)) } if (isServiceEnabled('search')) { await boss.work('indexItem', jobWrapper(indexItem)) diff --git a/worker/paidAction.js b/worker/paidAction.js index ad9dfb54..70396ddb 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -1,14 +1,13 @@ -import { getPaymentFailureStatus, hodlInvoiceCltvDetails } from '@/api/lnd' +import { getPaymentFailureStatus, hodlInvoiceCltvDetails, getPaymentOrNotSent } from '@/api/lnd' import { paidActions } from '@/api/paidAction' import { walletLogger } from '@/api/resolvers/wallet' import { LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' -import { formatMsats, formatSats, msatsToSats } from '@/lib/format' +import { formatMsats, formatSats, msatsToSats, toPositiveNumber } from '@/lib/format' import { datePivot } from '@/lib/time' -import { toPositiveNumber } from '@/lib/validate' import { Prisma } from '@prisma/client' import { cancelHodlInvoice, - getInvoice, getPayment, parsePaymentRequest, + getInvoice, parsePaymentRequest, payViaPaymentRequest, settleHodlInvoice } from 'ln-service' import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap' @@ -114,7 +113,7 @@ async function transitionInvoice (jobName, } } -async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, lnd, boss }) { +async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, lnd, boss }) { const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id } const context = { tx, @@ -278,7 +277,7 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode // this finalizes the forward by settling the incoming invoice after the outgoing payment is confirmed export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...args }, models, lnd, boss }) { - return await transitionInvoice('paidActionForwarded', { + const transitionedInvoice = await transitionInvoice('paidActionForwarded', { invoiceId, fromState: 'FORWARDING', toState: 'FORWARDED', @@ -287,8 +286,9 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a throw new Error('invoice is not held') } - const { bolt11, hash, msatsPaying } = dbInvoice.invoiceForward.withdrawl - const { payment, is_confirmed: isConfirmed } = withdrawal ?? await getPayment({ id: hash, lnd }) + const { hash, msatsPaying, createdAt } = dbInvoice.invoiceForward.withdrawl + const { payment, is_confirmed: isConfirmed } = withdrawal ?? + await getPaymentOrNotSent({ id: hash, lnd, createdAt }) if (!isConfirmed) { throw new Error('payment is not confirmed') } @@ -296,20 +296,6 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a // settle the invoice, allowing us to transition to PAID await settleHodlInvoice({ secret: payment.secret, lnd }) - // the amount we paid includes the fee so we need to subtract it to get the amount received - const received = Number(payment.mtokens) - Number(payment.fee_mtokens) - - const logger = walletLogger({ wallet: dbInvoice.invoiceForward.wallet, models }) - logger.ok( - `↙ payment received: ${formatSats(msatsToSats(received))}`, - { - bolt11, - preimage: payment.secret - // we could show the outgoing fee that we paid from the incoming amount to the receiver - // but we don't since it might look like the receiver paid the fee but that's not the case. - // fee: formatMsats(Number(payment.fee_mtokens)) - }) - return { preimage: payment.secret, invoiceForward: { @@ -328,11 +314,31 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a }, ...args }, { models, lnd, boss }) + + if (transitionedInvoice) { + const { bolt11, msatsPaid, msatsFeePaid } = transitionedInvoice.invoiceForward.withdrawl + // the amount we paid includes the fee so we need to subtract it to get the amount received + const received = Number(msatsPaid) - Number(msatsFeePaid) + + const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models }) + logger.ok( + `↙ payment received: ${formatSats(msatsToSats(received))}`, + { + bolt11, + preimage: transitionedInvoice.preimage + // we could show the outgoing fee that we paid from the incoming amount to the receiver + // but we don't since it might look like the receiver paid the fee but that's not the case. + // fee: formatMsats(msatsFeePaid) + }) + } + + return transitionedInvoice } // when the pending forward fails, we need to cancel the incoming invoice export async function paidActionFailedForward ({ data: { invoiceId, withdrawal: pWithdrawal, ...args }, models, lnd, boss }) { - return await transitionInvoice('paidActionFailedForward', { + let message + const transitionedInvoice = await transitionInvoice('paidActionFailedForward', { invoiceId, fromState: 'FORWARDING', toState: 'FAILED_FORWARD', @@ -341,21 +347,10 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal: throw new Error('invoice is not held') } - let withdrawal - let notSent = false - try { - withdrawal = pWithdrawal ?? await getPayment({ id: dbInvoice.invoiceForward.withdrawl.hash, lnd }) - } catch (err) { - if (err[1] === 'SentPaymentNotFound' && - dbInvoice.invoiceForward.withdrawl.createdAt < datePivot(new Date(), { milliseconds: -LND_PATHFINDING_TIMEOUT_MS * 2 })) { - // if the payment is older than 2x timeout, but not found in LND, we can assume it errored before lnd stored it - notSent = true - } else { - throw err - } - } + const { hash, createdAt } = dbInvoice.invoiceForward.withdrawl + const withdrawal = pWithdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt }) - if (!(withdrawal?.is_failed || notSent)) { + if (!(withdrawal?.is_failed || withdrawal?.notSent)) { throw new Error('payment has not failed') } @@ -363,14 +358,8 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal: // which once it does succeed will ensure we will try to cancel the held invoice until it actually cancels await boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash }, FINALIZE_OPTIONS) - const { status, message } = getPaymentFailureStatus(withdrawal) - const { bolt11, msatsFeePaying } = dbInvoice.invoiceForward.withdrawl - const logger = walletLogger({ wallet: dbInvoice.invoiceForward.wallet, models }) - logger.warn( - `incoming payment failed: ${message}`, { - bolt11, - max_fee: formatMsats(Number(msatsFeePaying)) - }) + const { status, message: failureMessage } = getPaymentFailureStatus(withdrawal) + message = failureMessage return { invoiceForward: { @@ -386,6 +375,18 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal: }, ...args }, { models, lnd, boss }) + + if (transitionedInvoice) { + const { bolt11, msatsFeePaying } = transitionedInvoice.invoiceForward.withdrawl + const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models }) + logger.warn( + `incoming payment failed: ${message}`, { + bolt11, + max_fee: formatMsats(msatsFeePaying) + }) + } + + return transitionedInvoice } export async function paidActionHeld ({ data: { invoiceId, ...args }, models, lnd, boss }) { @@ -427,7 +428,7 @@ export async function paidActionHeld ({ data: { invoiceId, ...args }, models, ln } export async function paidActionCanceling ({ data: { invoiceId, ...args }, models, lnd, boss }) { - return await transitionInvoice('paidActionCanceling', { + const transitionedInvoice = await transitionInvoice('paidActionCanceling', { invoiceId, fromState: ['HELD', 'PENDING', 'PENDING_HELD', 'FAILED_FORWARD'], toState: 'CANCELING', @@ -440,6 +441,17 @@ export async function paidActionCanceling ({ data: { invoiceId, ...args }, model }, ...args }, { models, lnd, boss }) + + if (transitionedInvoice) { + if (transitionedInvoice.invoiceForward) { + const { wallet, bolt11 } = transitionedInvoice.invoiceForward + const logger = walletLogger({ wallet, models }) + const decoded = await parsePaymentRequest({ request: bolt11 }) + logger.info(`invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { bolt11 }) + } + } + + return transitionedInvoice } export async function paidActionFailed ({ data: { invoiceId, ...args }, models, lnd, boss }) { diff --git a/worker/payingAction.js b/worker/payingAction.js new file mode 100644 index 00000000..a2c945d6 --- /dev/null +++ b/worker/payingAction.js @@ -0,0 +1,176 @@ +import { getPaymentFailureStatus, getPaymentOrNotSent } from '@/api/lnd' +import { walletLogger } from '@/api/resolvers/wallet' +import { formatMsats, formatSats, msatsToSats, toPositiveBigInt } from '@/lib/format' +import { datePivot } from '@/lib/time' +import { notifyWithdrawal } from '@/lib/webPush' +import { Prisma } from '@prisma/client' + +async function transitionWithdrawal (jobName, + { withdrawalId, toStatus, transition, withdrawal, onUnexpectedError }, + { models, lnd, boss } +) { + console.group(`${jobName}: transitioning withdrawal ${withdrawalId} from null to ${toStatus}`) + + let dbWithdrawal + try { + const currentDbWithdrawal = await models.withdrawl.findUnique({ where: { id: withdrawalId } }) + console.log('withdrawal has status', currentDbWithdrawal.status) + + if (currentDbWithdrawal.status) { + console.log('withdrawal is already has a terminal status, skipping transition') + return + } + + const { hash, createdAt } = currentDbWithdrawal + const lndWithdrawal = withdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt }) + + const transitionedWithdrawal = await models.$transaction(async tx => { + // grab optimistic concurrency lock and the withdrawal + dbWithdrawal = await tx.withdrawl.update({ + include: { + wallet: true + }, + where: { + id: withdrawalId, + status: null + }, + data: { + status: toStatus + } + }) + + // our own optimistic concurrency check + if (!dbWithdrawal) { + console.log('record not found in our own concurrency check, assuming concurrent worker transitioned it') + return + } + + const data = await transition({ lndWithdrawal, dbWithdrawal, tx }) + if (data) { + return await tx.withdrawl.update({ + include: { + wallet: true + }, + where: { id: dbWithdrawal.id }, + data + }) + } + + return dbWithdrawal + }, { + isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, + // we only need to do this because we settleHodlInvoice inside the transaction + // ... and it's prone to timing out + timeout: 60000 + }) + + if (transitionedWithdrawal) { + console.log('transition succeeded') + return transitionedWithdrawal + } + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2025') { + console.log('record not found, assuming concurrent worker transitioned it') + return + } + if (e.code === 'P2034') { + console.log('write conflict, assuming concurrent worker is transitioning it') + return + } + } + + console.error('unexpected error', e) + onUnexpectedError?.({ error: e, dbWithdrawal, models, boss }) + await boss.send( + jobName, + { withdrawalId }, + { startAfter: datePivot(new Date(), { seconds: 30 }), priority: 1000 }) + } finally { + console.groupEnd() + } +} + +export async function payingActionConfirmed ({ data: args, models, lnd, boss }) { + const transitionedWithdrawal = await transitionWithdrawal('payingActionConfirmed', { + toStatus: 'CONFIRMED', + ...args, + transition: async ({ dbWithdrawal, lndWithdrawal, tx }) => { + if (!lndWithdrawal?.is_confirmed) { + throw new Error('withdrawal is not confirmed') + } + + const msatsFeePaid = toPositiveBigInt(lndWithdrawal.payment.fee_mtokens) + const msatsPaid = toPositiveBigInt(lndWithdrawal.payment.mtokens) - msatsFeePaid + + console.log(`withdrawal confirmed paying ${msatsToSats(msatsPaid)} sats with ${msatsToSats(msatsFeePaid)} fee`) + + await tx.user.update({ + where: { id: dbWithdrawal.userId }, + data: { msats: { increment: dbWithdrawal.msatsFeePaying - msatsFeePaid } } + }) + + console.log(`user refunded ${msatsToSats(dbWithdrawal.msatsFeePaying - msatsFeePaid)} sats`) + + return { + msatsFeePaid, + msatsPaid, + preimage: lndWithdrawal.payment.secret + } + } + }, { models, lnd, boss }) + + if (transitionedWithdrawal) { + await notifyWithdrawal(transitionedWithdrawal) + + const logger = walletLogger({ models, wallet: transitionedWithdrawal.wallet }) + logger?.ok( + `↙ payment received: ${formatSats(msatsToSats(transitionedWithdrawal.msatsPaid))}`, + { + bolt11: transitionedWithdrawal.bolt11, + preimage: transitionedWithdrawal.preimage, + fee: formatMsats(transitionedWithdrawal.msatsFeePaid) + }) + } +} + +export async function payingActionFailed ({ data: args, models, lnd, boss }) { + let message + const transitionedWithdrawal = await transitionWithdrawal('payingActionFailed', { + toStatus: 'UNKNOWN_FAILURE', + ...args, + transition: async ({ dbWithdrawal, lndWithdrawal, tx }) => { + if (!lndWithdrawal?.is_failed) { + throw new Error('withdrawal is not failed') + } + + console.log(`withdrawal failed paying ${msatsToSats(dbWithdrawal.msatsPaying)} sats with ${msatsToSats(dbWithdrawal.msatsFeePaying)} fee`) + + await tx.user.update({ + where: { id: dbWithdrawal.userId }, + data: { msats: { increment: dbWithdrawal.msatsFeePaying + dbWithdrawal.msatsPaying } } + }) + + console.log(`user refunded ${msatsToSats(dbWithdrawal.msatsFeePaying + dbWithdrawal.msatsPaying)} sats`) + + // update to particular status + const { status, message: failureMessage } = getPaymentFailureStatus(lndWithdrawal) + message = failureMessage + + console.log('withdrawal failed with status', status) + return { + status + } + } + }, { models, lnd, boss }) + + if (transitionedWithdrawal) { + const logger = walletLogger({ models, wallet: transitionedWithdrawal.wallet }) + logger?.error( + `incoming payment failed: ${message}`, + { + bolt11: transitionedWithdrawal.bolt11, + max_fee: formatMsats(transitionedWithdrawal.msatsFeePaying) + }) + } +} diff --git a/worker/wallet.js b/worker/wallet.js index 454c5ca7..ac09c7ac 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -1,11 +1,9 @@ -import serialize from '@/api/resolvers/serial' import { - getInvoice, getPayment, cancelHodlInvoice, deletePayment, + getInvoice, subscribeToInvoices, subscribeToPayments, subscribeToInvoice } from 'ln-service' -import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush' -import { INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' -import { datePivot, sleep } from '@/lib/time' +import { getPaymentOrNotSent } from '@/api/lnd' +import { sleep } from '@/lib/time' import retry from 'async-retry' import { paidActionPaid, paidActionForwarded, @@ -13,9 +11,7 @@ import { paidActionForwarding, paidActionCanceling } from './paidAction' -import { getPaymentFailureStatus } from '@/api/lnd/index.js' -import { walletLogger } from '@/api/resolvers/wallet.js' -import { formatMsats, formatSats, msatsToSats } from '@/lib/format.js' +import { payingActionConfirmed, payingActionFailed } from './payingAction' export async function subscribeToWallet (args) { await subscribeToDeposits(args) @@ -143,19 +139,6 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd if (dbInv.actionType) { return await paidActionPaid({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss }) } - - // XXX we need to keep this to allow production to migrate to new paidAction flow - // once all non-paidAction receive invoices are migrated, we can remove this - const [[{ confirm_invoice: code }]] = await serialize([ - models.$queryRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`, - models.invoice.update({ where: { hash }, data: { confirmedIndex: inv.confirmed_index } }) - ], { models }) - - if (code === 0) { - notifyDeposit(dbInv.userId, { comment: dbInv.comment, ...inv }) - } - - return await boss.send('nip57', { hash }) } if (inv.is_held) { @@ -175,18 +158,6 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd if (dbInv.actionType) { return await paidActionFailed({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss }) } - - return await serialize( - models.invoice.update({ - where: { - hash: inv.id - }, - data: { - cancelled: true, - cancelledAt: new Date() - } - }), { models } - ) } } @@ -235,13 +206,12 @@ export async function checkWithdrawal ({ data: { hash, withdrawal, invoice }, bo hash, OR: [ { status: null }, - { invoiceForward: { some: { } } } + { invoiceForward: { isNot: null } } ] }, include: { wallet: true, invoiceForward: { - orderBy: { createdAt: 'desc' }, include: { invoice: true } @@ -252,103 +222,20 @@ export async function checkWithdrawal ({ data: { hash, withdrawal, invoice }, bo // nothing to do if the withdrawl is already recorded and it isn't an invoiceForward if (!dbWdrwl) return - let wdrwl - let notSent = false - try { - wdrwl = withdrawal ?? await getPayment({ id: hash, lnd }) - } catch (err) { - if (err[1] === 'SentPaymentNotFound' && - dbWdrwl.createdAt < datePivot(new Date(), { milliseconds: -LND_PATHFINDING_TIMEOUT_MS * 2 })) { - // if the payment is older than 2x timeout, but not found in LND, we can assume it errored before lnd stored it - notSent = true - } else { - throw err - } - } - - const logger = walletLogger({ models, wallet: dbWdrwl.wallet }) + const wdrwl = withdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt: dbWdrwl.createdAt }) if (wdrwl?.is_confirmed) { - if (dbWdrwl.invoiceForward.length > 0) { - return await paidActionForwarded({ data: { invoiceId: dbWdrwl.invoiceForward[0].invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss }) + if (dbWdrwl.invoiceForward) { + return await paidActionForwarded({ data: { invoiceId: dbWdrwl.invoiceForward.invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss }) } - const fee = Number(wdrwl.payment.fee_mtokens) - const paid = Number(wdrwl.payment.mtokens) - fee - const [[{ confirm_withdrawl: code }]] = await serialize([ - models.$queryRaw`SELECT confirm_withdrawl(${dbWdrwl.id}::INTEGER, ${paid}, ${fee})`, - models.withdrawl.update({ - where: { id: dbWdrwl.id }, - data: { - preimage: wdrwl.payment.secret - } - }) - ], { models }) - if (code === 0) { - notifyWithdrawal(dbWdrwl.userId, wdrwl) - - const { request: bolt11, secret: preimage } = wdrwl.payment - - logger?.ok( - `↙ payment received: ${formatSats(msatsToSats(paid))}`, - { - bolt11, - preimage, - fee: formatMsats(fee) - }) - } - } else if (wdrwl?.is_failed || notSent) { - if (dbWdrwl.invoiceForward.length > 0) { - return await paidActionFailedForward({ data: { invoiceId: dbWdrwl.invoiceForward[0].invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss }) + return await payingActionConfirmed({ data: { withdrawalId: dbWdrwl.id, withdrawal: wdrwl }, models, lnd, boss }) + } else if (wdrwl?.is_failed || wdrwl?.notSent) { + if (dbWdrwl.invoiceForward) { + return await paidActionFailedForward({ data: { invoiceId: dbWdrwl.invoiceForward.invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss }) } - const { message, status } = getPaymentFailureStatus(wdrwl) - await serialize( - models.$queryRaw` - SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")`, - { models } - ) - - logger?.error( - `incoming payment failed: ${message}`, - { - bolt11: wdrwl.payment.request, - max_fee: formatMsats(dbWdrwl.msatsFeePaying) - }) - } -} - -export async function autoDropBolt11s ({ models, lnd }) { - const retention = `${INVOICE_RETENTION_DAYS} days` - - // This query will update the withdrawls and return what the hash and bol11 values were before the update - const invoices = await models.$queryRaw` - WITH to_be_updated AS ( - SELECT id, hash, bolt11 - FROM "Withdrawl" - WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s") - 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 (invoices.length > 0) { - for (const invoice of invoices) { - try { - await deletePayment({ id: invoice.hash, lnd }) - } catch (error) { - console.error(`Error removing invoice with hash ${invoice.hash}:`, error) - await models.withdrawl.update({ - where: { id: invoice.id }, - data: { hash: invoice.hash, bolt11: invoice.bolt11, preimage: invoice.preimage } - }) - } - } + return await payingActionFailed({ data: { withdrawalId: dbWdrwl.id, withdrawal: wdrwl }, models, lnd, boss }) } } @@ -360,33 +247,16 @@ export async function finalizeHodlInvoice ({ data: { hash }, models, lnd, boss, return } - const dbInv = await models.invoice.findUnique({ - where: { hash }, - include: { - invoiceForward: { - include: { - withdrawl: true, - wallet: true - } - } - } - }) + const dbInv = await models.invoice.findUnique({ where: { hash } }) if (!dbInv) { console.log('invoice not found in database', hash) return } - // if this is an actionType we need to cancel conditionally - if (dbInv.actionType) { - await paidActionCanceling({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss }) - } else { - await cancelHodlInvoice({ id: hash, lnd }) - } + await paidActionCanceling({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss }) // sync LND invoice status with invoice status in database await checkInvoice({ data: { hash }, models, lnd, boss }) - - return dbInv } export async function checkPendingDeposits (args) {