Skip to content

Commit

Permalink
Receiver fallbacks
Browse files Browse the repository at this point in the history
  • Loading branch information
ekzyis committed Dec 6, 2024
1 parent bfff565 commit 674a588
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 86 deletions.
48 changes: 27 additions & 21 deletions api/paidAction/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,34 +317,39 @@ export async function retryPaidAction (actionType, args, incomingContext) {
optimistic: actionOptimistic,
me: await models.user.findUnique({ where: { id: parseInt(me.id) } }),
cost: BigInt(msatsRequested),
actionId
actionId,
failedInvoiceId: failedInvoice.id
}

let invoiceArgs
const invoiceForward = await models.invoiceForward.findUnique({
where: { invoiceId: failedInvoice.id },
where: {
invoiceId: failedInvoice.id
},
include: {
wallet: true,
invoice: true,
withdrawl: true
wallet: true
}
})
// TODO: receiver fallbacks
// use next receiver wallet if forward failed (we currently immediately fallback to SN)
const failedForward = invoiceForward?.withdrawl && invoiceForward.withdrawl.actionState !== 'CONFIRMED'
if (invoiceForward && !failedForward) {
const { userId } = invoiceForward.wallet
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: failedInvoice.msatsRequested,
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
description: await action.describe?.(actionArgs, retryContext),
expiry: INVOICE_EXPIRE_SECS
}, retryContext)
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
} else {
invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)

if (invoiceForward) {
// this is a wrapped invoice, we need to retry it with receiver fallbacks
try {
const { userId } = invoiceForward.wallet
// this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: failedInvoice.msatsRequested,
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
description: await action.describe?.(actionArgs, retryContext),
expiry: INVOICE_EXPIRE_SECS
}, retryContext)
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
} catch (err) {
console.log('failed to retry wrapped invoice, falling back to SN:', err)
}
}

invoiceArgs ??= await createSNInvoice(actionType, actionArgs, retryContext)

