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