direct receives and send paid action (#1650)

* direct receives and send paid action

* remove withdrawl->invoiceForward has many relationship

* fix formatMsats implicit type expectations

* ui + dropping direct payment bolt11s

* squash migrations

* fix bolt11 dropping and improve paid action wallet logging

* remove redundant sender id

* fix redirect when funding account over threshold

* better logging
This commit is contained in:
Keyan 2024-11-27 07:39:05 -06:00 committed by GitHub
parent 8b5e13236b
commit 0bff478d39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 955 additions and 450 deletions

View File

@ -1,7 +1,9 @@
import { cachedFetcher } from '@/lib/fetch' import { cachedFetcher } from '@/lib/fetch'
import { toPositiveNumber } from '@/lib/validate' import { toPositiveNumber } from '@/lib/format'
import { authenticatedLndGrpc } from '@/lib/lnd' 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({ const lnd = global.lnd || authenticatedLndGrpc({
cert: process.env.LND_CERT, cert: process.env.LND_CERT,
@ -88,22 +90,22 @@ export function getPaymentFailureStatus (withdrawal) {
throw new Error('withdrawal is not failed') throw new Error('withdrawal is not failed')
} }
if (withdrawal?.failed.is_insufficient_balance) { if (withdrawal?.failed?.is_insufficient_balance) {
return { return {
status: 'INSUFFICIENT_BALANCE', status: 'INSUFFICIENT_BALANCE',
message: 'you didn\'t have enough sats' message: 'you didn\'t have enough sats'
} }
} else if (withdrawal?.failed.is_invalid_payment) { } else if (withdrawal?.failed?.is_invalid_payment) {
return { return {
status: 'INVALID_PAYMENT', status: 'INVALID_PAYMENT',
message: 'invalid payment' message: 'invalid payment'
} }
} else if (withdrawal?.failed.is_pathfinding_timeout) { } else if (withdrawal?.failed?.is_pathfinding_timeout) {
return { return {
status: 'PATHFINDING_TIMEOUT', status: 'PATHFINDING_TIMEOUT',
message: 'no route found' message: 'no route found'
} }
} else if (withdrawal?.failed.is_route_not_found) { } else if (withdrawal?.failed?.is_route_not_found) {
return { return {
status: 'ROUTE_NOT_FOUND', status: 'ROUTE_NOT_FOUND',
message: 'no route 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 export default lnd

View File

@ -3,8 +3,8 @@ import { datePivot } from '@/lib/time'
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { createHmac } from '@/api/resolvers/wallet' import { createHmac } from '@/api/resolvers/wallet'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { createWrappedInvoice } from '@/wallets/server' import { createWrappedInvoice, createInvoice as createUserInvoice } from '@/wallets/server'
import { assertBelowMaxPendingInvoices } from './lib/assert' import { assertBelowMaxPendingInvoices, assertBelowMaxPendingDirectPayments } from './lib/assert'
import * as ITEM_CREATE from './itemCreate' import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate' 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) { } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) {
return await performOptimisticAction(actionType, args, contextWithPaymentMethod) 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) : 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) { export async function retryPaidAction (actionType, args, incomingContext) {
const { models, me } = incomingContext const { models, me } = incomingContext
const { invoice: failedInvoice } = args const { invoice: failedInvoice } = args

View File

@ -1,7 +1,10 @@
import { BALANCE_LIMIT_MSATS, PAID_ACTION_TERMINAL_STATES, USER_ID, SN_ADMIN_IDS } from '@/lib/constants' import { BALANCE_LIMIT_MSATS, PAID_ACTION_TERMINAL_STATES, USER_ID, SN_ADMIN_IDS } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format' import { msatsToSats, numWithUnits } from '@/lib/format'
import { datePivot } from '@/lib/time'
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100 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] const USER_IDS_BALANCE_NO_LIMIT = [...SN_ADMIN_IDS, USER_ID.anon, USER_ID.ad]
export async function assertBelowMaxPendingInvoices (context) { 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) { export async function assertBelowBalanceLimit (context) {
const { me, tx } = context const { me, tx } = context
if (!me || USER_IDS_BALANCE_NO_LIMIT.includes(me.id)) return if (!me || USER_IDS_BALANCE_NO_LIMIT.includes(me.id)) return

View File

@ -1,7 +1,6 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' 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 { notifyDeposit } from '@/lib/webPush'
import { numWithUnits, msatsToSats, satsToMsats } from '@/lib/format'
import { getInvoiceableWallets } from '@/wallets/server' import { getInvoiceableWallets } from '@/wallets/server'
import { assertBelowBalanceLimit } from './lib/assert' import { assertBelowBalanceLimit } from './lib/assert'
@ -9,6 +8,7 @@ export const anonable = false
export const paymentMethods = [ export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.P2P, PAID_ACTION_PAYMENT_METHODS.P2P,
PAID_ACTION_PAYMENT_METHODS.DIRECT,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
] ]
@ -16,17 +16,17 @@ export async function getCost ({ msats }) {
return toPositiveBigInt(msats) return toPositiveBigInt(msats)
} }
export async function getInvoiceablePeer (_, { me, models, cost }) { export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
if (!me?.proxyReceive) return null if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null
const wallets = await getInvoiceableWallets(me.id, { models }) 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 const wallets = await getInvoiceableWallets(me.id, { models })
// being greater than their desired threshold if (wallets.length === 0) {
if (wallets.length > 0 && (cost + me.msats) > satsToMsats(me.autoWithdrawThreshold)) { return null
return me.id
} }
return null return me.id
} }
export async function getSybilFeePercent () { export async function getSybilFeePercent () {

63
api/payingAction/index.js Normal file
View File

@ -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()
}
}

View File

@ -440,29 +440,37 @@ export default {
} }
if (user.noteWithdrawals) { 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({ const wdrwl = await models.withdrawl.findFirst({
where: { where: {
userId: me.id, userId: me.id,
status: 'CONFIRMED', status: 'CONFIRMED',
hash: {
not: null
},
updatedAt: { updatedAt: {
gt: lastChecked gt: lastChecked
}, },
OR: [ invoiceForward: { is: null }
{
invoiceForward: {
none: {}
}
},
{
invoiceForward: {
some: {
invoice: {
actionType: 'ZAP'
}
}
}
}
]
} }
}) })
if (wdrwl) { if (wdrwl) {

View File

@ -1,15 +1,14 @@
import { import {
payViaPaymentRequest,
getInvoice as getInvoiceFromLnd, deletePayment, getPayment, getInvoice as getInvoiceFromLnd, deletePayment, getPayment,
parsePaymentRequest parsePaymentRequest
} from 'ln-service' } from 'ln-service'
import crypto, { timingSafeEqual } from 'crypto' import crypto, { timingSafeEqual } from 'crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item' import { SELECT, itemQueryWithMeta } from './item'
import { formatMsats, formatSats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format' import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib/format'
import { import {
USER_ID, INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS USER_ID, INVOICE_RETENTION_DAYS,
PAID_ACTION_PAYMENT_METHODS
} from '@/lib/constants' } from '@/lib/constants'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
@ -24,6 +23,7 @@ import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate' import validateWallet from '@/wallets/validate'
import { canReceive } from '@/wallets/common' import { canReceive } from '@/wallets/common'
import performPaidAction from '../paidAction' import performPaidAction from '../paidAction'
import performPayingAction from '../payingAction'
function injectResolvers (resolvers) { function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:') console.group('injected GraphQL resolvers:')
@ -190,6 +190,18 @@ const resolvers = {
}) })
}, },
withdrawl: getWithdrawl, 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 }) => { numBolt11s: async (parent, args, { me, models, lnd }) => {
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
@ -251,6 +263,17 @@ const resolvers = {
AND "Withdrawl".created_at <= $2 AND "Withdrawl".created_at <= $2
GROUP BY "Withdrawl".id)` 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')) { if (include.has('stacked')) {
@ -436,33 +459,32 @@ const resolvers = {
WalletDetails: { WalletDetails: {
__resolveType: wallet => wallet.__resolveType __resolveType: wallet => wallet.__resolveType
}, },
InvoiceOrDirect: {
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
},
Mutation: { Mutation: {
createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => { createInvoice: async (parent, { amount }, { me, models, lnd, headers }) => {
await validateSchema(amountSchema, { amount }) await validateSchema(amountSchema, { amount })
await assertGofacYourself({ models, headers }) await assertGofacYourself({ models, headers })
const { invoice } = await performPaidAction('RECEIVE', { const { invoice, paymentMethod } = await performPaidAction('RECEIVE', {
msats: satsToMsats(amount) msats: satsToMsats(amount)
}, { models, lnd, me }) }, { models, lnd, me })
return invoice return {
...invoice,
__resolveType:
paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT ? 'Direct' : 'Invoice'
}
}, },
createWithdrawl: createWithdrawal, createWithdrawl: createWithdrawal,
sendToLnAddr, sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => { cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => {
verifyHmac(hash, hmac) verifyHmac(hash, hmac)
const dbInv = await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) 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 })
}
return await models.invoice.findFirst({ where: { hash } }) return await models.invoice.findFirst({ where: { hash } })
}, },
dropBolt11: async (parent, { id }, { me, models, lnd }) => { dropBolt11: async (parent, { hash }, { me, models, lnd }) => {
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
} }
@ -470,20 +492,20 @@ const resolvers = {
const retention = `${INVOICE_RETENTION_DAYS} days` const retention = `${INVOICE_RETENTION_DAYS} days`
const [invoice] = await models.$queryRaw` const [invoice] = await models.$queryRaw`
WITH to_be_updated AS ( WITH to_be_updated AS (
SELECT id, hash, bolt11 SELECT id, hash, bolt11
FROM "Withdrawl" FROM "Withdrawl"
WHERE "userId" = ${me.id} WHERE "userId" = ${me.id}
AND id = ${Number(id)} AND hash = ${hash}
AND now() > created_at + ${retention}::INTERVAL AND now() > created_at + ${retention}::INTERVAL
AND hash IS NOT NULL AND hash IS NOT NULL
AND status IS NOT NULL AND status IS NOT NULL
), updated_rows AS ( ), updated_rows AS (
UPDATE "Withdrawl" UPDATE "Withdrawl"
SET hash = NULL, bolt11 = NULL, preimage = NULL SET hash = NULL, bolt11 = NULL, preimage = NULL
FROM to_be_updated FROM to_be_updated
WHERE "Withdrawl".id = to_be_updated.id) WHERE "Withdrawl".id = to_be_updated.id)
SELECT * FROM to_be_updated;` SELECT * FROM to_be_updated;`
if (invoice) { if (invoice) {
try { try {
@ -497,7 +519,16 @@ const resolvers = {
throw new GqlInputError('failed to drop bolt11 from lnd') 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 }) => { setWalletPriority: async (parent, { id, priority }, { me, models }) => {
if (!me) { if (!me) {
@ -538,11 +569,11 @@ const resolvers = {
Withdrawl: { Withdrawl: {
satsPaying: w => msatsToSats(w.msatsPaying), satsPaying: w => msatsToSats(w.msatsPaying),
satsPaid: w => msatsToSats(w.msatsPaid), satsPaid: w => msatsToSats(w.msatsPaid),
satsFeePaying: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaying), satsFeePaying: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaying),
satsFeePaid: w => w.invoiceForward?.length > 0 ? 0 : msatsToSats(w.msatsFeePaid), satsFeePaid: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaid),
// we never want to fetch the sensitive data full monty in nested resolvers // we never want to fetch the sensitive data full monty in nested resolvers
forwardedActionType: async (withdrawl, args, { models }) => { forwardedActionType: async (withdrawl, args, { models }) => {
return (await models.invoiceForward.findFirst({ return (await models.invoiceForward.findUnique({
where: { withdrawlId: Number(withdrawl.id) }, where: { withdrawlId: Number(withdrawl.id) },
include: { include: {
invoice: true invoice: true
@ -551,7 +582,7 @@ const resolvers = {
}, },
preimage: async (withdrawl, args, { lnd }) => { preimage: async (withdrawl, args, { lnd }) => {
try { try {
if (withdrawl.status === 'CONFIRMED') { if (withdrawl.status === 'CONFIRMED' && withdrawl.hash) {
return withdrawl.preimage ?? (await getPayment({ id: withdrawl.hash, lnd })).payment.secret return withdrawl.preimage ?? (await getPayment({ id: withdrawl.hash, lnd })).payment.secret
} }
} catch (err) { } 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: { Invoice: {
satsReceived: i => msatsToSats(i.msatsReceived), 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') 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 // 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 // we can't allow this because it creates two outgoing payments from our node
// with the same hash // 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') throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
} }
const autoWithdraw = !!wallet?.id return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd })
// 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
} }
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }, export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },

View File

@ -108,6 +108,7 @@ export default gql`
wildWestMode: Boolean! wildWestMode: Boolean!
withdrawMaxFeeDefault: Int! withdrawMaxFeeDefault: Int!
proxyReceive: Boolean proxyReceive: Boolean
directReceive: Boolean
} }
type AuthMethods { type AuthMethods {
@ -187,6 +188,7 @@ export default gql`
vaultKeyHash: String vaultKeyHash: String
walletsUpdatedAt: Date walletsUpdatedAt: Date
proxyReceive: Boolean proxyReceive: Boolean
directReceive: Boolean
} }
type UserOptional { type UserOptional {

View File

@ -64,6 +64,7 @@ const typeDefs = `
extend type Query { extend type Query {
invoice(id: ID!): Invoice! invoice(id: ID!): Invoice!
withdrawl(id: ID!): Withdrawl! withdrawl(id: ID!): Withdrawl!
direct(id: ID!): Direct!
numBolt11s: Int! numBolt11s: Int!
connectAddress: String! connectAddress: String!
walletHistory(cursor: String, inc: String): History walletHistory(cursor: String, inc: String): History
@ -74,16 +75,20 @@ const typeDefs = `
} }
extend type Mutation { extend type Mutation {
createInvoice(amount: Int!): Invoice! createInvoice(amount: Int!): InvoiceOrDirect!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl! createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl! sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String!): Invoice! cancelInvoice(hash: String!, hmac: String!): Invoice!
dropBolt11(id: ID): Withdrawl dropBolt11(hash: String!): Boolean
removeWallet(id: ID!): Boolean removeWallet(id: ID!): Boolean
deleteWalletLogs(wallet: String): Boolean deleteWalletLogs(wallet: String): Boolean
setWalletPriority(id: ID!, priority: Int!): Boolean setWalletPriority(id: ID!, priority: Int!): Boolean
} }
interface InvoiceOrDirect {
id: ID!
}
type Wallet { type Wallet {
id: ID! id: ID!
createdAt: Date! createdAt: Date!
@ -101,7 +106,7 @@ const typeDefs = `
autoWithdrawMaxFeeTotal: Int! autoWithdrawMaxFeeTotal: Int!
} }
type Invoice { type Invoice implements InvoiceOrDirect {
id: ID! id: ID!
createdAt: Date! createdAt: Date!
hash: String! hash: String!
@ -141,6 +146,18 @@ const typeDefs = `
forwardedActionType: String 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 { type Fact {
id: ID! id: ID!
createdAt: Date! createdAt: Date!

View File

@ -2,7 +2,7 @@ import { InputGroup } from 'react-bootstrap'
import { Input } from './form' import { Input } from './form'
import { useMe } from './me' import { useMe } from './me'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { isNumber } from '@/lib/validate' import { isNumber } from '@/lib/format'
import Link from 'next/link' import Link from 'next/link'
function autoWithdrawThreshold ({ me }) { function autoWithdrawThreshold ({ me }) {

View File

@ -103,7 +103,7 @@ export default function Invoice ({
) )
} }
const { nostr, comment, lud18Data, bolt11, confirmedPreimage } = invoice const { bolt11, confirmedPreimage } = invoice
return ( return (
<> <>
@ -120,36 +120,7 @@ export default function Invoice ({
{!modal && {!modal &&
<> <>
{info && <div className='text-muted fst-italic text-center'>{info}</div>} {info && <div className='text-muted fst-italic text-center'>{info}</div>}
<div className='w-100'> <InvoiceExtras {...invoice} />
{nostr
? <AccordianItem
header='Nostr Zap Request'
body={
<pre>
<code>
{JSON.stringify(nostr, null, 2)}
</code>
</pre>
}
/>
: null}
</div>
{lud18Data &&
<div className='w-100'>
<AccordianItem
header='sender information'
body={<PayerData data={lud18Data} className='text-muted ms-3' />}
className='mb-3'
/>
</div>}
{comment &&
<div className='w-100'>
<AccordianItem
header='sender comments'
body={<span className='text-muted ms-3'>{comment}</span>}
className='mb-3'
/>
</div>}
<Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} /> <Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} />
{invoice?.item && <ActionInfo invoice={invoice} />} {invoice?.item && <ActionInfo invoice={invoice} />}
</>} </>}
@ -157,6 +128,43 @@ export default function Invoice ({
) )
} }
export function InvoiceExtras ({ nostr, lud18Data, comment }) {
return (
<>
<div className='w-100'>
{nostr
? <AccordianItem
header='Nostr Zap Request'
body={
<pre>
<code>
{JSON.stringify(nostr, null, 2)}
</code>
</pre>
}
/>
: null}
</div>
{lud18Data &&
<div className='w-100'>
<AccordianItem
header='sender information'
body={<PayerData data={lud18Data} className='text-muted ms-3' />}
className='mb-3'
/>
</div>}
{comment &&
<div className='w-100'>
<AccordianItem
header='sender comments'
body={<span className='text-muted ms-3'>{comment}</span>}
className='mb-3'
/>
</div>}
</>
)
}
function ActionInfo ({ invoice }) { function ActionInfo ({ invoice }) {
if (!invoice.actionType) return null if (!invoice.actionType) return null

View File

@ -1,7 +1,7 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
import { useWallet } from '@/wallets/index' 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 '@/fragments/wallet'
import Invoice from '@/components/invoice' import Invoice from '@/components/invoice'
import { useShowModal } from './modal' import { useShowModal } from './modal'
@ -10,17 +10,6 @@ import { InvoiceCanceledError, NoAttachedWalletError, InvoiceExpiredError } from
export const useInvoice = () => { export const useInvoice = () => {
const client = useApolloClient() 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` const [cancelInvoice] = useMutation(gql`
mutation cancelInvoice($hash: String!, $hmac: String!) { mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) { 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 isInvoice = useCallback(async ({ id }, that) => {
const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } }) const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } })
if (error) { if (error) {
@ -73,7 +53,7 @@ export const useInvoice = () => {
return inv return inv
}, [cancelInvoice]) }, [cancelInvoice])
return { create, cancel, isInvoice } return { cancel, isInvoice }
} }
const invoiceController = (id, isInvoice) => { const invoiceController = (id, isInvoice) => {

View File

@ -170,7 +170,7 @@ export function useWalletLogger (wallet, setLogs) {
const decoded = bolt11Decode(context.bolt11) const decoded = bolt11Decode(context.bolt11)
context = { context = {
...context, ...context,
amount: formatMsats(Number(decoded.millisatoshis)), amount: formatMsats(decoded.millisatoshis),
payment_hash: decoded.tagsObject.payment_hash, payment_hash: decoded.tagsObject.payment_hash,
description: decoded.tagsObject.description, description: decoded.tagsObject.description,
created_at: new Date(decoded.timestamp * 1000).toISOString(), created_at: new Date(decoded.timestamp * 1000).toISOString(),

View File

@ -51,6 +51,7 @@ ${STREAK_FIELDS}
vaultKeyHash vaultKeyHash
walletsUpdatedAt walletsUpdatedAt
proxyReceive proxyReceive
directReceive
} }
optional { optional {
isContributor isContributor
@ -113,6 +114,7 @@ export const SETTINGS_FIELDS = gql`
} }
apiKeyEnabled apiKeyEnabled
proxyReceive proxyReceive
directReceive
} }
}` }`

View File

@ -53,6 +53,7 @@ export const WITHDRAWL = gql`
id id
createdAt createdAt
bolt11 bolt11
hash
satsPaid satsPaid
satsFeePaying satsFeePaying
satsFeePaid 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` export const WALLET_HISTORY = gql`
${ITEM_FULL_FIELDS} ${ITEM_FULL_FIELDS}

View File

@ -7,6 +7,7 @@ export const PAID_ACTION_PAYMENT_METHODS = {
FEE_CREDIT: 'FEE_CREDIT', FEE_CREDIT: 'FEE_CREDIT',
PESSIMISTIC: 'PESSIMISTIC', PESSIMISTIC: 'PESSIMISTIC',
OPTIMISTIC: 'OPTIMISTIC', OPTIMISTIC: 'OPTIMISTIC',
DIRECT: 'DIRECT',
P2P: 'P2P' P2P: 'P2P'
} }
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING'] export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']

View File

@ -73,7 +73,7 @@ export const msatsToSatsDecimal = msats => {
} }
export const formatSats = (sats) => numWithUnits(sats, { unitSingular: 'sat', unitPlural: 'sats', abbreviate: false }) 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 => { export const hexToB64 = hexstring => {
return btoa(hexstring.match(/\w{2}/g).map(function (a) { return btoa(hexstring.match(/\w{2}/g).map(function (a) {
@ -128,3 +128,79 @@ export function giveOrdinalSuffix (i) {
} }
return i + 'th' 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)
}

View File

@ -513,79 +513,3 @@ export const deviceSyncSchema = object().shape({
return true 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)
}

View File

@ -350,12 +350,12 @@ export async function notifyDeposit (userId, invoice) {
} }
} }
export async function notifyWithdrawal (userId, wdrwl) { export async function notifyWithdrawal (wdrwl) {
try { try {
await sendUserNotification(userId, { await sendUserNotification(wdrwl.userId, {
title: `${numWithUnits(msatsToSats(wdrwl.payment.mtokens), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`, title: `${numWithUnits(msatsToSats(wdrwl.msatsPaid), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`,
tag: 'WITHDRAWAL', tag: 'WITHDRAWAL',
data: { sats: msatsToSats(wdrwl.payment.mtokens) } data: { sats: msatsToSats(wdrwl.msatsPaid) }
}) })
} catch (err) { } catch (err) {
console.error(err) console.error(err)

View File

@ -4,7 +4,7 @@ import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescrip
import { schnorr } from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { LNURLP_COMMENT_MAX_LENGTH, MAX_INVOICE_DESCRIPTION_LENGTH } from '@/lib/constants' 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 assertGofacYourself from '@/api/resolvers/ofac'
import performPaidAction from '@/api/paidAction' import performPaidAction from '@/api/paidAction'
@ -91,7 +91,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
return res.status(200).json({ return res.status(200).json({
pr: invoice.bolt11, pr: invoice.bolt11,
routes: [], 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) { } catch (error) {
console.log(error) console.log(error)

66
pages/directs/[id].js Normal file
View File

@ -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 (
<CenterLayout>
<LoadDirect />
</CenterLayout>
)
}
export function DirectSkeleton ({ status }) {
return (
<>
<div className='w-100 form-group'>
<QrSkeleton status={status} />
</div>
<div className='w-100 mt-3'>
<Bolt11Info />
</div>
</>
)
}
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 <div>error</div>
if (!data || loading) {
return <DirectSkeleton status='loading' />
}
return (
<>
<Qr
value={data.direct.bolt11}
description={numWithUnits(data.direct.sats, { abbreviate: false })}
statusVariant='pending' status='direct payment to attached wallet'
/>
<div className='w-100 mt-3'>
<InvoiceExtras {...data.direct} />
<Bolt11Info bolt11={data.direct.bolt11} preimage={data.direct.preimage} />
<div className='w-100 mt-3'>
<PrivacyOption payment={data.direct} />
</div>
</div>
</>
)
}

View File

@ -24,7 +24,7 @@ export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY,
function satusClass (status) { function satusClass (status) {
if (!status) { if (!status) {
return '' return 'text-reset'
} }
switch (status) { switch (status) {

View File

@ -159,7 +159,8 @@ export default function Settings ({ ssrData }) {
diagnostics: settings?.diagnostics, diagnostics: settings?.diagnostics,
hideIsContributor: settings?.hideIsContributor, hideIsContributor: settings?.hideIsContributor,
noReferralLinks: settings?.noReferralLinks, noReferralLinks: settings?.noReferralLinks,
proxyReceive: settings?.proxyReceive proxyReceive: settings?.proxyReceive,
directReceive: settings?.directReceive
}} }}
schema={settingsSchema} schema={settingsSchema}
onSubmit={async ({ onSubmit={async ({
@ -339,7 +340,7 @@ export default function Settings ({ ssrData }) {
<div className='d-flex align-items-center'>proxy deposits to attached wallets <div className='d-flex align-items-center'>proxy deposits to attached wallets
<Info> <Info>
<ul> <ul>
<li>Forward deposits directly to your attached wallets if they will cause your balance to exceed your auto-withdraw threshold</li> <li>Forward deposits directly to your attached wallets if they cause your balance to exceed your auto-withdraw threshold</li>
<li>Payments will be wrapped by the SN node to preserve your wallet's privacy</li> <li>Payments will be wrapped by the SN node to preserve your wallet's privacy</li>
<li>This will incur in a 10% fee</li> <li>This will incur in a 10% fee</li>
</ul> </ul>
@ -349,6 +350,22 @@ export default function Settings ({ ssrData }) {
name='proxyReceive' name='proxyReceive'
groupClassName='mb-0' groupClassName='mb-0'
/> />
<Checkbox
label={
<div className='d-flex align-items-center'>directly deposit to attached wallets
<Info>
<ul>
<li>Directly deposit to your attached wallets if they cause your balance to exceed your auto-withdraw threshold</li>
<li>Senders will be able to see your wallet's lightning node public key</li>
<li>If 'proxy deposits' is also checked, it will take precedence and direct deposits will only be used as a fallback</li>
<li>Because we can't determine if a payment succeeds, you won't be notified about direct deposits</li>
</ul>
</Info>
</div>
}
name='directReceive'
groupClassName='mb-0'
/>
<Checkbox <Checkbox
label={ label={
<div className='d-flex align-items-center'>hide invoice descriptions <div className='d-flex align-items-center'>hide invoice descriptions

View File

@ -114,6 +114,7 @@ export function FundForm () {
const [createInvoice, { called, error }] = useMutation(gql` const [createInvoice, { called, error }] = useMutation(gql`
mutation createInvoice($amount: Int!) { mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount) { createInvoice(amount: $amount) {
__typename
id id
} }
}`) }`)
@ -147,7 +148,11 @@ export function FundForm () {
schema={amountSchema} schema={amountSchema}
onSubmit={async ({ amount }) => { onSubmit={async ({ amount }) => {
const { data } = await createInvoice({ variables: { amount: Number(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}`)
}
}} }}
> >
<Input <Input