return await models.$transaction(async tx => {
const context = { ...retryContext, tx, invoiceArgs }

Expand Down Expand Up @@ -404,7 +409,7 @@ async function createSNInvoice (actionType, args, context) {
}

async function createDbInvoice (actionType, args, context) {
const { me, models, tx, cost, optimistic, actionId, invoiceArgs } = context
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, failedInvoiceId } = context
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs

const db = tx ?? models
Expand All @@ -429,7 +434,8 @@ async function createDbInvoice (actionType, args, context) {
actionOptimistic: optimistic,
actionArgs: args,
expiresAt,
actionId
actionId,
failedInvoiceId
}

let invoice
Expand Down
14 changes: 11 additions & 3 deletions components/use-invoice.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useApolloClient, useMutation } from '@apollo/client'
import { useCallback } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
import { InvoiceExpiredError, InvoiceCanceledError } from '@/wallets/errors'

export default function useInvoice () {
const client = useApolloClient()
Expand All @@ -16,14 +16,22 @@ export default function useInvoice () {
throw error
}

const { cancelled, cancelledAt, actionError, actionState, expiresAt } = data.invoice
const { cancelled, cancelledAt, actionError, actionState, expiresAt, isHeld } = data.invoice

const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt)
if (expired) {
throw new InvoiceExpiredError(data.invoice)
}

if (cancelled || actionError) {
const failed = cancelled || actionError

// failed forwards might already have been finalized (actionState === 'FAILED')
// so we also check for held payments
if ((failed && isHeld) || actionState === 'FAILED_FORWARD') {
throw new WalletReceiverError(data.invoice)
}

if (failed) {
throw new InvoiceCanceledError(data.invoice, actionError)
}

Expand Down
14 changes: 14 additions & 0 deletions prisma/migrations/20241206112911_failed_invoice_id/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
Warnings:
- A unique constraint covering the columns `[failedInvoiceId]` on the table `Invoice` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "failedInvoiceId" INTEGER;

-- CreateIndex
CREATE UNIQUE INDEX "Invoice.failedInvoiceId_unique" ON "Invoice"("failedInvoiceId");

-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_failedInvoiceId_fkey" FOREIGN KEY ("failedInvoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
44 changes: 23 additions & 21 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -904,27 +904,29 @@ model ItemMention {
}

model Invoice {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
hash String @unique(map: "Invoice.hash_unique")
preimage String? @unique(map: "Invoice.preimage_unique")
isHeld Boolean?
bolt11 String
expiresAt DateTime
confirmedAt DateTime?
confirmedIndex BigInt?
cancelled Boolean @default(false)
cancelledAt DateTime?
msatsRequested BigInt
msatsReceived BigInt?
desc String?
comment String?
lud18Data Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
invoiceForward InvoiceForward?
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
hash String @unique(map: "Invoice.hash_unique")
preimage String? @unique(map: "Invoice.preimage_unique")
isHeld Boolean?
bolt11 String
expiresAt DateTime
confirmedAt DateTime?
confirmedIndex BigInt?
cancelled Boolean @default(false)
cancelledAt DateTime?
msatsRequested BigInt
msatsReceived BigInt?
desc String?
comment String?
lud18Data Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
invoiceForward InvoiceForward?
failedInvoiceId Int? @unique(map: "Invoice.failedInvoiceId_unique")
failedInvoice Invoice? @relation("InvoiceChain", fields: [failedInvoiceId], references: [id], onDelete: Cascade)
retryInvoice Invoice? @relation("InvoiceChain")
actionState InvoiceActionState?
actionType InvoiceActionType?
actionOptimistic Boolean?
Expand Down
8 changes: 8 additions & 0 deletions wallets/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export class WalletSenderError extends WalletPaymentError {
}
}

export class WalletReceiverError extends WalletPaymentError {
constructor (invoice) {
super(`payment forwarding failed for invoice ${invoice.hash}`)
this.name = 'WalletReceiverError'
this.invoice = invoice
}
}

export class WalletsNotAvailableError extends WalletConfigurationError {
constructor () {
super('no wallet available')
Expand Down
69 changes: 44 additions & 25 deletions wallets/payment.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import useInvoice from '@/components/use-invoice'
import { FAST_POLL_INTERVAL } from '@/lib/constants'
import {
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError
} from '@/wallets/errors'
import { canSend } from './common'
import { useWalletLoggerFactory } from './logger'
Expand All @@ -24,44 +24,62 @@ export function useWalletPayment () {
throw new WalletsNotAvailableError()
}

for (const [i, wallet] of wallets.entries()) {
for (let i = 0; i < wallets.length; i++) {
const wallet = wallets[i]
const controller = invoiceController(latestInvoice, invoiceHelper.isInvoice)

const walletPromise = sendPayment(wallet, latestInvoice)
const pollPromise = controller.wait(waitFor)

try {
return await new Promise((resolve, reject) => {
// can't await wallet payments since we might pay hold invoices and thus payments might not settle immediately.
// that's why we separately check if we received the payment with the invoice controller.
sendPayment(wallet, latestInvoice).catch(reject)
controller.wait(waitFor)
.then(resolve)
.catch(reject)
walletPromise.catch(reject)
pollPromise.then(resolve).catch(reject)
})
} catch (err) {
// cancel invoice to make sure it cannot be paid later and create new invoice to retry.
// we only need to do this if payment was attempted which is not the case if the wallet is not enabled.
if (err instanceof WalletPaymentError) {
await invoiceHelper.cancel(latestInvoice)
let paymentError = err

// is there another wallet to try?
const lastAttempt = i === wallets.length - 1
if (!lastAttempt) {
latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
if (!(paymentError instanceof WalletError)) {
// payment failed for some reason unrelated to wallets (ie invoice expired or was canceled).
// bail out of attempting wallets.
throw paymentError
}

// at this point, paymentError is always a wallet error,
// we just need to distinguish between receiver and sender errors

try {
// we always await the poll promise here to check for failed forwards
// since sender wallet errors can be caused by them which we want to
// consider as receiver errors, not sender errors.
await pollPromise
} catch (err) {
if (err instanceof WalletError) {
paymentError = err
}
}

// TODO: receiver fallbacks
//
// if payment failed because of the receiver, we should use the same wallet again.
// if (err instanceof ReceiverError) { ... }
if (paymentError instanceof WalletReceiverError) {
// if payment failed because of the receiver, use the same wallet again.
i -= 1
}

if (paymentError instanceof WalletPaymentError) {
// if a payment was attempted, cancel invoice to make sure it cannot be paid later and create new invoice to retry.
await invoiceHelper.cancel(latestInvoice)
}

// try next wallet if the payment failed because of the wallet
// and not because it expired or was canceled
if (err instanceof WalletError) {
aggregateError = new WalletAggregateError([aggregateError, err], latestInvoice)
continue
// only create a new invoice if we will try to pay with a wallet again
const retry = paymentError instanceof WalletReceiverError || i < wallets.length - 1
if (retry) {
latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
}

// payment failed not because of the sender or receiver wallet. bail out of attemping wallets.
throw err
aggregateError = new WalletAggregateError([aggregateError, paymentError], latestInvoice)

continue
} finally {
controller.stop()
}
Expand Down Expand Up @@ -131,6 +149,7 @@ function useSendPayment () {
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage })
} catch (err) {
// TODO: avoid logging confusing payment error if receiver failed and we canceled the invoice
const message = err.message || err.toString?.()
logger.error(`payment failed: ${message}`, { bolt11 })
throw new WalletSenderError(wallet.def.name, invoice, message)
Expand Down
62 changes: 46 additions & 16 deletions wallets/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]

const MAX_PENDING_INVOICES_PER_WALLET = 25

export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) {
export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { failedInvoiceId, models }) {
// get the wallets in order of priority
const wallets = await getInvoiceableWallets(userId, { models })
const wallets = await getInvoiceableWallets(userId, { failedInvoiceId, models })

msats = toPositiveNumber(msats)

Expand Down Expand Up @@ -81,7 +81,7 @@ export async function createInvoice (userId, { msats, description, descriptionHa

export async function createWrappedInvoice (userId,
{ msats, feePercent, description, descriptionHash, expiry = 360 },
{ models, me, lnd }) {
{ failedInvoiceId, models, me, lnd }) {
let logger, bolt11
try {
const { invoice, wallet } = await createInvoice(userId, {
Expand All @@ -90,7 +90,7 @@ export async function createWrappedInvoice (userId,
description,
descriptionHash,
expiry
}, { models })
}, { failedInvoiceId, models })

logger = walletLogger({ wallet, models })
bolt11 = invoice
Expand All @@ -110,18 +110,48 @@ export async function createWrappedInvoice (userId,
}
}

export async function getInvoiceableWallets (userId, { models }) {
const wallets = await models.wallet.findMany({
where: { userId, enabled: true },
include: {
user: true
},
orderBy: [
{ priority: 'asc' },
// use id as tie breaker (older wallet first)
{ id: 'asc' }
]
})
export async function getInvoiceableWallets (userId, { failedInvoiceId, models }) {
// filter out all wallets that have already been tried by recursively following the chain of retried invoices.
// the current failed invoice is in state 'FAILED' and not in state 'RETRYING'
// because we are currently retrying it so it has not been updated yet.
// if failedInvoiceId is not provided, the subquery will be empty and thus no wallets are filtered out.
const wallets = await models.$queryRaw`
SELECT
"Wallet".*,
jsonb_build_object(
'id', "users"."id",
'hideInvoiceDesc', "users"."hideInvoiceDesc"
) AS "user"
FROM "Wallet"
JOIN "users" ON "users"."id" = "Wallet"."userId"
WHERE
"Wallet"."userId" = ${userId}
AND "Wallet"."enabled" = true
AND "Wallet"."id" NOT IN (
WITH RECURSIVE "Retries" AS (
-- select the current failed invoice that we are currently retrying
-- this failed invoice will be used to start the recursion
SELECT "Invoice"."id", "Invoice"."failedInvoiceId"
FROM "Invoice"
WHERE "Invoice"."id" = ${failedInvoiceId} AND "Invoice"."actionState" = 'FAILED'
UNION ALL
-- recursive part: use failedInvoiceId to select the previous invoice that failed in the chain
-- until there is no more previous invoice
SELECT "Invoice"."id", "Invoice"."failedInvoiceId"
FROM "Invoice"
JOIN "Retries" ON "Invoice"."id" = "Retries"."failedInvoiceId"
WHERE "Invoice"."actionState" = 'RETRYING'
)
SELECT
"Wallet"."id"
FROM "Retries"
JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Retries"."id"
JOIN "Wallet" ON "Wallet"."id" = "InvoiceForward"."walletId"
WHERE "InvoiceForward"."withdrawlId" IS NOT NULL
)
ORDER BY "Wallet"."priority" ASC, "Wallet"."id" ASC`

const walletsWithDefs = wallets.map(wallet => {
const w = walletDefs.find(w => w.walletType === wallet.type)
Expand Down

0 comments on commit 674a588

Please sign in to comment.