Compare commits
6 Commits
b0207a2906
...
707d7bdf8b
Author | SHA1 | Date |
---|---|---|
soxa | 707d7bdf8b | |
Riccardo Balbo | e05989d371 | |
Riccardo Balbo | 6630899e79 | |
ekzyis | a032da57b9 | |
Keyan | 0bff478d39 | |
ekzyis | 8b5e13236b |
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
@ -256,7 +316,7 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
|||
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
|
||||
}
|
||||
|
||||
const { msatsRequested, actionId } = failedInvoice
|
||||
const { msatsRequested, actionId, actionArgs } = failedInvoice
|
||||
const retryContext = {
|
||||
...incomingContext,
|
||||
optimistic: true,
|
||||
|
@ -265,7 +325,7 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
|||
actionId
|
||||
}
|
||||
|
||||
const invoiceArgs = await createSNInvoice(actionType, args, retryContext)
|
||||
const invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)
|
||||
|
||||
return await models.$transaction(async tx => {
|
||||
const context = { ...retryContext, tx, invoiceArgs }
|
||||
|
@ -282,7 +342,7 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
|||
})
|
||||
|
||||
// create a new invoice
|
||||
const invoice = await createDbInvoice(actionType, args, context)
|
||||
const invoice = await createDbInvoice(actionType, actionArgs, context)
|
||||
|
||||
return {
|
||||
result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -440,29 +440,37 @@ export default {
|
|||
}
|
||||
|
||||
if (user.noteWithdrawals) {
|
||||
const wdrwl = await models.withdrawl.findFirst({
|
||||
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
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
invoiceForward: {
|
||||
none: {}
|
||||
}
|
||||
updatedAt: {
|
||||
gt: lastChecked
|
||||
},
|
||||
{
|
||||
invoiceForward: {
|
||||
some: {
|
||||
invoice: {
|
||||
actionType: 'ZAP'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
invoiceForward: { is: null }
|
||||
}
|
||||
})
|
||||
if (wdrwl) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
@ -474,7 +496,7 @@ const resolvers = {
|
|||
SELECT id, hash, bolt11
|
||||
FROM "Withdrawl"
|
||||
WHERE "userId" = ${me.id}
|
||||
AND id = ${Number(id)}
|
||||
AND hash = ${hash}
|
||||
AND now() > created_at + ${retention}::INTERVAL
|
||||
AND hash IS NOT NULL
|
||||
AND status IS NOT NULL
|
||||
|
@ -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 },
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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 }) {
|
||||
|
|
|
@ -360,12 +360,22 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||
onUpload={file => {
|
||||
const uploadMarker = `![Uploading ${file.name}…]()`
|
||||
const text = innerRef.current.value
|
||||
const cursorPosition = innerRef.current.selectionStart || text.length
|
||||
const cursorPosition = innerRef.current.selectionStart
|
||||
let preMarker = text.slice(0, cursorPosition)
|
||||
const postMarker = text.slice(cursorPosition)
|
||||
let postMarker = text.slice(cursorPosition)
|
||||
// when uploading multiple files at once, we want to make sure the upload markers are separated by blank lines
|
||||
if (preMarker && !/\n+\s*$/.test(preMarker)) {
|
||||
preMarker += '\n\n'
|
||||
if (preMarker) {
|
||||
// Count existing newlines at the end of preMarker
|
||||
const existingNewlines = preMarker.match(/[\n]+$/)?.[0].length || 0
|
||||
// Add only the needed newlines to reach 2
|
||||
preMarker += '\n'.repeat(Math.max(0, 2 - existingNewlines))
|
||||
}
|
||||
// if there's text after the cursor, we want to make sure the upload marker is separated by a blank line
|
||||
if (postMarker) {
|
||||
// Count existing newlines at the start of postMarker
|
||||
const existingNewlines = postMarker.match(/^[\n]*/)?.[0].length || 0
|
||||
// Add only the needed newlines to reach 2
|
||||
postMarker = '\n'.repeat(Math.max(0, 2 - existingNewlines)) + postMarker
|
||||
}
|
||||
const newText = preMarker + uploadMarker + postMarker
|
||||
helpers.setValue(newText)
|
||||
|
|
|
@ -103,7 +103,7 @@ export default function Invoice ({
|
|||
)
|
||||
}
|
||||
|
||||
const { nostr, comment, lud18Data, bolt11, confirmedPreimage } = invoice
|
||||
const { bolt11, confirmedPreimage } = invoice
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -120,6 +120,17 @@ export default function Invoice ({
|
|||
{!modal &&
|
||||
<>
|
||||
{info && <div className='text-muted fst-italic text-center'>{info}</div>}
|
||||
<InvoiceExtras {...invoice} />
|
||||
<Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} />
|
||||
{invoice?.item && <ActionInfo invoice={invoice} />}
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function InvoiceExtras ({ nostr, lud18Data, comment }) {
|
||||
return (
|
||||
<>
|
||||
<div className='w-100'>
|
||||
{nostr
|
||||
? <AccordianItem
|
||||
|
@ -150,9 +161,6 @@ export default function Invoice ({
|
|||
className='mb-3'
|
||||
/>
|
||||
</div>}
|
||||
<Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} />
|
||||
{invoice?.item && <ActionInfo invoice={invoice} />}
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -25,13 +25,11 @@ export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnte
|
|||
onDragEnter={onDragEnter}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className={styles.cardMeta}>
|
||||
<div className={styles.indicators}>
|
||||
{status.any !== Status.Disabled && <DraggableIcon className={styles.drag} width={16} height={16} />}
|
||||
{support.recv && <RecvIcon className={`${styles.indicator} ${statusToClass(status.recv)}`} />}
|
||||
{support.send && <SendIcon className={`${styles.indicator} ${statusToClass(status.send)}`} />}
|
||||
</div>
|
||||
</div>
|
||||
<Card.Body
|
||||
// we attach touch listener only to card body to not interfere with wallet link
|
||||
onTouchStart={onTouchStart}
|
||||
|
@ -42,8 +40,8 @@ export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnte
|
|||
>
|
||||
<div className='d-flex text-center align-items-center h-100'>
|
||||
{image
|
||||
? <img width='100%' {...image} />
|
||||
: <Card.Title className='w-100 justify-content-center align-items-center'>{wallet.def.card.title}</Card.Title>}
|
||||
? <img className={styles.walletLogo} {...image} />
|
||||
: <Card.Title className={styles.walletLogo}>{wallet.def.card.title}</Card.Title>}
|
||||
</div>
|
||||
</Card.Body>
|
||||
<Link href={`/settings/wallets/${wallet.def.name}`}>
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -51,6 +51,7 @@ ${STREAK_FIELDS}
|
|||
vaultKeyHash
|
||||
walletsUpdatedAt
|
||||
proxyReceive
|
||||
directReceive
|
||||
}
|
||||
optional {
|
||||
isContributor
|
||||
|
@ -113,6 +114,7 @@ export const SETTINGS_FIELDS = gql`
|
|||
}
|
||||
apiKeyEnabled
|
||||
proxyReceive
|
||||
directReceive
|
||||
}
|
||||
}`
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -24,7 +24,7 @@ export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY,
|
|||
|
||||
function satusClass (status) {
|
||||
if (!status) {
|
||||
return ''
|
||||
return 'text-reset'
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
|
|
|
@ -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 }) {
|
|||
<div className='d-flex align-items-center'>proxy deposits to attached wallets
|
||||
<Info>
|
||||
<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>This will incur in a 10% fee</li>
|
||||
</ul>
|
||||
|
@ -349,6 +350,22 @@ export default function Settings ({ ssrData }) {
|
|||
name='proxyReceive'
|
||||
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
|
||||
label={
|
||||
<div className='d-flex align-items-center'>hide invoice descriptions
|
||||
|
|
|
@ -19,6 +19,7 @@ import validateWallet from '@/wallets/validate'
|
|||
import { ValidationError } from 'yup'
|
||||
import { useFormikContext } from 'formik'
|
||||
import { useWalletImage } from '@/components/wallet-image'
|
||||
import styles from '@/styles/wallet.module.css'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||
|
||||
|
@ -72,7 +73,7 @@ export default function WalletSettings () {
|
|||
return (
|
||||
<CenterLayout>
|
||||
{image
|
||||
? <img {...image} className='pb-3 px-2 mw-100' />
|
||||
? <img {...image} className={styles.walletBanner} />
|
||||
: <h2 className='pb-2'>{wallet.def.card.title}</h2>}
|
||||
<h6 className='text-muted text-center pb-3'><Text>{wallet.def.card.subtitle}</Text></h6>
|
||||
<Form
|
||||
|
|
|
@ -7,6 +7,11 @@ import { useCallback, useState } from 'react'
|
|||
import { useIsClient } from '@/components/use-client'
|
||||
import WalletCard from '@/components/wallet-card'
|
||||
import { useToast } from '@/components/toast'
|
||||
import BootstrapForm from 'react-bootstrap/Form'
|
||||
import RecvIcon from '@/svgs/arrow-left-down-line.svg'
|
||||
import SendIcon from '@/svgs/arrow-right-up-line.svg'
|
||||
import { useRouter } from 'next/router'
|
||||
import { supportsReceive, supportsSend } from '@/wallets/common'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||
|
||||
|
@ -17,6 +22,12 @@ export default function Wallet ({ ssrData }) {
|
|||
const [sourceIndex, setSourceIndex] = useState(null)
|
||||
const [targetIndex, setTargetIndex] = useState(null)
|
||||
|
||||
const router = useRouter()
|
||||
const [filter, setFilter] = useState({
|
||||
send: router.query.send === 'true',
|
||||
receive: router.query.receive === 'true'
|
||||
})
|
||||
|
||||
const reorder = useCallback(async (sourceIndex, targetIndex) => {
|
||||
const newOrder = [...wallets.filter(w => w.config?.enabled)]
|
||||
const [source] = newOrder.splice(sourceIndex, 1)
|
||||
|
@ -65,6 +76,13 @@ export default function Wallet ({ ssrData }) {
|
|||
}
|
||||
}, [sourceIndex, reorder, onReorderError])
|
||||
|
||||
const onFilterChange = useCallback((key) => {
|
||||
return e => {
|
||||
setFilter(old => ({ ...old, [key]: e.target.checked }))
|
||||
router.replace({ query: { ...router.query, [key]: e.target.checked } }, undefined, { shallow: true })
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className='py-5 w-100'>
|
||||
|
@ -76,7 +94,26 @@ export default function Wallet ({ ssrData }) {
|
|||
</Link>
|
||||
</div>
|
||||
<div className={styles.walletGrid} onDragEnd={onDragEnd}>
|
||||
{wallets.map((w, i) => {
|
||||
<div className={styles.walletFilters}>
|
||||
<BootstrapForm.Check
|
||||
inline
|
||||
label={<span><RecvIcon width={16} height={16} /> receive</span>}
|
||||
onChange={onFilterChange('receive')}
|
||||
checked={filter.receive}
|
||||
/>
|
||||
<BootstrapForm.Check
|
||||
inline
|
||||
label={<span><SendIcon width={16} height={16} /> send</span>}
|
||||
onChange={onFilterChange('send')}
|
||||
checked={filter.send}
|
||||
/>
|
||||
</div>
|
||||
{wallets
|
||||
.filter(w => {
|
||||
return (!filter.send || (filter.send && supportsSend(w))) &&
|
||||
(!filter.receive || (filter.receive && supportsReceive(w)))
|
||||
})
|
||||
.map((w, i) => {
|
||||
const draggable = isClient && w.config?.enabled
|
||||
|
||||
return (
|
||||
|
|
|
@ -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) } })
|
||||
if (data.createInvoice.__typename === 'Direct') {
|
||||
router.push(`/directs/${data.createInvoice.id}`)
|
||||
} else {
|
||||
router.push(`/invoices/${data.createInvoice.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
|
|
|
@ -118,18 +118,18 @@ function LoadWithdrawl () {
|
|||
<InvoiceStatus variant={variant} status={status} />
|
||||
<div className='w-100 mt-3'>
|
||||
<Bolt11Info bolt11={data.withdrawl.bolt11} preimage={data.withdrawl.preimage}>
|
||||
<PrivacyOption wd={data.withdrawl} />
|
||||
<PrivacyOption payment={data.withdrawl} />
|
||||
</Bolt11Info>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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,20 +143,20 @@ 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) {
|
||||
update (cache, { data }) {
|
||||
if (data.dropBolt11) {
|
||||
cache.modify({
|
||||
id: `Withdrawl:${wd.id}`,
|
||||
id: `${payment.__typename}:${payment.id}`,
|
||||
fields: {
|
||||
bolt11: () => null,
|
||||
hash: () => null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
|
@ -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
|
||||
|
|
|
@ -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");
|
|
@ -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")
|
||||
|
|
2
sndev
2
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
|
||||
|
|
|
@ -1,10 +1,34 @@
|
|||
.walletGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, 160px);
|
||||
grid-gap: 20px;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
margin-top: 3rem;
|
||||
padding: 20px 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 440px) {
|
||||
.walletGrid {
|
||||
grid-template-columns: repeat(auto-fill, 140px);
|
||||
grid-gap: 15px;
|
||||
}
|
||||
.card {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 330px) {
|
||||
.walletGrid {
|
||||
grid-template-columns: 100%;
|
||||
}
|
||||
.walletGrid > * {
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.walletFilters {
|
||||
grid-column: 1 / -1;
|
||||
margin-left: 0.2rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag {
|
||||
|
@ -17,18 +41,34 @@
|
|||
|
||||
.card {
|
||||
width: 160px;
|
||||
height: 180px;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 160 / 180;
|
||||
}
|
||||
|
||||
.indicators {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
padding: 10px 10px 0 10px;
|
||||
grid-gap: 0.2rem;
|
||||
grid-auto-flow: column;
|
||||
column-gap: 0.2rem;
|
||||
margin-left: auto;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.walletLogo {
|
||||
max-width: 100%;
|
||||
max-height: 40%;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.walletBanner {
|
||||
max-width: min(256px, 100vw);
|
||||
max-height: 100px;
|
||||
padding: 0 15px 1rem 15px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
|
|
|
@ -3,18 +3,47 @@ import { bolt11Tags } from '@/lib/bolt11'
|
|||
import { Mutex } from 'async-mutex'
|
||||
export * from '@/wallets/lnc'
|
||||
|
||||
async function disconnect (lnc, logger) {
|
||||
if (lnc) {
|
||||
const mutex = new Mutex()
|
||||
const serverHost = 'mailbox.terminal.lightning.today:443'
|
||||
|
||||
export async function testSendPayment (credentials, { logger }) {
|
||||
const lnc = await getLNC(credentials, { logger })
|
||||
logger?.info('validating permissions ...')
|
||||
await validateNarrowPerms(lnc)
|
||||
logger?.info('permissions ok')
|
||||
return lnc.credentials.credentials
|
||||
}
|
||||
|
||||
export async function sendPayment (bolt11, credentials, { logger }) {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
return await mutex.runExclusive(async () => {
|
||||
try {
|
||||
const lnc = await getLNC(credentials, { logger })
|
||||
const { paymentError, paymentPreimage: preimage } = await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
|
||||
if (paymentError) throw new Error(paymentError)
|
||||
if (!preimage) throw new Error('No preimage in response')
|
||||
return preimage
|
||||
} catch (err) {
|
||||
const msg = err.message || err.toString?.()
|
||||
if (msg.includes('invoice expired')) throw new InvoiceExpiredError(hash)
|
||||
if (msg.includes('canceled')) throw new InvoiceCanceledError(hash)
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function disconnectLNC (lnc, { logger } = {}) {
|
||||
try {
|
||||
if (!lnc?.isConnected) return
|
||||
lnc.disconnect()
|
||||
logger.info('disconnecting...')
|
||||
// wait for lnc to disconnect before releasing the mutex
|
||||
logger?.info('disconnecting...')
|
||||
// wait for lnc to disconnect
|
||||
await new Promise((resolve, reject) => {
|
||||
let counter = 0
|
||||
const interval = setInterval(() => {
|
||||
if (lnc?.isConnected) {
|
||||
if (counter++ > 100) {
|
||||
logger.error('failed to disconnect from lnc')
|
||||
logger?.error('failed to disconnect from lnc')
|
||||
clearInterval(interval)
|
||||
reject(new Error('failed to disconnect from lnc'))
|
||||
}
|
||||
|
@ -24,85 +53,59 @@ async function disconnect (lnc, logger) {
|
|||
resolve()
|
||||
})
|
||||
}, 50)
|
||||
logger.info('disconnected')
|
||||
logger?.info('disconnected')
|
||||
} catch (err) {
|
||||
logger.error('failed to disconnect from lnc: ' + err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSendPayment (credentials, { logger }) {
|
||||
let lnc
|
||||
try {
|
||||
lnc = await getLNC(credentials)
|
||||
|
||||
logger.info('connecting ...')
|
||||
await lnc.connect()
|
||||
logger.info('connected')
|
||||
|
||||
logger.info('validating permissions ...')
|
||||
await validateNarrowPerms(lnc)
|
||||
logger.info('permissions ok')
|
||||
|
||||
return lnc.credentials.credentials
|
||||
} finally {
|
||||
await disconnect(lnc, logger)
|
||||
logger?.error('failed to disconnect from lnc: ' + err)
|
||||
}
|
||||
}
|
||||
|
||||
const mutex = new Mutex()
|
||||
async function getLNC (credentials = {}, { logger } = {}) {
|
||||
if (window.snLncKillerTimeout) clearTimeout(window.snLncKillerTimeout)
|
||||
|
||||
export async function sendPayment (bolt11, credentials, { logger }) {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
|
||||
return await mutex.runExclusive(async () => {
|
||||
let lnc
|
||||
try {
|
||||
lnc = await getLNC(credentials)
|
||||
|
||||
await lnc.connect()
|
||||
const { paymentError, paymentPreimage: preimage } =
|
||||
await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
|
||||
|
||||
if (paymentError) throw new Error(paymentError)
|
||||
if (!preimage) throw new Error('No preimage in response')
|
||||
|
||||
return preimage
|
||||
} catch (err) {
|
||||
const msg = err.message || err.toString?.()
|
||||
if (msg.includes('invoice expired')) {
|
||||
throw new InvoiceExpiredError(hash)
|
||||
}
|
||||
if (msg.includes('canceled')) {
|
||||
throw new InvoiceCanceledError(hash)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
await disconnect(lnc, logger)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function getLNC (credentials = {}) {
|
||||
const serverHost = 'mailbox.terminal.lightning.today:443'
|
||||
// XXX we MUST reuse the same instance of LNC because it references a global Go object
|
||||
// that holds closures to the first LNC instance it's created with
|
||||
if (window.lnc) {
|
||||
window.lnc.credentials.credentials = {
|
||||
...window.lnc.credentials.credentials,
|
||||
...credentials,
|
||||
serverHost
|
||||
}
|
||||
return window.lnc
|
||||
}
|
||||
if (!window.snLnc) { // create new instance
|
||||
const { default: LNC } = await import('@lightninglabs/lnc-web')
|
||||
window.lnc = new LNC({
|
||||
window.snLnc = new LNC({
|
||||
credentialStore: new LncCredentialStore({
|
||||
...credentials,
|
||||
serverHost
|
||||
})
|
||||
})
|
||||
return window.lnc
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
// try to disconnect gracefully when the page is closed
|
||||
disconnectLNC(window.snLnc, { logger })
|
||||
})
|
||||
} else if (JSON.stringify(window.snLncCredentials ?? {}) !== JSON.stringify(credentials)) {
|
||||
console.log('LNC instance has new credentials')
|
||||
// disconnect and update credentials if they've changed
|
||||
await disconnectLNC(window.snLnc, { logger })
|
||||
// XXX we MUST reuse the same instance of LNC because it references a global Go object
|
||||
// that holds closures to the first LNC instance it's created with
|
||||
window.snLnc.credentials.credentials = {
|
||||
...window.snLnc.credentials.credentials,
|
||||
...credentials,
|
||||
serverHost
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.snLnc.isConnected) {
|
||||
logger?.info('connecting ...')
|
||||
await window.snLnc.connect()
|
||||
logger?.info('connected')
|
||||
}
|
||||
|
||||
window.snLncCredentials = {
|
||||
...credentials
|
||||
}
|
||||
|
||||
window.snLncKillerTimeout = setTimeout(() => {
|
||||
logger?.info('disconnecting from lnc due to inactivity ...')
|
||||
mutex.runExclusive(async () => {
|
||||
await disconnectLNC(window.snLnc, { logger })
|
||||
})
|
||||
}, 4000)
|
||||
|
||||
return window.snLnc
|
||||
}
|
||||
|
||||
function validateNarrowPerms (lnc) {
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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 }) {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
158
worker/wallet.js
158
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 })
|
||||
}
|
||||
|
||||
// sync LND invoice status with invoice status in database
|
||||
await checkInvoice({ data: { hash }, models, lnd, boss })
|
||||
|
||||
return dbInv
|
||||
}
|
||||
|
||||
export async function checkPendingDeposits (args) {
|
||||
|
|
Loading…
Reference in New Issue