View File

@ -118,18 +118,18 @@ function LoadWithdrawl () {
<InvoiceStatus variant={variant} status={status} /> <InvoiceStatus variant={variant} status={status} />
<div className='w-100 mt-3'> <div className='w-100 mt-3'>
<Bolt11Info bolt11={data.withdrawl.bolt11} preimage={data.withdrawl.preimage}> <Bolt11Info bolt11={data.withdrawl.bolt11} preimage={data.withdrawl.preimage}>
<PrivacyOption wd={data.withdrawl} /> <PrivacyOption payment={data.withdrawl} />
</Bolt11Info> </Bolt11Info>
</div> </div>
</> </>
) )
} }
function PrivacyOption ({ wd }) { export function PrivacyOption ({ payment }) {
if (!wd.bolt11) return if (!payment.bolt11) return
const { me } = useMe() 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 const oldEnough = new Date() >= keepUntil
if (!oldEnough) { if (!oldEnough) {
return ( return (
@ -143,19 +143,19 @@ function PrivacyOption ({ wd }) {
const toaster = useToast() const toaster = useToast()
const [dropBolt11] = useMutation( const [dropBolt11] = useMutation(
gql` gql`
mutation dropBolt11($id: ID!) { mutation dropBolt11($hash: String!) {
dropBolt11(id: $id) { dropBolt11(hash: $hash)
id
}
}`, { }`, {
update (cache) { update (cache, { data }) {
cache.modify({ if (data.dropBolt11) {
id: `Withdrawl:${wd.id}`, cache.modify({
fields: { id: `${payment.__typename}:${payment.id}`,
bolt11: () => null, fields: {
hash: () => null bolt11: () => null,
} hash: () => null
}) }
})
}
} }
}) })
@ -169,7 +169,7 @@ function PrivacyOption ({ wd }) {
onConfirm={async () => { onConfirm={async () => {
if (me) { if (me) {
try { try {
await dropBolt11({ variables: { id: wd.id } }) await dropBolt11({ variables: { hash: payment.hash } })
} catch (err) { } catch (err) {
toaster.danger('unable to delete invoice: ' + err.message || err.toString?.()) toaster.danger('unable to delete invoice: ' + err.message || err.toString?.())
throw err throw err

View File

@ -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");

View File

@ -141,6 +141,9 @@ model User {
walletsUpdatedAt DateTime? walletsUpdatedAt DateTime?
vaultEntries VaultEntry[] @relation("VaultEntries") vaultEntries VaultEntry[] @relation("VaultEntries")
proxyReceive Boolean @default(false) proxyReceive Boolean @default(false)
directReceive Boolean @default(false)
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -217,6 +220,7 @@ model Wallet {
vaultEntries VaultEntry[] @relation("VaultEntries") vaultEntries VaultEntry[] @relation("VaultEntries")
withdrawals Withdrawl[] withdrawals Withdrawl[]
InvoiceForward InvoiceForward[] InvoiceForward InvoiceForward[]
DirectPayment DirectPayment[]
@@index([userId]) @@index([userId])
@@index([priority]) @@index([priority])
@ -940,6 +944,29 @@ model Invoice {
@@index([actionState]) @@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 { model InvoiceForward {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@ -954,7 +981,7 @@ model InvoiceForward {
// we get these values when the outgoing invoice is settled // we get these values when the outgoing invoice is settled
invoiceId Int @unique invoiceId Int @unique
withdrawlId Int? withdrawlId Int? @unique
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
@ -982,7 +1009,7 @@ model Withdrawl {
walletId Int? walletId Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull)
invoiceForward InvoiceForward[] invoiceForward InvoiceForward?
@@index([createdAt], map: "Withdrawl.created_at_index") @@index([createdAt], map: "Withdrawl.created_at_index")
@@index([userId], map: "Withdrawl.userId_index") @@index([userId], map: "Withdrawl.userId_index")

2
sndev
View File

@ -261,7 +261,7 @@ sndev__withdraw() {
if [ "$1" = "--cln" ]; then if [ "$1" = "--cln" ]; then
shift shift
label=$(date +%s) 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 else
sndev__cli lnd addinvoice --amt "$@" | jq -j '.payment_request'; echo sndev__cli lnd addinvoice --amt "$@" | jq -j '.payment_request'; echo
fi fi

View File

@ -14,11 +14,10 @@ import * as webln from '@/wallets/webln'
import { walletLogger } from '@/api/resolvers/wallet' import { walletLogger } from '@/api/resolvers/wallet'
import walletDefs from '@/wallets/server' import walletDefs from '@/wallets/server'
import { parsePaymentRequest } from 'ln-service' 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 { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { withTimeout } from '@/lib/time' import { withTimeout } from '@/lib/time'
import { canReceive } from './common' import { canReceive } from './common'
import { formatMsats, formatSats, msatsToSats } from '@/lib/format'
import wrapInvoice from './wrap' import wrapInvoice from './wrap'
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]

View File

@ -1,6 +1,6 @@
import { createHodlInvoice, parsePaymentRequest } from 'ln-service' import { createHodlInvoice, parsePaymentRequest } from 'ln-service'
import { estimateRouteFee, getBlockHeight } from '../api/lnd' 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 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 const MAX_OUTGOING_MSATS = BigInt(900_000_000) // the maximum msats we'll allow for the outgoing invoice

43
worker/autoDropBolt11.js Normal file
View File

@ -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`
}

View File

@ -3,7 +3,7 @@ import './loadenv'
import PgBoss from 'pg-boss' import PgBoss from 'pg-boss'
import createPrisma from '@/lib/create-prisma' import createPrisma from '@/lib/create-prisma'
import { import {
autoDropBolt11s, checkInvoice, checkPendingDeposits, checkPendingWithdrawals, checkInvoice, checkPendingDeposits, checkPendingWithdrawals,
checkWithdrawal, checkWithdrawal,
finalizeHodlInvoice, subscribeToWallet finalizeHodlInvoice, subscribeToWallet
} from './wallet' } from './wallet'
@ -35,6 +35,8 @@ import { thisDay } from './thisDay'
import { isServiceEnabled } from '@/lib/sndev' import { isServiceEnabled } from '@/lib/sndev'
import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts' import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts'
import { expireBoost } from './expireBoost' import { expireBoost } from './expireBoost'
import { payingActionConfirmed, payingActionFailed } from './payingAction'
import { autoDropBolt11s } from './autoDropBolt11'
async function work () { async function work () {
const boss = new PgBoss(process.env.DATABASE_URL) const boss = new PgBoss(process.env.DATABASE_URL)
@ -102,6 +104,9 @@ async function work () {
await boss.work('paidActionCanceling', jobWrapper(paidActionCanceling)) await boss.work('paidActionCanceling', jobWrapper(paidActionCanceling))
await boss.work('paidActionFailed', jobWrapper(paidActionFailed)) await boss.work('paidActionFailed', jobWrapper(paidActionFailed))
await boss.work('paidActionPaid', jobWrapper(paidActionPaid)) await boss.work('paidActionPaid', jobWrapper(paidActionPaid))
// payingAction jobs
await boss.work('payingActionFailed', jobWrapper(payingActionFailed))
await boss.work('payingActionConfirmed', jobWrapper(payingActionConfirmed))
} }
if (isServiceEnabled('search')) { if (isServiceEnabled('search')) {
await boss.work('indexItem', jobWrapper(indexItem)) await boss.work('indexItem', jobWrapper(indexItem))

View File

@ -1,14 +1,13 @@
import { getPaymentFailureStatus, hodlInvoiceCltvDetails } from '@/api/lnd' import { getPaymentFailureStatus, hodlInvoiceCltvDetails, getPaymentOrNotSent } from '@/api/lnd'
import { paidActions } from '@/api/paidAction' import { paidActions } from '@/api/paidAction'
import { walletLogger } from '@/api/resolvers/wallet' import { walletLogger } from '@/api/resolvers/wallet'
import { LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' 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 { datePivot } from '@/lib/time'
import { toPositiveNumber } from '@/lib/validate'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { import {
cancelHodlInvoice, cancelHodlInvoice,
getInvoice, getPayment, parsePaymentRequest, getInvoice, parsePaymentRequest,
payViaPaymentRequest, settleHodlInvoice payViaPaymentRequest, settleHodlInvoice
} from 'ln-service' } from 'ln-service'
import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap' 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 args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const context = { const context = {
tx, 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 // 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 }) { export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...args }, models, lnd, boss }) {
return await transitionInvoice('paidActionForwarded', { const transitionedInvoice = await transitionInvoice('paidActionForwarded', {
invoiceId, invoiceId,
fromState: 'FORWARDING', fromState: 'FORWARDING',
toState: 'FORWARDED', toState: 'FORWARDED',
@ -287,8 +286,9 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a
throw new Error('invoice is not held') throw new Error('invoice is not held')
} }
const { bolt11, hash, msatsPaying } = dbInvoice.invoiceForward.withdrawl const { hash, msatsPaying, createdAt } = dbInvoice.invoiceForward.withdrawl
const { payment, is_confirmed: isConfirmed } = withdrawal ?? await getPayment({ id: hash, lnd }) const { payment, is_confirmed: isConfirmed } = withdrawal ??
await getPaymentOrNotSent({ id: hash, lnd, createdAt })
if (!isConfirmed) { if (!isConfirmed) {
throw new Error('payment is not confirmed') 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 // settle the invoice, allowing us to transition to PAID
await settleHodlInvoice({ secret: payment.secret, lnd }) 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 { return {
preimage: payment.secret, preimage: payment.secret,
invoiceForward: { invoiceForward: {
@ -328,11 +314,31 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a
}, },
...args ...args
}, { models, lnd, boss }) }, { 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 // when the pending forward fails, we need to cancel the incoming invoice
export async function paidActionFailedForward ({ data: { invoiceId, withdrawal: pWithdrawal, ...args }, models, lnd, boss }) { export async function paidActionFailedForward ({ data: { invoiceId, withdrawal: pWithdrawal, ...args }, models, lnd, boss }) {
return await transitionInvoice('paidActionFailedForward', { let message
const transitionedInvoice = await transitionInvoice('paidActionFailedForward', {
invoiceId, invoiceId,
fromState: 'FORWARDING', fromState: 'FORWARDING',
toState: 'FAILED_FORWARD', toState: 'FAILED_FORWARD',
@ -341,21 +347,10 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal:
throw new Error('invoice is not held') throw new Error('invoice is not held')
} }
let withdrawal const { hash, createdAt } = dbInvoice.invoiceForward.withdrawl
let notSent = false const withdrawal = pWithdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt })
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
}
}
if (!(withdrawal?.is_failed || notSent)) { if (!(withdrawal?.is_failed || withdrawal?.notSent)) {
throw new Error('payment has not failed') 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 // 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) await boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash }, FINALIZE_OPTIONS)
const { status, message } = getPaymentFailureStatus(withdrawal) const { status, message: failureMessage } = getPaymentFailureStatus(withdrawal)
const { bolt11, msatsFeePaying } = dbInvoice.invoiceForward.withdrawl message = failureMessage
const logger = walletLogger({ wallet: dbInvoice.invoiceForward.wallet, models })
logger.warn(
`incoming payment failed: ${message}`, {
bolt11,
max_fee: formatMsats(Number(msatsFeePaying))
})
return { return {
invoiceForward: { invoiceForward: {
@ -386,6 +375,18 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal:
}, },
...args ...args
}, { models, lnd, boss }) }, { 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 }) { 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 }) { export async function paidActionCanceling ({ data: { invoiceId, ...args }, models, lnd, boss }) {
return await transitionInvoice('paidActionCanceling', { const transitionedInvoice = await transitionInvoice('paidActionCanceling', {
invoiceId, invoiceId,
fromState: ['HELD', 'PENDING', 'PENDING_HELD', 'FAILED_FORWARD'], fromState: ['HELD', 'PENDING', 'PENDING_HELD', 'FAILED_FORWARD'],
toState: 'CANCELING', toState: 'CANCELING',
@ -440,6 +441,17 @@ export async function paidActionCanceling ({ data: { invoiceId, ...args }, model
}, },
...args ...args
}, { models, lnd, boss }) }, { 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 }) { export async function paidActionFailed ({ data: { invoiceId, ...args }, models, lnd, boss }) {

176
worker/payingAction.js Normal file
View File

@ -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)
})
}
}

View File

@ -1,11 +1,9 @@
import serialize from '@/api/resolvers/serial'
import { import {
getInvoice, getPayment, cancelHodlInvoice, deletePayment, getInvoice,
subscribeToInvoices, subscribeToPayments, subscribeToInvoice subscribeToInvoices, subscribeToPayments, subscribeToInvoice
} from 'ln-service' } from 'ln-service'
import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush' import { getPaymentOrNotSent } from '@/api/lnd'
import { INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' import { sleep } from '@/lib/time'
import { datePivot, sleep } from '@/lib/time'
import retry from 'async-retry' import retry from 'async-retry'
import { import {
paidActionPaid, paidActionForwarded, paidActionPaid, paidActionForwarded,
@ -13,9 +11,7 @@ import {
paidActionForwarding, paidActionForwarding,
paidActionCanceling paidActionCanceling
} from './paidAction' } from './paidAction'
import { getPaymentFailureStatus } from '@/api/lnd/index.js' import { payingActionConfirmed, payingActionFailed } from './payingAction'
import { walletLogger } from '@/api/resolvers/wallet.js'
import { formatMsats, formatSats, msatsToSats } from '@/lib/format.js'
export async function subscribeToWallet (args) { export async function subscribeToWallet (args) {
await subscribeToDeposits(args) await subscribeToDeposits(args)
@ -143,19 +139,6 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd
if (dbInv.actionType) { if (dbInv.actionType) {
return await paidActionPaid({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss }) 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) { if (inv.is_held) {
@ -175,18 +158,6 @@ export async function checkInvoice ({ data: { hash, invoice }, boss, models, lnd
if (dbInv.actionType) { if (dbInv.actionType) {
return await paidActionFailed({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss }) 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, hash,
OR: [ OR: [
{ status: null }, { status: null },
{ invoiceForward: { some: { } } } { invoiceForward: { isNot: null } }
] ]
}, },
include: { include: {
wallet: true, wallet: true,
invoiceForward: { invoiceForward: {
orderBy: { createdAt: 'desc' },
include: { include: {
invoice: true 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 // nothing to do if the withdrawl is already recorded and it isn't an invoiceForward
if (!dbWdrwl) return if (!dbWdrwl) return
let wdrwl const wdrwl = withdrawal ?? await getPaymentOrNotSent({ id: hash, lnd, createdAt: dbWdrwl.createdAt })
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 })
if (wdrwl?.is_confirmed) { if (wdrwl?.is_confirmed) {
if (dbWdrwl.invoiceForward.length > 0) { if (dbWdrwl.invoiceForward) {
return await paidActionForwarded({ data: { invoiceId: dbWdrwl.invoiceForward[0].invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss }) return await paidActionForwarded({ data: { invoiceId: dbWdrwl.invoiceForward.invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss })
} }
const fee = Number(wdrwl.payment.fee_mtokens) return await payingActionConfirmed({ data: { withdrawalId: dbWdrwl.id, withdrawal: wdrwl }, models, lnd, boss })
const paid = Number(wdrwl.payment.mtokens) - fee } else if (wdrwl?.is_failed || wdrwl?.notSent) {
const [[{ confirm_withdrawl: code }]] = await serialize([ if (dbWdrwl.invoiceForward) {
models.$queryRaw`SELECT confirm_withdrawl(${dbWdrwl.id}::INTEGER, ${paid}, ${fee})`, return await paidActionFailedForward({ data: { invoiceId: dbWdrwl.invoiceForward.invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss })
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 })
} }
const { message, status } = getPaymentFailureStatus(wdrwl) return await payingActionFailed({ data: { withdrawalId: dbWdrwl.id, withdrawal: wdrwl }, models, lnd, boss })
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 }
})
}
}
} }
} }
@ -360,33 +247,16 @@ export async function finalizeHodlInvoice ({ data: { hash }, models, lnd, boss,
return return
} }
const dbInv = await models.invoice.findUnique({ const dbInv = await models.invoice.findUnique({ where: { hash } })
where: { hash },
include: {
invoiceForward: {
include: {
withdrawl: true,
wallet: true
}
}
}
})
if (!dbInv) { if (!dbInv) {
console.log('invoice not found in database', hash) console.log('invoice not found in database', hash)
return return
} }
// if this is an actionType we need to cancel conditionally await paidActionCanceling({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss })
if (dbInv.actionType) {
await paidActionCanceling({ data: { invoiceId: dbInv.id, invoice: inv }, models, lnd, boss })
} else {
await cancelHodlInvoice({ id: hash, lnd })
}
// sync LND invoice status with invoice status in database // sync LND invoice status with invoice status in database
await checkInvoice({ data: { hash }, models, lnd, boss }) await checkInvoice({ data: { hash }, models, lnd, boss })
return dbInv
} }
export async function checkPendingDeposits (args) { export async function checkPendingDeposits (args) {