From 03ec61f6058d551628282089e45aed19512bbca5 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 13 Nov 2024 18:11:49 +0100 Subject: [PATCH 01/19] better fallbacks --- api/paidAction/boost.js | 4 +- api/paidAction/downZap.js | 4 +- api/paidAction/index.js | 325 +++++++++++++++++--------------- api/paidAction/itemCreate.js | 10 +- api/paidAction/pollVote.js | 3 +- api/paidAction/zap.js | 4 +- api/resolvers/notifications.js | 5 + api/resolvers/paidAction.js | 30 +-- api/typeDefs/paidAction.js | 8 +- api/typeDefs/wallet.js | 5 + components/item-act.js | 2 +- components/pay-bounty.js | 2 +- components/payment.js | 7 +- components/poll.js | 6 +- components/qr.js | 22 ++- components/use-paid-mutation.js | 228 +++++++++++++++------- fragments/paidAction.js | 17 +- fragments/wallet.js | 6 + lib/constants.js | 3 +- wallets/server.js | 81 ++++---- worker/territory.js | 8 +- worker/weeklyPosts.js | 16 +- 22 files changed, 475 insertions(+), 321 deletions(-) diff --git a/api/paidAction/boost.js b/api/paidAction/boost.js index af96b4c83..cb374cead 100644 --- a/api/paidAction/boost.js +++ b/api/paidAction/boost.js @@ -33,12 +33,12 @@ export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, c } export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) const [{ id, path }] = await tx.$queryRaw` SELECT "Item".id, ltree2text(path) as path FROM "Item" JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" - WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` + WHERE "ItemAct"."invoiceId" = ${invoiceId}::INTEGER` + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) return { id, sats: msatsToSats(cost), act: 'BOOST', path } } diff --git a/api/paidAction/downZap.js b/api/paidAction/downZap.js index f4df5c826..1822986e6 100644 --- a/api/paidAction/downZap.js +++ b/api/paidAction/downZap.js @@ -34,12 +34,12 @@ export async function perform ({ invoiceId, sats, id: itemId }, { me, cost, tx } } export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) const [{ id, path }] = await tx.$queryRaw` SELECT "Item".id, ltree2text(path) as path FROM "Item" JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" - WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` + WHERE "ItemAct"."invoiceId" = ${invoiceId}::INTEGER` + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) return { id, sats: msatsToSats(cost), act: 'DONT_LIKE_THIS', path } } diff --git a/api/paidAction/index.js b/api/paidAction/index.js index a32eb6b5f..3969ea33a 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -30,9 +30,9 @@ export const paidActions = { DONATE } -export default async function performPaidAction (actionType, args, incomingContext) { +export default async function performPaidAction (actionType, args, { ...context }) { try { - const { me, models, forcePaymentMethod } = incomingContext + const { models } = context const paidAction = paidActions[actionType] console.group('performPaidAction', actionType, args) @@ -41,131 +41,163 @@ export default async function performPaidAction (actionType, args, incomingConte throw new Error(`Invalid action type ${actionType}`) } - if (!me && !paidAction.anonable) { + // add context properties + context.me = context.me ? await models.user.findUnique({ where: { id: context.me.id } }) : undefined + context.cost = await paidAction.getCost(args, context) + context.sybilFeePercent = await paidAction.getSybilFeePercent?.(args, context) + context.attempt = context.attempt ?? 0 // how many times the client thinks it has tried + context.forceInternal = context.forceInternal ?? false // use only internal payment methods + context.prioritizeInternal = context.prioritizeInternal ?? false // prefer internal payment methods + context.description = context.me?.hideInvoiceDesc ? undefined : await paidAction.describe?.(args, context) + context.descriptionHash = await paidAction.describeHash?.(args, context) + context.supportedPaymentMethods = paidAction.paymentMethods ?? await paidAction.getPaymentMethods?.(args, context) ?? [] + + const { + me, + forceInternal, + cost, + prioritizeInternal + } = context + + if (!me && !paidAction.anonable) { // action is not allowed for anons throw new Error('You must be logged in to perform this action') } - // treat context as immutable - const contextWithMe = { - ...incomingContext, - me: me ? await models.user.findUnique({ where: { id: me.id } }) : undefined - } - const context = { - ...contextWithMe, - cost: await paidAction.getCost(args, contextWithMe), - sybilFeePercent: await paidAction.getSybilFeePercent?.(args, contextWithMe) + if (cost === 0n) { // special case for zero cost actions + console.log('performing zero cost action') + return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod: PAID_ACTION_PAYMENT_METHODS.ZERO_COST }) } - // special case for zero cost actions - if (context.cost === 0n) { - console.log('performing zero cost action') - return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod: 'ZERO_COST' }) + // ort supported payment methods + if (forceInternal) { + // forced internal payments, so we keep only the payment methods that qualify as such + if (!me) { + throw new Error('user must be logged in to use internal payments') + } + const forcedPaymentMethods = [] + if (context.supportedPaymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT)) { + forcedPaymentMethods.push(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) + } + if (forcedPaymentMethods.length === 0) { + throw new Error('action does not support internal payments') + } + context.supportedPaymentMethods = forcedPaymentMethods + } else if (prioritizeInternal) { + // prefer internal payment methods + const priority = { + [PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT]: -2 + // add other internal methods here + } + context.supportedPaymentMethods = context.supportedPaymentMethods.sort((a, b) => { + return priority[a] - priority[b] + }) } - for (const paymentMethod of paidAction.paymentMethods) { - console.log(`considering payment method ${paymentMethod}`) + const { supportedPaymentMethods } = context - if (forcePaymentMethod && - paymentMethod !== forcePaymentMethod) { - console.log('skipping payment method', paymentMethod, 'because forcePaymentMethod is set to', forcePaymentMethod) - continue - } + for (const paymentMethod of supportedPaymentMethods) { + console.log(`trying payment method ${paymentMethod}`) - // payment methods that anonymous users can use - if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P) { - try { - return await performP2PAction(actionType, args, context) - } catch (e) { - if (e instanceof NonInvoiceablePeerError) { - console.log('peer cannot be invoiced, skipping') - continue + // internal actions with constraint checks + try { + switch (paymentMethod) { + case PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT: { + if (!me || (me.msats ?? 0n) < cost) break // if anon or low balance skip + return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod }) } - console.error(`${paymentMethod} action failed`, e) + // more internal payment methods here -> fee credit + } + } catch (e) { + console.error('performPaidAction failed with internal payment method', e) + // if we fail for reasons unrelated to balance, we should throw to fail the mutation + if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { throw e } - } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) { - return await beginPessimisticAction(actionType, args, context) } - // additionalpayment methods that logged in users can use - if (me) { - if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) { - try { - return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod }) - } catch (e) { - // if we fail with fee credits or reward sats, but not because of insufficient funds, bail - console.error(`${paymentMethod} action failed`, e) - if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { - throw e - } + // other actions + try { + switch (paymentMethod) { + case PAID_ACTION_PAYMENT_METHODS.P2P: { + return await performP2PAction(actionType, args, context, paymentMethod) + } + case PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC: { + if (!me) break // anons are not optimistic + return await performOptimisticAction(actionType, args, context) + } + case PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC: { + return await beginPessimisticAction(actionType, args, context) } - } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) { - return await performOptimisticAction(actionType, args, context) } + } catch (e) { + console.error('performPaidAction failed', e) } } - throw new Error('No working payment method found') - } catch (e) { - console.error('performPaidAction failed', e) - throw e + throw new Error('no payment method succeeded') } finally { console.groupEnd() } } -async function performNoInvoiceAction (actionType, args, incomingContext) { - const { me, models, cost, paymentMethod } = incomingContext +async function performNoInvoiceAction (actionType, args, { ...context }) { + const { me, models, cost, paymentMethod } = context const action = paidActions[actionType] - const result = await models.$transaction(async tx => { - const context = { ...incomingContext, tx } + const run = async tx => { + context.tx = tx - if (paymentMethod === 'FEE_CREDIT') { + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) { await tx.user.update({ where: { id: me?.id ?? USER_ID.anon }, data: { msats: { decrement: cost } } }) - } + } // add other internal methods here - const result = await action.perform(args, context) + const result = await performAction(null, action, args, context) await action.onPaid?.(result, context) return { result, - paymentMethod + paymentMethod, + retriable: false } - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) - + } + // if this is nested into another transaction (eg for retryPaidAction), use the parent transaction + const result = context.tx ? await run(context.tx) : await models.$transaction(run, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) // run non critical side effects in the background // after the transaction has been committed - action.nonCriticalSideEffects?.(result.result, incomingContext).catch(console.error) + action.nonCriticalSideEffects?.(result.result, context).catch(console.error) return result } -async function performOptimisticAction (actionType, args, incomingContext) { - const { models, invoiceArgs: incomingInvoiceArgs } = incomingContext +async function performOptimisticAction (actionType, args, { ...context }) { + const { models } = context const action = paidActions[actionType] - const optimisticContext = { ...incomingContext, optimistic: true } - const invoiceArgs = incomingInvoiceArgs ?? await createSNInvoice(actionType, args, optimisticContext) + context.optimistic = true + const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(context) - return await models.$transaction(async tx => { - const context = { ...optimisticContext, tx, invoiceArgs } - - const invoice = await createDbInvoice(actionType, args, context) + const run = async tx => { + context.tx = tx + const invoice = await createDbInvoice(actionType, args, { ...context, invoiceArgs }) + const result = await performAction(invoice, action, args, context) return { invoice, - result: await action.perform?.({ invoiceId: invoice.id, ...args }, context), - paymentMethod: 'OPTIMISTIC' + result, + paymentMethod: 'OPTIMISTIC', + retriable: false } - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + } + + // if this is nested into another transaction (eg for retryPaidAction), use the parent transaction + return context.tx ? await run(context.tx) : await models.$transaction(run, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) } -async function beginPessimisticAction (actionType, args, context) { +async function beginPessimisticAction (actionType, args, { ...context }) { const action = paidActions[actionType] if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC)) { @@ -173,111 +205,98 @@ async function beginPessimisticAction (actionType, args, context) { } // just create the invoice and complete action when it's paid - const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(actionType, args, context) + const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(context) return { invoice: await createDbInvoice(actionType, args, { ...context, invoiceArgs }), - paymentMethod: 'PESSIMISTIC' + paymentMethod: 'PESSIMISTIC', + retriable: false } } -async function performP2PAction (actionType, args, incomingContext) { - // if the action has an invoiceable peer, we'll create a peer invoice - // wrap it, and return the wrapped invoice - const { cost, models, lnd, sybilFeePercent, me } = incomingContext +async function performP2PAction (actionType, args, { ...context }) { + const { cost, models, lnd, sybilFeePercent, me, supportedPaymentMethods, description, descriptionHash, attempt } = context if (!sybilFeePercent) { throw new Error('sybil fee percent is not set for an invoiceable peer action') } - const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext) + const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, context) if (!userId) { throw new NonInvoiceablePeerError() } - await assertBelowMaxPendingInvoices(incomingContext) + await assertBelowMaxPendingInvoices(context) - const description = await paidActions[actionType].describe(args, incomingContext) - const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, { + // optimistic only if logged in and the action supports optimism + const optimistic = (me && supportedPaymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)) + + const { invoice, wrappedInvoice, wallet, maxFee, retriable } = await createWrappedInvoice(userId, { msats: cost, feePercent: sybilFeePercent, description, - expiry: INVOICE_EXPIRE_SECS + descriptionHash, + expiry: INVOICE_EXPIRE_SECS, + skipWallets: attempt }, { models, me, lnd }) - const context = { - ...incomingContext, - invoiceArgs: { - bolt11: invoice, - wrappedBolt11: wrappedInvoice, - wallet, - maxFee - } + context.invoiceArgs = { + bolt11: invoice, + wrappedBolt11: wrappedInvoice, + wallet, + maxFee } - return me - ? await performOptimisticAction(actionType, args, context) - : await beginPessimisticAction(actionType, args, context) + return { + retriable, + ...(optimistic + ? await performOptimisticAction(actionType, args, context) + : await beginPessimisticAction(actionType, args, context)) + } } -export async function retryPaidAction (actionType, args, incomingContext) { - const { models, me } = incomingContext - const { invoice: failedInvoice } = args - - console.log('retryPaidAction', actionType, args) - - const action = paidActions[actionType] - if (!action) { - throw new Error(`retryPaidAction - invalid action type ${actionType}`) - } +export async function retryPaidAction ({ invoiceId, forceInternal, attempt, prioritizeInternal }, { ...context }) { + const { models, me } = context - if (!me) { - throw new Error(`retryPaidAction - must be logged in ${actionType}`) - } + const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id ?? USER_ID.anon } }) - if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)) { - throw new Error(`retryPaidAction - action does not support optimism ${actionType}`) + if (!failedInvoice) { + throw new Error('invoice not found') } - if (!action.retry) { - throw new Error(`retryPaidAction - action does not support retrying ${actionType}`) + if (failedInvoice.actionState !== 'FAILED') { + // you should cancel the invoice before retrying the action! + throw new Error(`actions is not in a retriable state: ${failedInvoice.actionState}`) } - if (!failedInvoice) { - throw new Error(`retryPaidAction - missing invoice ${actionType}`) - } + const actionType = failedInvoice.actionType - const { msatsRequested, actionId } = failedInvoice - const retryContext = { - ...incomingContext, - optimistic: true, - me: await models.user.findUnique({ where: { id: me.id } }), - cost: BigInt(msatsRequested), - actionId + const paidAction = paidActions[actionType] + if (!paidAction) { + throw new Error(`retryPaidAction - invalid action type ${actionType}`) } - const invoiceArgs = await createSNInvoice(actionType, args, retryContext) + const { msatsRequested, actionId, actionArgs } = failedInvoice + context.cost = msatsRequested + context.actionId = actionId + context.retryForInvoice = failedInvoice + context.forceInternal = forceInternal + context.attempt = attempt + context.prioritizeInternal = prioritizeInternal return await models.$transaction(async tx => { - const context = { ...retryContext, tx, invoiceArgs } - - // update the old invoice to RETRYING, so that it's not confused with FAILED - await tx.invoice.update({ - where: { - id: failedInvoice.id, - actionState: 'FAILED' - }, - data: { - actionState: 'RETRYING' - } - }) - - // create a new invoice - const invoice = await createDbInvoice(actionType, args, context) - - return { - result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context), - invoice, - paymentMethod: 'OPTIMISTIC' + const supportRetrying = paidAction.retry + if (supportRetrying) { + // update the old invoice to RETRYING, so that it's not confused with FAILED + await tx.invoice.update({ + where: { + id: failedInvoice.id, + actionState: 'FAILED' + }, + data: { + actionState: 'RETRYING' + } + }) } + return await performPaidAction(actionType, actionArgs, context) }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) } @@ -309,9 +328,9 @@ export class NonInvoiceablePeerError extends Error { // we seperate the invoice creation into two functions because // because if lnd is slow, it'll timeout the interactive tx -async function createSNInvoice (actionType, args, context) { - const { me, lnd, cost, optimistic } = context - const action = paidActions[actionType] +async function createSNInvoice (context) { + const { lnd, cost, optimistic, description, descriptionHash } = context + const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice if (cost < 1000n) { @@ -321,7 +340,8 @@ async function createSNInvoice (actionType, args, context) { const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) const invoice = await createLNDInvoice({ - description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context), + description, + descriptionHash, lnd, mtokens: String(cost), expires_at: expiresAt @@ -394,3 +414,12 @@ async function createDbInvoice (actionType, args, context) { return invoice } + +async function performAction (dbInvoice, paidAction, args, { ...context }) { + const { retryForInvoice } = context + if (retryForInvoice && paidAction.retry) { + return await paidAction.retry({ invoiceId: retryForInvoice.id, newInvoiceId: dbInvoice?.id }, context) + } else { + return await paidAction.perform?.({ invoiceId: dbInvoice?.id, ...args }, context) + } +} diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js index 4b2b8bb9e..2fd0c0715 100644 --- a/api/paidAction/itemCreate.js +++ b/api/paidAction/itemCreate.js @@ -148,13 +148,15 @@ export async function perform (args, context) { } export async function retry ({ invoiceId, newInvoiceId }, { tx }) { + const res = (await tx.$queryRaw` + SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" + FROM "Item" WHERE "invoiceId" = ${invoiceId}::INTEGER` + )[0] + res.invoiceId = newInvoiceId await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) await tx.item.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) await tx.upload.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - return (await tx.$queryRaw` - SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt" - FROM "Item" WHERE "invoiceId" = ${newInvoiceId}::INTEGER` - )[0] + return res } export async function onPaid ({ invoice, id }, context) { diff --git a/api/paidAction/pollVote.js b/api/paidAction/pollVote.js index c63ecef2e..31f2be337 100644 --- a/api/paidAction/pollVote.js +++ b/api/paidAction/pollVote.js @@ -41,11 +41,10 @@ export async function perform ({ invoiceId, id }, { me, cost, tx }) { } export async function retry ({ invoiceId, newInvoiceId }, { tx }) { + const { pollOptionId } = await tx.pollVote.findFirst({ where: { invoiceId } }) await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) await tx.pollBlindVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) await tx.pollVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) - - const { pollOptionId } = await tx.pollVote.findFirst({ where: { invoiceId: newInvoiceId } }) return { id: pollOptionId } } diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index a6eee0319..03c8ca4f6 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -64,12 +64,12 @@ export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, c } export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { - await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) const [{ id, path }] = await tx.$queryRaw` SELECT "Item".id, ltree2text(path) as path FROM "Item" JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId" - WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER` + WHERE "ItemAct"."invoiceId" = ${invoiceId}::INTEGER` + await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } }) return { id, sats: msatsToSats(cost), act: 'TIP', path } } diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index a934f64f0..4478f1c5e 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -346,6 +346,11 @@ export default { "Invoice"."actionType" = 'POLL_VOTE' OR "Invoice"."actionType" = 'BOOST' ) + AND EXISTS ( + SELECT 1 + FROM "ItemAct" + WHERE "ItemAct"."invoiceId" = "Invoice".id + ) ORDER BY "sortTime" DESC LIMIT ${LIMIT})` ) diff --git a/api/resolvers/paidAction.js b/api/resolvers/paidAction.js index 233279038..970a57c23 100644 --- a/api/resolvers/paidAction.js +++ b/api/resolvers/paidAction.js @@ -31,6 +31,13 @@ export default { where: { id: invoiceId, userId: me?.id ?? USER_ID.anon + }, + include: { + invoiceForward: { + include: { + withdrawl: true + } + } } }) if (!invoice) { @@ -46,28 +53,11 @@ export default { } }, Mutation: { - retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => { - if (!me) { - throw new Error('You must be logged in') - } - - const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } }) - if (!invoice) { - throw new Error('Invoice not found') - } - - if (invoice.actionState !== 'FAILED') { - if (invoice.actionState === 'PAID') { - throw new Error('Invoice is already paid') - } - throw new Error(`Invoice is not in failed state: ${invoice.actionState}`) - } - - const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd }) - + retryPaidAction: async (parent, { invoiceId, forceInternal, prioritizeInternal, attempt }, { models, me, lnd }) => { + const result = await retryPaidAction({ invoiceId, forceInternal, prioritizeInternal, attempt }, { models, me, lnd }) return { ...result, - type: paidActionType(invoice.actionType) + type: paidActionType(result.invoice.actionType) } } }, diff --git a/api/typeDefs/paidAction.js b/api/typeDefs/paidAction.js index 56dd74323..212c8213a 100644 --- a/api/typeDefs/paidAction.js +++ b/api/typeDefs/paidAction.js @@ -7,7 +7,7 @@ extend type Query { } extend type Mutation { - retryPaidAction(invoiceId: Int!): PaidAction! + retryPaidAction(invoiceId: Int!, forceInternal: Boolean, attempt: Int, prioritizeInternal: Boolean): PaidAction! } enum PaymentMethod { @@ -20,36 +20,42 @@ enum PaymentMethod { interface PaidAction { invoice: Invoice paymentMethod: PaymentMethod! + retriable: Boolean } type ItemPaidAction implements PaidAction { result: Item invoice: Invoice paymentMethod: PaymentMethod! + retriable: Boolean } type ItemActPaidAction implements PaidAction { result: ItemActResult invoice: Invoice paymentMethod: PaymentMethod! + retriable: Boolean } type PollVotePaidAction implements PaidAction { result: PollVoteResult invoice: Invoice paymentMethod: PaymentMethod! + retriable: Boolean } type SubPaidAction implements PaidAction { result: Sub invoice: Invoice paymentMethod: PaymentMethod! + retriable: Boolean } type DonatePaidAction implements PaidAction { result: DonateResult invoice: Invoice paymentMethod: PaymentMethod! + retriable: Boolean } ` diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index f65498969..36a34fb10 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -122,6 +122,11 @@ const typeDefs = ` actionError: String item: Item itemAct: ItemAct + invoiceForward: InvoiceForward + } + + type InvoiceForward { + withdrawl: Withdrawl } type Withdrawl { diff --git a/components/item-act.js b/components/item-act.js index 36d5a0c77..bbb87b373 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -272,7 +272,7 @@ export function useZap () { const sats = nextTip(meSats, { ...me?.privates }) const variables = { id: item.id, sats, act: 'TIP' } - const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } } + const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables }, retriable: true } } try { await abortSignal.pause({ me, amount: sats }) diff --git a/components/pay-bounty.js b/components/pay-bounty.js index 35c5ebe60..83d0bd6c6 100644 --- a/components/pay-bounty.js +++ b/components/pay-bounty.js @@ -50,7 +50,7 @@ export default function PayBounty ({ children, item }) { const variables = { id: item.id, sats: root.bounty, act: 'TIP' } const act = useAct({ variables, - optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } }, + optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path }, retriable: true } }, ...payBountyCacheMods }) diff --git a/components/payment.js b/components/payment.js index 175ca2b3b..7e4dbba20 100644 --- a/components/payment.js +++ b/components/payment.js @@ -112,9 +112,10 @@ const invoiceController = (id, isInvoice) => { export const useWalletPayment = () => { const invoice = useInvoice() - const wallet = useWallet() + const defaultWallet = useWallet() - const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => { + const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor, wallet) => { + wallet = wallet ?? defaultWallet if (!wallet) { throw new NoAttachedWalletError() } @@ -134,7 +135,7 @@ export const useWalletPayment = () => { } finally { controller.stop() } - }, [wallet, invoice]) + }, [defaultWallet, invoice]) return waitForWalletPayment } diff --git a/components/poll.js b/components/poll.js index dc694f801..746eb131f 100644 --- a/components/poll.js +++ b/components/poll.js @@ -23,7 +23,7 @@ export default function Poll ({ item }) { onClick={me ? async () => { const variables = { id: v.id } - const optimisticResponse = { pollVote: { __typename: 'PollVotePaidAction', result: { id: v.id } } } + const optimisticResponse = { pollVote: { __typename: 'PollVotePaidAction', result: { id: v.id }, retriable: true } } try { const { error } = await pollVote({ variables, @@ -103,7 +103,7 @@ function PollResult ({ v, progress }) { ) } -export function usePollVote ({ query = POLL_VOTE, itemId }) { +export function usePollVote ({ query = POLL_VOTE, itemId, ...options }) { const update = (cache, { data }) => { // the mutation name varies for optimistic retries const response = Object.values(data)[0] @@ -185,6 +185,6 @@ export function usePollVote ({ query = POLL_VOTE, itemId }) { }) } - const [pollVote] = usePaidMutation(query, { update, onPayError, onPaid }) + const [pollVote] = usePaidMutation(query, { update, onPayError, onPaid, ...options }) return pollVote } diff --git a/components/qr.js b/components/qr.js index 23757ba33..a4220ef71 100644 --- a/components/qr.js +++ b/components/qr.js @@ -2,8 +2,9 @@ import { QRCodeSVG } from 'qrcode.react' import { CopyInput, InputSkeleton } from './form' import InvoiceStatus from './invoice-status' import { useEffect } from 'react' -import { useWallet } from '@/wallets/index' +import { useWallets } from '@/wallets/index' import Bolt11Info from './bolt11-info' +import { canSend } from '@/wallets/common' export const qrImageSettings = { src: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 256 256\'%3E%3Cpath fill-rule=\'evenodd\' d=\'m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z\'/%3E%3C/svg%3E', @@ -16,20 +17,25 @@ export const qrImageSettings = { export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) { const qrValue = asIs ? value : 'lightning:' + value.toUpperCase() - const wallet = useWallet() + const { wallets } = useWallets() useEffect(() => { async function effect () { - if (automated && wallet) { - try { - await wallet.sendPayment(value) - } catch (e) { - console.log(e?.message) + const usableWallets = wallets.filter(w => !w.def.isAvailable || w.def.isAvailable()) + .filter(w => w.config?.enabled && canSend(w))[0] + if (automated && usableWallets.length > 0) { + for (const wallet of usableWallets) { + try { + await wallet.sendPayment(value) + break + } catch (e) { + console.log(e?.message) + } } } } effect() - }, [wallet]) + }, [wallets]) return ( <> diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index 765508b5f..ae258b9fb 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -1,9 +1,11 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { useCallback, useState } from 'react' import { useInvoice, useQrPayment, useWalletPayment } from './payment' -import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors' -import { GET_PAID_ACTION } from '@/fragments/paidAction' - +import { InvoiceCanceledError } from '@/wallets/errors' +import { GET_PAID_ACTION, RETRY_PAID_ACTION } from '@/fragments/paidAction' +import { useWallets, useWallet } from '@/wallets/index' +import { canSend } from '@/wallets/common' +import { useMe } from './me' /* this is just like useMutation with a few changes: 1. pays an invoice returned by the mutation @@ -23,39 +25,149 @@ export function usePaidMutation (mutation, const [getPaidAction] = useLazyQuery(GET_PAID_ACTION, { fetchPolicy: 'network-only' }) + const [retryPaidAction] = useMutation(RETRY_PAID_ACTION) const waitForWalletPayment = useWalletPayment() const invoiceHelper = useInvoice() const waitForQrPayment = useQrPayment() const client = useApolloClient() // innerResult is used to store/control the result of the mutation when innerMutate runs const [innerResult, setInnerResult] = useState(result) + const { wallets: walletDefs } = useWallets() + const { me } = useMe() + + const addPayError = (e, rest) => ({ + ...rest, + payError: e, + error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined + }) + + // walletDefs shouldn't change on rerender, so it should be safe + const senderWallets = walletDefs + .map(w => useWallet(w.def.name)) + .filter(w => !w.def.isAvailable || w.def.isAvailable()) + .filter(w => w.config?.enabled && canSend(w)).map(w => { + return { ...w, failed: false } + }) + + const waitForActionPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }, originalResponse, action) => { + const walletErrors = [] + let response = originalResponse + let invoiceUsed = false + + const cancelInvoice = async () => { + try { + invoiceUsed = true + await invoiceHelper.cancel(invoice) + console.log('old invoice canceled') + } catch (err) { + console.error('could not cancel old invoice', err) + } + } - const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }) => { - let walletError - const start = Date.now() + // ensures every invoice is used only once + const refreshInvoice = async (attempt = 0) => { + if (invoiceUsed) { + await cancelInvoice() + const retry = await retryPaidAction({ variables: { invoiceId: parseInt(invoice.id), attempt } }) + response = retry.data?.retryPaidAction + invoice = response?.invoice + invoiceUsed = true + } else invoiceUsed = true + } + + // if anon we go straight to qr code + if (!me) { + await refreshInvoice() + if (!invoice) { + setInnerResult(r => addPayError(new Error('You must be logged in'), r)) + throw new Error('You must be logged in') + } + await waitForQrPayment(invoice, null, { persistOnNavigate, waitFor }) + return { invoice, response } + } + + const paymentAttemptStartTime = Date.now() + // we try with attached wallets + let attempt = 0 + while (true) { + await refreshInvoice(attempt) + if (!invoice) return { invoice, response } + + // first non failed sender wallet + const senderWallet = senderWallets.find(w => !w.failed) + if (!senderWallet) { + console.log('no sender wallet available') + break + } + + try { + console.log('trying to pay with wallet', senderWallet.def.name) + await waitForWalletPayment(invoice, waitFor, senderWallet) + console.log('paid with wallet', senderWallet.def.name) + return { invoice, response } + } catch (err) { + walletErrors.push(err) + // get action data + const { data: paidActionData } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } }) + const hasWithdrawl = !!paidActionData.invoice?.invoiceForward?.withdrawl + if (hasWithdrawl) { + // SN received the payment but couldn't forward it, we try another receiver with the same sender + if (!response.retriable) { // we are out of receivers + console.log('the receiver wallet failed to receive the payment, but we exhausted all options') + break + } + console.log('the receiver wallet failed to receive the payment, will try another one') + attempt++ + } else { + // SN didn't receive the payment, so the sender must have failed + senderWallet.failed = true + console.log('the sender wallet failed to pay the invoice', senderWallet.def.name) + } + } + } + + // we try an internal payment try { - return await waitForWalletPayment(invoice, waitFor) - } catch (err) { - if ( - (!alwaysShowQROnFailure && Date.now() - start > 1000) || - err instanceof InvoiceCanceledError || - err instanceof InvoiceExpiredError) { - // bail since qr code payment will also fail - // also bail if the payment took more than 1 second - // and cancel the invoice if it's not already canceled so it can be retried - invoiceHelper.cancel(invoice).catch(console.error) - throw err + console.log('could not pay with any wallet... will try with an internal payment...') + await cancelInvoice() + const retry = await retryPaidAction({ variables: { invoiceId: parseInt(invoice.id), prioritizeInternal: true } }) + response = retry.data?.retryPaidAction + invoice = response?.invoice + if (!invoice) { + return { response } + } else { + // if the internal payment returned an invoice, it means it failed + // maybe the user doesn't have enough credits. + invoiceUsed = false } - walletError = err + } catch (err) { + console.log('could not pay with internal payment... will fallback to another method') + walletErrors.push(err) } - return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor }) - }, [waitForWalletPayment, waitForQrPayment, invoiceHelper]) + + // last resort, show qr code or fail + + // we don't show the qr if too much time has passed from the payment attempt, this prevents + // very slow payments from resulting in a qr codes being shown unexpectedly during the user navigation + const failedEarly = paymentAttemptStartTime - Date.now() < 1000 + if (alwaysShowQROnFailure || failedEarly) { + console.log('show qr code for manual payment') + await refreshInvoice(attempt) + await waitForQrPayment(invoice, walletErrors[walletErrors.length - 1], { persistOnNavigate, waitFor }) + } else { + console.log('we are out of options, we will throw the errors') + cancelInvoice().catch(console.error) + throw new Error(walletErrors.map(e => e.message).join('\n')) + } + + return { invoice, response } + }, [waitForWalletPayment, waitForQrPayment, invoiceHelper, senderWallets]) const innerMutate = useCallback(async ({ onCompleted: innerOnCompleted, ...innerOptions } = {}) => { innerOptions.optimisticResponse = addOptimisticResponseExtras(innerOptions.optimisticResponse) - let { data, ...rest } = await mutate(innerOptions) + let { data, ...rest } = await mutate({ ...innerOptions }) // use the most inner callbacks/options if they exist const { @@ -75,62 +187,52 @@ export function usePaidMutation (mutation, if (invoice) { // adds payError, escalating to a normal error if the invoice is not canceled or // has an actionError - const addPayError = (e, rest) => ({ - ...rest, - payError: e, - error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined - }) + const wait = response?.paymentMethod !== 'OPTIMISTIC' || forceWaitForPayment + const alwaysShowQROnFailure = options.alwaysShowQROnFailure ?? innerOptions.alwaysShowQROnFailure ?? wait // should we wait for the invoice to be paid? - if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) { + if (!wait) { // onCompleted is called before the invoice is paid for optimistic updates ourOnCompleted?.(data) - // don't wait to pay the invoice - waitForPayment(invoice, { persistOnNavigate, waitFor }).then(() => { - onPaid?.(client.cache, { data }) - }).catch(e => { - console.error('usePaidMutation: failed to pay invoice', e) - // onPayError is called after the invoice fails to pay - // useful for updating invoiceActionState to FAILED - onPayError?.(e, client.cache, { data }) - setInnerResult(r => addPayError(e, r)) - }) } else { - // the action is pessimistic - try { - // wait for the invoice to be paid - await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor }) - if (!response.result) { - // if the mutation didn't return any data, ie pessimistic, we need to fetch it - const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } }) - // create new data object - // ( hmac is only returned on invoice creation so we need to add it back to the data ) - data = { - [Object.keys(data)[0]]: { - ...paidAction, - invoice: { ...paidAction.invoice, hmac: invoice.hmac } - } + setInnerResult({ data, ...rest }) + } + // don't wait to pay the invoice + const p = waitForActionPayment(invoice, { alwaysShowQROnFailure, persistOnNavigate, waitFor }, response, innerOptions).then(async ({ invoice, response }) => { + if (!response.result) { // supposedly this is never the case for optimistic actions + // if the mutation didn't return any data, ie pessimistic, we need to fetch it + const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } }) + // create new data object + // ( hmac is only returned on invoice creation so we need to add it back to the data ) + data = { + [Object.keys(data)[0]]: { + ...paidAction, + invoice: { ...paidAction.invoice, hmac: invoice.hmac } } - // we need to run update functions on mutations now that we have the data - update?.(client.cache, { data }) } - ourOnCompleted?.(data) - onPaid?.(client.cache, { data }) - } catch (e) { - console.error('usePaidMutation: failed to pay invoice', e) - onPayError?.(e, client.cache, { data }) - rest = addPayError(e, rest) + // we need to run update functions on mutations now that we have the data + update?.(client.cache, { data }) } - } + if (wait) ourOnCompleted?.(data) + onPaid?.(client.cache, { data }) + setInnerResult({ data, ...rest }) + }).catch(e => { + console.error('usePaidMutation: failed to pay invoice', e) + // onPayError is called after the invoice fails to pay + // useful for updating invoiceActionState to FAILED + onPayError?.(e, client.cache, { data }) + setInnerResult(r => addPayError(e, r)) + }) + + if (wait) await p } else { // fee credits paid for it ourOnCompleted?.(data) onPaid?.(client.cache, { data }) } - setInnerResult({ data, ...rest }) return { data, ...rest } - }, [mutate, options, waitForPayment, onCompleted, client.cache, getPaidAction, setInnerResult]) + }, [mutate, options, waitForActionPayment, onCompleted, client.cache, getPaidAction, setInnerResult]) return [innerMutate, innerResult] } @@ -139,7 +241,7 @@ export function usePaidMutation (mutation, function addOptimisticResponseExtras (optimisticResponse) { if (!optimisticResponse) return optimisticResponse const key = Object.keys(optimisticResponse)[0] - optimisticResponse[key] = { invoice: null, paymentMethod: 'OPTIMISTIC', ...optimisticResponse[key] } + optimisticResponse[key] = { invoice: null, paymentMethod: 'OPTIMISTIC', retriable: true, ...optimisticResponse[key] } return optimisticResponse } diff --git a/fragments/paidAction.js b/fragments/paidAction.js index c47fa7005..576eea20c 100644 --- a/fragments/paidAction.js +++ b/fragments/paidAction.js @@ -13,6 +13,7 @@ export const PAID_ACTION = gql` ...InvoiceFields } paymentMethod + retriable }` const ITEM_PAID_ACTION_FIELDS = gql` @@ -88,8 +89,10 @@ export const RETRY_PAID_ACTION = gql` ${PAID_ACTION} ${ITEM_PAID_ACTION_FIELDS} ${ITEM_ACT_PAID_ACTION_FIELDS} - mutation retryPaidAction($invoiceId: Int!) { - retryPaidAction(invoiceId: $invoiceId) { + ${SUB_FULL_FIELDS} + + mutation retryPaidAction($invoiceId: Int!, $forceInternal: Boolean, $attempt: Int, $prioritizeInternal: Boolean) { + retryPaidAction(invoiceId: $invoiceId, forceInternal: $forceInternal, attempt: $attempt, prioritizeInternal: $prioritizeInternal) { __typename ...PaidActionFields ... on ItemPaidAction { @@ -103,6 +106,16 @@ export const RETRY_PAID_ACTION = gql` id } } + ... on SubPaidAction { + result { + ...SubFullFields + } + } + ... on DonatePaidAction { + result { + sats + } + } } }` diff --git a/fragments/wallet.js b/fragments/wallet.js index c9cdd65ce..b55eecd74 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -3,6 +3,7 @@ import { ITEM_FULL_FIELDS } from './items' import { VAULT_ENTRY_FIELDS } from './vault' export const INVOICE_FIELDS = gql` + fragment InvoiceFields on Invoice { id hash @@ -21,6 +22,11 @@ export const INVOICE_FIELDS = gql` actionType actionError confirmedPreimage + invoiceForward { + withdrawl { + status + } + } }` export const INVOICE_FULL = gql` diff --git a/lib/constants.js b/lib/constants.js index d1de99710..24c58b8a2 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -7,7 +7,8 @@ export const PAID_ACTION_PAYMENT_METHODS = { FEE_CREDIT: 'FEE_CREDIT', PESSIMISTIC: 'PESSIMISTIC', OPTIMISTIC: 'OPTIMISTIC', - P2P: 'P2P' + P2P: 'P2P', + ZERO_COST: 'ZERO_COST' } export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING'] export const NOFOLLOW_LIMIT = 1000 diff --git a/wallets/server.js b/wallets/server.js index c08f04476..e72fcdf9b 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -14,7 +14,7 @@ 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 { toNumber, toPositiveBigInt, toPositiveNumber } from '@/lib/validate' import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { withTimeout } from '@/lib/time' import { canReceive } from './common' @@ -25,7 +25,7 @@ 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, wrap = false, feePercent, skipWallets = 0 }, { models, me, lnd }) { // get the wallets in order of priority const wallets = await models.wallet.findMany({ where: { userId, enabled: true }, @@ -39,9 +39,15 @@ export async function createInvoice (userId, { msats, description, descriptionHa ] }) - msats = toPositiveNumber(msats) + msats = toPositiveBigInt(msats) + let innerMsats = msats + if (wrap) { + if (!feePercent) throw new Error('feePercent is required for wrapped invoices') + innerMsats = msats * (100n - feePercent) / 100n + } - for (const wallet of wallets) { + for (let i = toNumber(Math.min(skipWallets, wallets.length), 0, wallets.length); i < wallets.length; i++) { + const wallet = wallets[i] const w = walletDefs.find(w => w.walletType === wallet.type) const config = wallet.wallet @@ -55,13 +61,13 @@ export async function createInvoice (userId, { msats, description, descriptionHa logger.info( `↙ incoming payment: ${formatSats(msatsToSats(msats))}`, { - amount: formatMsats(msats) - }) + amount: formatMsats(toNumber(msats)) + }) // TODO add fee info? let invoice try { invoice = await walletCreateInvoice( - { msats, description, descriptionHash, expiry }, + { msats: innerMsats, description, descriptionHash, expiry }, { ...w, userId, createInvoice: w.createInvoice }, { logger, models }) } catch (err) { @@ -74,21 +80,35 @@ export async function createInvoice (userId, { msats, description, descriptionHa bolt11: invoice }) - if (BigInt(bolt11.mtokens) !== BigInt(msats)) { - if (BigInt(bolt11.mtokens) > BigInt(msats)) { + if (BigInt(bolt11.mtokens) !== msats) { + if (BigInt(bolt11.mtokens) > msats) { throw new Error('invoice invalid: amount too big') } if (BigInt(bolt11.mtokens) === 0n) { throw new Error('invoice invalid: amount is 0 msats') } - if (BigInt(msats) - BigInt(bolt11.mtokens) >= 1000n) { + if (innerMsats - BigInt(bolt11.mtokens) >= 1000n) { throw new Error('invoice invalid: amount too small') } logger.warn('wallet does not support msats') } - return { invoice, wallet, logger } + let wrappedInvoice + let maxFee + + if (wrap) { + const wrappedInvoiceData = + await wrapInvoice( + { bolt11: invoice, feePercent }, + { msats, description, descriptionHash }, + { me, lnd } + ) + wrappedInvoice = wrappedInvoiceData.invoice.request + maxFee = wrappedInvoiceData.maxFee + } + + return { invoice, wallet, logger, wrappedInvoice, maxFee, retriable: i < wallets.length - 1 } } catch (err) { logger.error(err.message) } @@ -98,34 +118,18 @@ export async function createInvoice (userId, { msats, description, descriptionHa } export async function createWrappedInvoice (userId, - { msats, feePercent, description, descriptionHash, expiry = 360 }, + { msats, feePercent, description, descriptionHash, expiry = 360, skipWallets = 0 }, { models, me, lnd }) { - let logger, bolt11 - try { - const { invoice, wallet } = await createInvoice(userId, { - // this is the amount the stacker will receive, the other (feePercent)% is our fee - msats: toPositiveBigInt(msats) * (100n - feePercent) / 100n, - description, - descriptionHash, - expiry - }, { models }) - - logger = walletLogger({ wallet, models }) - bolt11 = invoice - - const { invoice: wrappedInvoice, maxFee } = - await wrapInvoice({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd }) - - return { - invoice, - wrappedInvoice: wrappedInvoice.request, - wallet, - maxFee - } - } catch (e) { - logger?.error('invalid invoice: ' + e.message, { bolt11 }) - throw e - } + return await createInvoice(userId, { + // this is the amount the stacker will receive, the other (feePercent)% is our fee + msats, + feePercent, + wrap: true, + description, + descriptionHash, + expiry, + skipWallets + }, { models, me, lnd }) } async function walletCreateInvoice ( @@ -142,6 +146,7 @@ async function walletCreateInvoice ( createInvoice }, { logger, models }) { + msats = toPositiveNumber(msats) const wallet = await models.wallet.findFirst({ where: { userId, diff --git a/worker/territory.js b/worker/territory.js index e687b6125..87c2e4cf5 100644 --- a/worker/territory.js +++ b/worker/territory.js @@ -1,7 +1,6 @@ import lnd from '@/api/lnd' import performPaidAction from '@/api/paidAction' import serialize from '@/api/resolvers/serial' -import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { nextBillingWithGrace } from '@/lib/territory' import { datePivot } from '@/lib/time' @@ -37,12 +36,7 @@ export async function territoryBilling ({ data: { subName }, boss, models }) { try { const { result } = await performPaidAction('TERRITORY_BILLING', - { name: subName }, { - models, - me: sub.user, - lnd, - forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT - }) + { name: subName }, { models, me: sub.user, lnd, forceInternal: true }) if (!result) { throw new Error('not enough fee credits to auto-renew territory') } diff --git a/worker/weeklyPosts.js b/worker/weeklyPosts.js index 275b23bab..5b764b7f5 100644 --- a/worker/weeklyPosts.js +++ b/worker/weeklyPosts.js @@ -1,17 +1,12 @@ import performPaidAction from '@/api/paidAction' -import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' +import { USER_ID } from '@/lib/constants' import { datePivot } from '@/lib/time' import gql from 'graphql-tag' export async function autoPost ({ data: item, models, apollo, lnd, boss }) { return await performPaidAction('ITEM_CREATE', { ...item, subName: 'meta', userId: USER_ID.sn, apiKey: true }, - { - models, - me: { id: USER_ID.sn }, - lnd, - forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT - }) + { models, me: { id: USER_ID.sn }, lnd, forceInternal: true }) } export async function weeklyPost (args) { @@ -52,10 +47,5 @@ export async function payWeeklyPostBounty ({ data: { id }, models, apollo, lnd } await performPaidAction('ZAP', { id: winner.id, sats: item.bounty }, - { - models, - me: { id: USER_ID.sn }, - lnd, - forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT - }) + { models, me: { id: USER_ID.sn }, lnd, forceInternal: true }) } From 0223c9e43894d54b82cbfc035f1955581abb44c1 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Thu, 14 Nov 2024 15:03:41 +0100 Subject: [PATCH 02/19] clean payment methods exception handling --- api/paidAction/index.js | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 3969ea33a..0b3010e43 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -98,28 +98,21 @@ export default async function performPaidAction (actionType, args, { ...context for (const paymentMethod of supportedPaymentMethods) { console.log(`trying payment method ${paymentMethod}`) - // internal actions with constraint checks - try { - switch (paymentMethod) { - case PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT: { - if (!me || (me.msats ?? 0n) < cost) break // if anon or low balance skip - return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod }) - } - // more internal payment methods here -> fee credit - } - } catch (e) { - console.error('performPaidAction failed with internal payment method', e) - // if we fail for reasons unrelated to balance, we should throw to fail the mutation - if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { - throw e + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS) { + try { + return await performP2PAction(actionType, args, context, paymentMethod) + } catch (e) { + // p2p can fail for various reasons, if it does, we should try another payment method + console.error('paid action failed with P2P payment method, try another one', e) + continue } } - // other actions try { switch (paymentMethod) { - case PAID_ACTION_PAYMENT_METHODS.P2P: { - return await performP2PAction(actionType, args, context, paymentMethod) + case PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT: { + if (!me || (me.msats ?? 0n) < cost) break // if anon or low balance skip + return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod }) } case PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC: { if (!me) break // anons are not optimistic @@ -130,10 +123,15 @@ export default async function performPaidAction (actionType, args, { ...context } } } catch (e) { - console.error('performPaidAction failed', e) + console.error('performPaidAction failed with internal payment method', e) + // if we fail for reasons unrelated to balance, we should throw to fail the mutation + if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { + throw e + } } } + // if we reach this point, no payment method succeeded throw new Error('no payment method succeeded') } finally { console.groupEnd() @@ -198,12 +196,6 @@ async function performOptimisticAction (actionType, args, { ...context }) { } async function beginPessimisticAction (actionType, args, { ...context }) { - const action = paidActions[actionType] - - if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC)) { - throw new Error(`This action ${actionType} does not support pessimistic invoicing`) - } - // just create the invoice and complete action when it's paid const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(context) return { From a016427b1bbf51e6deb0db5a28888f8023aa94ec Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Thu, 14 Nov 2024 15:04:13 +0100 Subject: [PATCH 03/19] fix --- api/paidAction/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 0b3010e43..f91a97e20 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -98,7 +98,7 @@ export default async function performPaidAction (actionType, args, { ...context for (const paymentMethod of supportedPaymentMethods) { console.log(`trying payment method ${paymentMethod}`) - if (paymentMethod === PAID_ACTION_PAYMENT_METHODS) { + if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P) { try { return await performP2PAction(actionType, args, context, paymentMethod) } catch (e) { @@ -124,7 +124,7 @@ export default async function performPaidAction (actionType, args, { ...context } } catch (e) { console.error('performPaidAction failed with internal payment method', e) - // if we fail for reasons unrelated to balance, we should throw to fail the mutation + // if we fail for reasons unrelated to balance, we should throw to fail the action if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { throw e } From bf04342b1efed1942b58481a7d53ebbebc3a3829 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Thu, 14 Nov 2024 15:37:26 +0100 Subject: [PATCH 04/19] comments --- api/paidAction/index.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/api/paidAction/index.js b/api/paidAction/index.js index f91a97e20..370cacb42 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -68,22 +68,26 @@ export default async function performPaidAction (actionType, args, { ...context return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod: PAID_ACTION_PAYMENT_METHODS.ZERO_COST }) } - // ort supported payment methods + // sort and filter supported payment methods if (forceInternal) { - // forced internal payments, so we keep only the payment methods that qualify as such + // we keep only the payment methods that qualify as internal payments if (!me) { throw new Error('user must be logged in to use internal payments') } const forcedPaymentMethods = [] + // reset the supported payment methods to only include internal methods + // that are supported by the action if (context.supportedPaymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT)) { forcedPaymentMethods.push(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) } + // TODO: add reward sats + // ... if (forcedPaymentMethods.length === 0) { throw new Error('action does not support internal payments') } context.supportedPaymentMethods = forcedPaymentMethods } else if (prioritizeInternal) { - // prefer internal payment methods + // prefer internal payment methods over the others (if they are supported) const priority = { [PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT]: -2 // add other internal methods here @@ -114,6 +118,8 @@ export default async function performPaidAction (actionType, args, { ...context if (!me || (me.msats ?? 0n) < cost) break // if anon or low balance skip return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod }) } + // TODO: add reward sats + // ... case PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC: { if (!me) break // anons are not optimistic return await performOptimisticAction(actionType, args, context) @@ -176,6 +182,7 @@ async function performOptimisticAction (actionType, args, { ...context }) { const action = paidActions[actionType] context.optimistic = true + // create the invoice and perform the action immediately( invoiceArgs could be passed in by the p2p method) const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(context) const run = async tx => { @@ -196,7 +203,7 @@ async function performOptimisticAction (actionType, args, { ...context }) { } async function beginPessimisticAction (actionType, args, { ...context }) { - // just create the invoice and complete action when it's paid + // just create the invoice and complete action when it's paid (invoiceArgs could be passed in by the p2p method) const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(context) return { invoice: await createDbInvoice(actionType, args, { ...context, invoiceArgs }), From 22c0e809dbfb56c9a86fd3612913760305125228 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 15 Nov 2024 01:12:44 +0100 Subject: [PATCH 05/19] expose only the withdrawl status --- api/typeDefs/wallet.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 36a34fb10..c593e1862 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -122,11 +122,15 @@ const typeDefs = ` actionError: String item: Item itemAct: ItemAct - invoiceForward: InvoiceForward + invoiceForward: InvoiceForwardStatus } - type InvoiceForward { - withdrawl: Withdrawl + type InvoiceForwardStatus { + withdrawl: WithdrawlStatus + } + + type WithdrawlStatus { + status: String } type Withdrawl { From 0fab13d8df473e61ded1bbeefc438b4f9d858549 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 15 Nov 2024 01:23:07 +0100 Subject: [PATCH 06/19] bail early if the invoice was canceled or expired --- components/use-paid-mutation.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index ae258b9fb..d36b6f803 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -1,7 +1,7 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { useCallback, useState } from 'react' import { useInvoice, useQrPayment, useWalletPayment } from './payment' -import { InvoiceCanceledError } from '@/wallets/errors' +import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors' import { GET_PAID_ACTION, RETRY_PAID_ACTION } from '@/fragments/paidAction' import { useWallets, useWallet } from '@/wallets/index' import { canSend } from '@/wallets/common' @@ -106,6 +106,10 @@ export function usePaidMutation (mutation, console.log('paid with wallet', senderWallet.def.name) return { invoice, response } } catch (err) { + if (err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) { + // bail early if the invoice was canceled or expired + throw err + } walletErrors.push(err) // get action data const { data: paidActionData } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } }) From 468df05798fe572158244436550cb6bfaac5793f Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 15 Nov 2024 01:29:40 +0100 Subject: [PATCH 07/19] login check for retryPaidAction --- api/paidAction/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 370cacb42..9bb291bab 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -255,7 +255,9 @@ async function performP2PAction (actionType, args, { ...context }) { export async function retryPaidAction ({ invoiceId, forceInternal, attempt, prioritizeInternal }, { ...context }) { const { models, me } = context - const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id ?? USER_ID.anon } }) + if (!me) throw new Error('user must be logged in to retry paid actions') + + const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id } }) if (!failedInvoice) { throw new Error('invoice not found') From 0e8b8ce73f7e79bf1c9180c5663d926a6eb2b170 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 15 Nov 2024 01:32:35 +0100 Subject: [PATCH 08/19] comments --- api/paidAction/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 9bb291bab..e870631ae 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -255,7 +255,10 @@ async function performP2PAction (actionType, args, { ...context }) { export async function retryPaidAction ({ invoiceId, forceInternal, attempt, prioritizeInternal }, { ...context }) { const { models, me } = context - if (!me) throw new Error('user must be logged in to retry paid actions') + if (!me) { + // otherwise every anon could retry retry other anons' actions + throw new Error('user must be logged in to retry paid actions') + } const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id } }) From a6ffcda8c6db0710ebc6a250a393706c63bbd56e Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 15 Nov 2024 01:36:09 +0100 Subject: [PATCH 09/19] cleanup --- api/paidAction/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/paidAction/index.js b/api/paidAction/index.js index e870631ae..847e43314 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -260,7 +260,7 @@ export async function retryPaidAction ({ invoiceId, forceInternal, attempt, prio throw new Error('user must be logged in to retry paid actions') } - const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id } }) + const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } }) if (!failedInvoice) { throw new Error('invoice not found') @@ -275,7 +275,7 @@ export async function retryPaidAction ({ invoiceId, forceInternal, attempt, prio const paidAction = paidActions[actionType] if (!paidAction) { - throw new Error(`retryPaidAction - invalid action type ${actionType}`) + throw new Error(`invalid action type ${actionType}`) } const { msatsRequested, actionId, actionArgs } = failedInvoice From 85678150dc330500a8570f7b08301ccc5c2d151d Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Sat, 16 Nov 2024 21:10:46 +0100 Subject: [PATCH 10/19] lint fix --- wallets/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallets/server.js b/wallets/server.js index 3c52f92f8..996dddfde 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -14,7 +14,7 @@ import * as webln from '@wallets/webln' import { walletLogger } from '@/api/resolvers/wallet' import walletDefs from '@wallets/server' import { parsePaymentRequest } from 'ln-service' -import { toNumber, toPositiveBigInt, toPositiveNumber } from '@/lib/validate' +import { toNumber, toPositiveBigInt } from '@/lib/validate' import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { withTimeout } from '@/lib/time' import { canReceive } from './common' From 4e96d5d2a80e1cfba65f1976b857c904722b5a94 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Sun, 17 Nov 2024 10:43:16 +0100 Subject: [PATCH 11/19] fix merging issue and apply review suggestions --- api/paidAction/index.js | 11 ++++++----- components/payment.js | 2 +- components/qr.js | 6 ++---- wallets/server.js | 29 +++++++++++++++-------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 893843016..db18adefa 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -49,9 +49,9 @@ export default async function performPaidAction (actionType, args, { ...context context.me = context.me ? await models.user.findUnique({ where: { id: context.me.id } }) : undefined context.cost = await paidAction.getCost(args, context) context.sybilFeePercent = await paidAction.getSybilFeePercent?.(args, context) - context.attempt = context.attempt ?? 0 // how many times the client thinks it has tried - context.forceInternal = context.forceInternal ?? false // use only internal payment methods - context.prioritizeInternal = context.prioritizeInternal ?? false // prefer internal payment methods + context.attempt ??= 0 // how many times the client thinks it has tried + context.forceInternal ??= false // use only internal payment methods + context.prioritizeInternal ??= false // prefer internal payment methods context.description = context.me?.hideInvoiceDesc ? undefined : await paidAction.describe?.(args, context) context.descriptionHash = await paidAction.describeHash?.(args, context) context.supportedPaymentMethods = paidAction.paymentMethods ?? await paidAction.getPaymentMethods?.(args, context) ?? [] @@ -108,7 +108,7 @@ export default async function performPaidAction (actionType, args, { ...context if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P) { try { - return await performP2PAction(actionType, args, context, paymentMethod) + return await performP2PAction(actionType, args, context) } catch (e) { // p2p can fail for various reasons, if it does, we should try another payment method console.error('paid action failed with P2P payment method, try another one', e) @@ -238,7 +238,7 @@ async function performP2PAction (actionType, args, { ...context }) { description, descriptionHash, expiry: INVOICE_EXPIRE_SECS, - skipWallets: attempt + walletOffset: attempt }, { models, me, lnd }) context.invoiceArgs = { @@ -291,6 +291,7 @@ export async function retryPaidAction ({ invoiceId, forceInternal, attempt, prio context.prioritizeInternal = prioritizeInternal return await models.$transaction(async tx => { + context.tx = tx const supportRetrying = paidAction.retry if (supportRetrying) { // update the old invoice to RETRYING, so that it's not confused with FAILED diff --git a/components/payment.js b/components/payment.js index 7e4dbba20..526a23122 100644 --- a/components/payment.js +++ b/components/payment.js @@ -115,7 +115,7 @@ export const useWalletPayment = () => { const defaultWallet = useWallet() const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor, wallet) => { - wallet = wallet ?? defaultWallet + wallet ??= defaultWallet if (!wallet) { throw new NoAttachedWalletError() } diff --git a/components/qr.js b/components/qr.js index a4220ef71..3af278c49 100644 --- a/components/qr.js +++ b/components/qr.js @@ -21,10 +21,8 @@ export default function Qr ({ asIs, value, useWallet: automated, statusVariant, useEffect(() => { async function effect () { - const usableWallets = wallets.filter(w => !w.def.isAvailable || w.def.isAvailable()) - .filter(w => w.config?.enabled && canSend(w))[0] - if (automated && usableWallets.length > 0) { - for (const wallet of usableWallets) { + if (automated && wallets.length > 0) { + for (const wallet of wallets) { try { await wallet.sendPayment(value) break diff --git a/wallets/server.js b/wallets/server.js index 996dddfde..db76bece3 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -1,18 +1,18 @@ // import server side wallets -import * as lnd from '@wallets/lnd/server' -import * as cln from '@wallets/cln/server' -import * as lnAddr from '@wallets/lightning-address/server' -import * as lnbits from 'wallets/lnbits/server' -import * as nwc from '@wallets/nwc/server' -import * as phoenixd from '@wallets/phoenixd/server' -import * as blink from '@wallets/blink/server' +import * as lnd from '@/wallets/lnd/server' +import * as cln from '@/wallets/cln/server' +import * as lnAddr from '@/wallets/lightning-address/server' +import * as lnbits from '@/wallets/lnbits/server' +import * as nwc from '@/wallets/nwc/server' +import * as phoenixd from '@/wallets/phoenixd/server' +import * as blink from '@/wallets/blink/server' // we import only the metadata of client side wallets -import * as lnc from '@wallets/lnc' -import * as webln from '@wallets/webln' +import * as lnc from '@/wallets/lnc' +import * as webln from '@/wallets/webln' import { walletLogger } from '@/api/resolvers/wallet' -import walletDefs from '@wallets/server' +import walletDefs from '@/wallets/server' import { parsePaymentRequest } from 'ln-service' import { toNumber, toPositiveBigInt } from '@/lib/validate' import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' @@ -25,7 +25,7 @@ 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, wrap = false, feePercent, skipWallets = 0 }, { models, me, lnd }) { +export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360, wrap = false, feePercent, walletOffset = 0 }, { models, me, lnd }) { // get the wallets in order of priority const wallets = await getInvoiceableWallets(userId, { models }) @@ -37,7 +37,8 @@ export async function createInvoice (userId, { msats, description, descriptionHa innerMsats = msats * (100n - feePercent) / 100n } - for (let i = toNumber(Math.min(skipWallets, wallets.length), 0, wallets.length); i < wallets.length; i++) { + const offset = toNumber(Math.min(walletOffset, wallets.length), 0, wallets.length) + for (let i = offset; i < wallets.length; i++) { const { def, wallet } = wallets[i] const config = wallet.wallet @@ -108,7 +109,7 @@ export async function createInvoice (userId, { msats, description, descriptionHa } export async function createWrappedInvoice (userId, - { msats, feePercent, description, descriptionHash, expiry = 360, skipWallets = 0 }, + { msats, feePercent, description, descriptionHash, expiry = 360, walletOffset = 0 }, { models, me, lnd }) { return await createInvoice(userId, { // this is the amount the stacker will receive, the other (feePercent)% is our fee @@ -118,7 +119,7 @@ export async function createWrappedInvoice (userId, description, descriptionHash, expiry, - skipWallets + walletOffset }, { models, me, lnd }) } From f94c92ba6bf7e12bfe5aefd03ad860db2ff01330 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Sun, 17 Nov 2024 10:46:42 +0100 Subject: [PATCH 12/19] remove unrelated change --- api/paidAction/index.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/api/paidAction/index.js b/api/paidAction/index.js index db18adefa..df072979f 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -53,7 +53,6 @@ export default async function performPaidAction (actionType, args, { ...context context.forceInternal ??= false // use only internal payment methods context.prioritizeInternal ??= false // prefer internal payment methods context.description = context.me?.hideInvoiceDesc ? undefined : await paidAction.describe?.(args, context) - context.descriptionHash = await paidAction.describeHash?.(args, context) context.supportedPaymentMethods = paidAction.paymentMethods ?? await paidAction.getPaymentMethods?.(args, context) ?? [] const { @@ -217,7 +216,7 @@ async function beginPessimisticAction (actionType, args, { ...context }) { } async function performP2PAction (actionType, args, { ...context }) { - const { cost, models, lnd, sybilFeePercent, me, supportedPaymentMethods, description, descriptionHash, attempt } = context + const { cost, models, lnd, sybilFeePercent, me, supportedPaymentMethods, description, attempt } = context if (!sybilFeePercent) { throw new Error('sybil fee percent is not set for an invoiceable peer action') } @@ -236,7 +235,6 @@ async function performP2PAction (actionType, args, { ...context }) { msats: cost, feePercent: sybilFeePercent, description, - descriptionHash, expiry: INVOICE_EXPIRE_SECS, walletOffset: attempt }, { models, me, lnd }) @@ -321,7 +319,7 @@ export class NonInvoiceablePeerError extends Error { // we seperate the invoice creation into two functions because // because if lnd is slow, it'll timeout the interactive tx async function createSNInvoice (context) { - const { lnd, cost, optimistic, description, descriptionHash } = context + const { lnd, cost, optimistic, description } = context const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice @@ -335,7 +333,6 @@ async function createSNInvoice (context) { const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS }) const invoice = await createLNDInvoice({ description, - descriptionHash, lnd, mtokens: String(cost), expires_at: expiresAt From 03f9a928368b2d73aa423d17ae727113422c79d8 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Sun, 17 Nov 2024 10:49:16 +0100 Subject: [PATCH 13/19] remove unused import --- components/qr.js | 1 - 1 file changed, 1 deletion(-) diff --git a/components/qr.js b/components/qr.js index 3af278c49..d7403444c 100644 --- a/components/qr.js +++ b/components/qr.js @@ -4,7 +4,6 @@ import InvoiceStatus from './invoice-status' import { useEffect } from 'react' import { useWallets } from '@/wallets/index' import Bolt11Info from './bolt11-info' -import { canSend } from '@/wallets/common' export const qrImageSettings = { src: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 256 256\'%3E%3Cpath fill-rule=\'evenodd\' d=\'m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z\'/%3E%3C/svg%3E', From 811aa6cce46e1624313e0463294c368ac91d66a5 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Mon, 18 Nov 2024 10:45:45 +0100 Subject: [PATCH 14/19] apply review suggestions --- components/poll.js | 4 ++-- components/qr.js | 10 +++++----- components/use-paid-mutation.js | 18 +++--------------- wallets/index.js | 11 +++++++++++ 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/components/poll.js b/components/poll.js index 746eb131f..264ef8e16 100644 --- a/components/poll.js +++ b/components/poll.js @@ -103,7 +103,7 @@ function PollResult ({ v, progress }) { ) } -export function usePollVote ({ query = POLL_VOTE, itemId, ...options }) { +export function usePollVote ({ query = POLL_VOTE, itemId }) { const update = (cache, { data }) => { // the mutation name varies for optimistic retries const response = Object.values(data)[0] @@ -185,6 +185,6 @@ export function usePollVote ({ query = POLL_VOTE, itemId, ...options }) { }) } - const [pollVote] = usePaidMutation(query, { update, onPayError, onPaid, ...options }) + const [pollVote] = usePaidMutation(query, { update, onPayError, onPaid }) return pollVote } diff --git a/components/qr.js b/components/qr.js index d7403444c..837468ad8 100644 --- a/components/qr.js +++ b/components/qr.js @@ -2,7 +2,7 @@ import { QRCodeSVG } from 'qrcode.react' import { CopyInput, InputSkeleton } from './form' import InvoiceStatus from './invoice-status' import { useEffect } from 'react' -import { useWallets } from '@/wallets/index' +import { useEnabledWallets } from '@/wallets/index' import Bolt11Info from './bolt11-info' export const qrImageSettings = { @@ -16,12 +16,12 @@ export const qrImageSettings = { export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) { const qrValue = asIs ? value : 'lightning:' + value.toUpperCase() - const { wallets } = useWallets() + const senderWallets = useEnabledWallets() useEffect(() => { async function effect () { - if (automated && wallets.length > 0) { - for (const wallet of wallets) { + if (automated && senderWallets.length > 0) { + for (const wallet of senderWallets) { try { await wallet.sendPayment(value) break @@ -32,7 +32,7 @@ export default function Qr ({ asIs, value, useWallet: automated, statusVariant, } } effect() - }, [wallets]) + }, [senderWallets]) return ( <> diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index d36b6f803..45c23d5ed 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -3,8 +3,8 @@ import { useCallback, useState } from 'react' import { useInvoice, useQrPayment, useWalletPayment } from './payment' import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors' import { GET_PAID_ACTION, RETRY_PAID_ACTION } from '@/fragments/paidAction' -import { useWallets, useWallet } from '@/wallets/index' -import { canSend } from '@/wallets/common' +import { useEnabledWallets } from '@/wallets/index' + import { useMe } from './me' /* this is just like useMutation with a few changes: @@ -32,7 +32,7 @@ export function usePaidMutation (mutation, const client = useApolloClient() // innerResult is used to store/control the result of the mutation when innerMutate runs const [innerResult, setInnerResult] = useState(result) - const { wallets: walletDefs } = useWallets() + const senderWallets = useEnabledWallets() const { me } = useMe() const addPayError = (e, rest) => ({ @@ -41,14 +41,6 @@ export function usePaidMutation (mutation, error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined }) - // walletDefs shouldn't change on rerender, so it should be safe - const senderWallets = walletDefs - .map(w => useWallet(w.def.name)) - .filter(w => !w.def.isAvailable || w.def.isAvailable()) - .filter(w => w.config?.enabled && canSend(w)).map(w => { - return { ...w, failed: false } - }) - const waitForActionPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }, originalResponse, action) => { const walletErrors = [] let response = originalResponse @@ -78,10 +70,6 @@ export function usePaidMutation (mutation, // if anon we go straight to qr code if (!me) { await refreshInvoice() - if (!invoice) { - setInnerResult(r => addPayError(new Error('You must be logged in'), r)) - throw new Error('You must be logged in') - } await waitForQrPayment(invoice, null, { persistOnNavigate, waitFor }) return { invoice, response } } diff --git a/wallets/index.js b/wallets/index.js index 719e230f6..943634d97 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -215,6 +215,17 @@ export function useWallets () { return useContext(WalletsContext) } +export function useEnabledWallets () { + const { wallets } = useContext(WalletsContext) + // walletDefs shouldn't change on rerender, so it should be safe + return wallets + .map(w => useWallet(w.def.name)) + .filter(w => !w.def.isAvailable || w.def.isAvailable()) + .filter(w => w.config?.enabled && canSend(w)).map(w => { + return { ...w, failed: false } + }) +} + export function useWallet (name) { const { wallets } = useWallets() From f3e80b16375cecd1411aeb6ca5f0d06b11c6937f Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Mon, 18 Nov 2024 18:26:46 +0100 Subject: [PATCH 15/19] fix bigint conversion lost in merge --- wallets/server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wallets/server.js b/wallets/server.js index db76bece3..6f77c6742 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -14,7 +14,7 @@ import * as webln from '@/wallets/webln' import { walletLogger } from '@/api/resolvers/wallet' import walletDefs from '@/wallets/server' import { parsePaymentRequest } from 'ln-service' -import { toNumber, toPositiveBigInt } from '@/lib/validate' +import { toNumber, toPositiveBigInt, toPositiveNumber } from '@/lib/validate' import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { withTimeout } from '@/lib/time' import { canReceive } from './common' @@ -181,7 +181,7 @@ async function walletCreateInvoice ( return await withTimeout( def.createInvoice( { - msats, + msats: toPositiveNumber(msats), // TODO: should probably make the wallet interface work with bigints description: wallet.user.hideInvoiceDesc ? undefined : description, descriptionHash, expiry From 6a1d184520014339aebddb65c54c1b4587675b39 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Mon, 18 Nov 2024 18:27:31 +0100 Subject: [PATCH 16/19] move wallet map to failing states in use-paid-mutation --- components/use-paid-mutation.js | 5 ++++- wallets/index.js | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index 45c23d5ed..e913577e0 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -32,9 +32,12 @@ export function usePaidMutation (mutation, const client = useApolloClient() // innerResult is used to store/control the result of the mutation when innerMutate runs const [innerResult, setInnerResult] = useState(result) - const senderWallets = useEnabledWallets() const { me } = useMe() + const senderWallets = useEnabledWallets().map(w => { + return { ...w, failed: false } + }) + const addPayError = (e, rest) => ({ ...rest, payError: e, diff --git a/wallets/index.js b/wallets/index.js index 943634d97..50095480a 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -221,9 +221,7 @@ export function useEnabledWallets () { return wallets .map(w => useWallet(w.def.name)) .filter(w => !w.def.isAvailable || w.def.isAvailable()) - .filter(w => w.config?.enabled && canSend(w)).map(w => { - return { ...w, failed: false } - }) + .filter(w => w.config?.enabled && canSend(w)) } export function useWallet (name) { From 69b59b8f2ba09a5a659df67410dd1ef30aa30ddc Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 19 Nov 2024 10:19:19 +0100 Subject: [PATCH 17/19] fix tx timeout --- api/paidAction/index.js | 67 ++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/api/paidAction/index.js b/api/paidAction/index.js index df072979f..d3b609d62 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -153,6 +153,18 @@ async function performNoInvoiceAction (actionType, args, { ...context }) { const run = async tx => { context.tx = tx + const failedInvoice = context.retryForInvoice + if (failedInvoice) { + await tx.invoice.update({ + where: { + id: failedInvoice.id, + actionState: 'FAILED' + }, + data: { + actionState: 'RETRYING' + } + }) + } if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) { await tx.user.update({ @@ -190,6 +202,18 @@ async function performOptimisticAction (actionType, args, { ...context }) { const run = async tx => { context.tx = tx + const failedInvoice = context.retryForInvoice + if (failedInvoice) { + await tx.invoice.update({ + where: { + id: failedInvoice.id, + actionState: 'FAILED' + }, + data: { + actionState: 'RETRYING' + } + }) + } const invoice = await createDbInvoice(actionType, args, { ...context, invoiceArgs }) const result = await performAction(invoice, action, args, context) @@ -208,11 +232,28 @@ async function performOptimisticAction (actionType, args, { ...context }) { async function beginPessimisticAction (actionType, args, { ...context }) { // just create the invoice and complete action when it's paid (invoiceArgs could be passed in by the p2p method) const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(context) - return { - invoice: await createDbInvoice(actionType, args, { ...context, invoiceArgs }), - paymentMethod: 'PESSIMISTIC', - retriable: false + const run = async tx => { + context.tx = tx + const failedInvoice = context.retryForInvoice + if (failedInvoice) { + await tx.invoice.update({ + where: { + id: failedInvoice.id, + actionState: 'FAILED' + }, + data: { + actionState: 'RETRYING' + } + }) + } + return { + invoice: await createDbInvoice(actionType, args, { ...context, invoiceArgs }), + paymentMethod: 'PESSIMISTIC', + retriable: false + } } + // if this is nested into another transaction (eg for retryPaidAction), use the parent transaction + return context.tx ? await run(context.tx) : await context.models.$transaction(run, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) } async function performP2PAction (actionType, args, { ...context }) { @@ -288,23 +329,7 @@ export async function retryPaidAction ({ invoiceId, forceInternal, attempt, prio context.attempt = attempt context.prioritizeInternal = prioritizeInternal - return await models.$transaction(async tx => { - context.tx = tx - const supportRetrying = paidAction.retry - if (supportRetrying) { - // update the old invoice to RETRYING, so that it's not confused with FAILED - await tx.invoice.update({ - where: { - id: failedInvoice.id, - actionState: 'FAILED' - }, - data: { - actionState: 'RETRYING' - } - }) - } - return await performPaidAction(actionType, actionArgs, context) - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + return await performPaidAction(actionType, actionArgs, context) } const INVOICE_EXPIRE_SECS = 600 From 1dc983fc2926a392730ae605c8acc5bd5edaa0e1 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 19 Nov 2024 12:51:06 +0100 Subject: [PATCH 18/19] no fallback in qr payment --- components/qr.js | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/components/qr.js b/components/qr.js index 837468ad8..38fa6f8b3 100644 --- a/components/qr.js +++ b/components/qr.js @@ -2,7 +2,7 @@ import { QRCodeSVG } from 'qrcode.react' import { CopyInput, InputSkeleton } from './form' import InvoiceStatus from './invoice-status' import { useEffect } from 'react' -import { useEnabledWallets } from '@/wallets/index' +import { useWallet } from '@/wallets/index' import Bolt11Info from './bolt11-info' export const qrImageSettings = { @@ -16,23 +16,19 @@ export const qrImageSettings = { export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) { const qrValue = asIs ? value : 'lightning:' + value.toUpperCase() - const senderWallets = useEnabledWallets() - + const wallet = useWallet() useEffect(() => { async function effect () { - if (automated && senderWallets.length > 0) { - for (const wallet of senderWallets) { - try { - await wallet.sendPayment(value) - break - } catch (e) { - console.log(e?.message) - } + if (automated && wallet) { + try { + await wallet.sendPayment(value) + } catch (e) { + console.log(e?.message) } } } effect() - }, [senderWallets]) + }, [wallet]) return ( <> From ecb908ae86828eb887970fa527ca1881121902ba Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 19 Nov 2024 17:37:50 +0100 Subject: [PATCH 19/19] throw if invoice fails to cancel --- components/use-paid-mutation.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index e913577e0..765e26191 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -53,9 +53,8 @@ export function usePaidMutation (mutation, try { invoiceUsed = true await invoiceHelper.cancel(invoice) - console.log('old invoice canceled') - } catch (err) { - console.error('could not cancel old invoice', err) + } catch (e) { + throw new InvoiceCancellationFailure(e.message) } } @@ -136,6 +135,9 @@ export function usePaidMutation (mutation, invoiceUsed = false } } catch (err) { + if (err instanceof InvoiceCancellationFailure) { + throw new Error('could not cancel the invoice') + } console.log('could not pay with internal payment... will fallback to another method') walletErrors.push(err) } @@ -282,3 +284,10 @@ export const paidActionCacheMods = { }) } } + +class InvoiceCancellationFailure extends Error { + constructor (message) { + super(message) + this.name = 'InvoiceCancellationFailure' + } +}