diff --git a/api/paidAction/boost.js b/api/paidAction/boost.js
index 05d8001743..e833df8a84 100644
--- a/api/paidAction/boost.js
+++ b/api/paidAction/boost.js
@@ -34,12 +34,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 3807462ef8..0fb04b113a 100644
--- a/api/paidAction/downZap.js
+++ b/api/paidAction/downZap.js
@@ -35,12 +35,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 81779883b8..a973b58cfc 100644
--- a/api/paidAction/index.js
+++ b/api/paidAction/index.js
@@ -1,7 +1,5 @@
-import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
-import { datePivot } from '@/lib/time'
-import { PAID_ACTION_PAYMENT_METHODS, PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
-import { createHmac } from '../resolvers/wallet'
+import { PAID_ACTION_TERMINAL_STATES, USER_ID, MAX_PENDING_PAID_ACTIONS_PER_USER, PAID_ACTION_PAYMENT_METHODS, INVOICE_EXPIRE_SECS } from '@/lib/constants'
+import { parsePaymentRequest, createInvoice as lndCreateInvoice, createHodlInvoice as lndCreateHodlInvoice } from 'ln-service'
import { Prisma } from '@prisma/client'
import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
@@ -15,8 +13,14 @@ import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate'
import * as BOOST from './boost'
import * as BUY_CREDITS from './buyCredits'
-import wrapInvoice from 'wallets/wrap'
-import { createInvoice as createUserInvoice } from 'wallets/server'
+import * as TRANSFER from './transfer'
+
+import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate'
+import { createHmac } from '@/lib/crypto'
+import { finalizeHodlInvoice } from 'worker/wallet'
+import { datePivot } from '@/lib/time'
+
+import { SN_WALLET, createInvoice as createUserInvoice, createHodlInvoice as createUserHodlInvoice } from 'wallets/server'
export const paidActions = {
ITEM_CREATE,
@@ -30,75 +34,159 @@ export const paidActions = {
TERRITORY_BILLING,
TERRITORY_UNARCHIVE,
DONATE,
+ TRANSFER,
BUY_CREDITS
}
+async function checkUser (userId, { models, tx, me }, minSats) {
+ // count pending invoices and bail if we're over the limit
+ const pendingInvoices = await (tx ?? models).invoice.count({
+ where: {
+ userId,
+ actionState: {
+ // not in a terminal state. Note: null isn't counted by prisma
+ notIn: PAID_ACTION_TERMINAL_STATES
+ }
+ }
+ })
+
+ console.log('pending paid actions', pendingInvoices)
+ if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) {
+ throw new Error('You have too many pending paid actions, cancel some or wait for them to expire')
+ }
+
+ if (minSats !== undefined && toPositiveBigInt(minSats) > (me?.msats ?? 0n)) {
+ throw new Error('You do not have enough sats to perform this action')
+ }
+}
+
export default async function performPaidAction (actionType, args, context) {
try {
- const { me, models, forceFeeCredits } = context
- const paidAction = paidActions[actionType]
-
console.group('performPaidAction', actionType, args)
-
+ const paidAction = paidActions[actionType]
if (!paidAction) {
throw new Error(`Invalid action type ${actionType}`)
}
- if (!me && !paidAction.anonable) {
- throw new Error('You must be logged in to perform this action')
- }
+ context.me = context.me ? await context.models.user.findUnique({ where: { id: context.me.id } }) : undefined
+ context.actionAttempt = context.actionAttempt ?? 0
+ context.forceInternal = context.forceInternal ?? false
+ context.prioritizeInternal = context.prioritizeInternal ?? false
+
+ context.cost = await paidAction.getCost(args, context) ?? 0n
+ if (context.cost < 0n) throw new Error('Cost cannot be negative')
- context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined
- context.cost = await paidAction.getCost(args, context)
+ context.sybilFeePercent = paidAction.getSybilFeePercent ? await paidAction.getSybilFeePercent(args, context) : undefined
+ if (context.sybilFeePercent !== undefined && context.sybilFeePercent < 0n) throw new Error('Sybil fee percent cannot be negative')
- // special case for zero cost actions
- if (context.cost === 0n) {
+ context.description = context.me?.hideInvoiceDesc ? undefined : await paidAction.describe(args, context)
+ context.descriptionHash = paidAction.describeHash ? await paidAction.describeHash(args, context) : undefined
+ context.supportedPaymentMethods = paidAction.paymentMethods ?? (paidAction.getPaymentMethods ? await paidAction.getPaymentMethods(args, context) : undefined)
+
+ const {
+ me,
+ actionAttempt,
+ forceInternal,
+ cost,
+ sybilFeePercent,
+ description,
+ descriptionHash,
+ prioritizeInternal,
+ lnd
+ } = context
+
+ let supportedPaymentMethods = context.supportedPaymentMethods
+
+ if (cost === 0n) {
console.log('performing zero cost action')
- return await performNoInvoiceAction(actionType, args, context, 'ZERO_COST')
+ // this cannot fail, ever
+ return await performInternalAction(cost, actionType, paidAction, PAID_ACTION_PAYMENT_METHODS.ZERO_COST, args, context)
}
- for (const paymentMethod of paidAction.paymentMethods) {
- console.log(`performing payment method ${paymentMethod}`)
-
- if (forceFeeCredits &&
- paymentMethod !== PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT &&
- paymentMethod !== PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
- throw new Error('forceFeeCredits is set, but user does not have enough fee credits or reward sats')
+ if (forceInternal) {
+ // forced internal transaction, 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 (supportedPaymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT)) {
+ forcedPaymentMethods.push(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT)
+ }
+ if (supportedPaymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.REWARD_SATS)) {
+ forcedPaymentMethods.push(PAID_ACTION_PAYMENT_METHODS.REWARD_SATS)
+ }
+ if (forcedPaymentMethods.length === 0) {
+ throw new Error('action does not support internal payments')
+ }
+ supportedPaymentMethods = forcedPaymentMethods
+ } else if (prioritizeInternal) {
+ // move internal payments to the top (fee first)
+ // without removing the other payment methods
+ const priority = {
+ [PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT]: -2,
+ [PAID_ACTION_PAYMENT_METHODS.REWARD_SATS]: -1
+ }
+ supportedPaymentMethods = supportedPaymentMethods.sort((a, b) => {
+ return (priority[a] || 0) - (priority[b] || 0)
+ })
+ console.log('Prioritize internal payment methods', supportedPaymentMethods)
+ }
- // 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.error(`${paymentMethod} action failed`, e)
- throw e
+ for (const paymentMethod of supportedPaymentMethods) {
+ if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) {
+ if (me && (me.mcredits ?? 0n) >= cost) {
+ try {
+ return await performInternalAction(cost, actionType, paidAction, paymentMethod, args, context)
+ } catch (e) {
+ console.error('payment method failed', paymentMethod, e)
}
}
- } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) {
- return await performPessimisticAction(actionType, args, context)
- }
-
- // additionalpayment methods that logged in users can use
- if (me) {
- if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT ||
- paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
+ } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
+ if (me && (me.msats ?? 0n) >= cost) {
try {
- return await performNoInvoiceAction(actionType, args, context, paymentMethod)
+ return await performInternalAction(cost, actionType, paidAction, paymentMethod, args, context)
} 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 \\"mcredits_positive\\"') &&
- !e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
- throw e
- }
+ console.error('payment method failed', paymentMethod, e)
}
- } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) {
- return await performOptimisticAction(actionType, args, context)
}
+ } else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P) {
+ try {
+ const optimistic =
+ paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC ||
+ // if action supports optimism and its a logged p2p action, we can try to be p2p and optimistic
+ (me && paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P &&
+ supportedPaymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC))
+
+ const receiverUserId = paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P ? await paidAction.invoiceablePeer?.(args, context) : undefined
+ if (!receiverUserId) throw new Error("this action doesn't support p2p payment method")
+ const invoiceData = await (optimistic ? createUserInvoice : createUserHodlInvoice)(receiverUserId, {
+ msats: cost,
+ description,
+ descriptionHash,
+ sybilFeePercent,
+ attempt: actionAttempt
+ }, context)
+ if (optimistic) return await performOptimisticAction(invoiceData, actionType, paidAction, args, context)
+ return await performPessimisticAction(invoiceData, actionType, paidAction, args, context)
+ } catch (e) {
+ console.error('payment method failed', paymentMethod, e)
+ }
+ } else {
+ // anon users are never optimistic
+ if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC && !me) continue
+ const optimistic = paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
+ const invoiceData = await createSNInvoice({
+ description,
+ descriptionHash,
+ cost,
+ isHodl: !optimistic
+ },
+ { lnd })
+ if (optimistic) return await performOptimisticAction(invoiceData, actionType, paidAction, args, context)
+ return await performPessimisticAction(invoiceData, actionType, paidAction, args, context)
}
}
+ throw new Error('No payment method available')
} catch (e) {
console.error('performPaidAction failed', e)
throw e
@@ -107,283 +195,304 @@ export default async function performPaidAction (actionType, args, context) {
}
}
-async function performNoInvoiceAction (actionType, args, context, paymentMethod) {
- const { me, models, cost } = context
- const action = paidActions[actionType]
+async function performAction (invoiceEntry, paidAction, args, context) {
+ const { retryForInvoice } = context
+ if (retryForInvoice && paidAction.retry) {
+ return await paidAction.retry({ invoiceId: retryForInvoice.id, newInvoiceId: invoiceEntry?.id }, context)
+ } else {
+ return await paidAction.perform?.({ invoiceId: invoiceEntry?.id, ...args }, context)
+ }
+}
- const result = await models.$transaction(async tx => {
+async function performInternalAction (cost, actionType, paidAction, paymentMethod, args, context) {
+ const { me, models, retryForInvoice } = context
+ const run = async tx => {
context.tx = tx
+ await checkUser(me?.id ?? USER_ID.anon, context, cost)
+
+ try {
+ if (paymentMethod !== PAID_ACTION_PAYMENT_METHODS.ZERO_COST) {
+ if (cost <= 0n) throw new Error('invalid cost')
+ console.log('enough fee credits available, performing internal action')
+ if ((paymentMethod === 'REWARD_SATS' || paymentMethod === 'FEE_CREDIT')) {
+ await tx.user.update({
+ where: {
+ id: me?.id ?? USER_ID.anon
+ },
+ data: paymentMethod === 'REWARD_SATS'
+ ? { msats: { decrement: cost } }
+ : { mcredits: { decrement: cost } }
+ })
+ console.log('Paid action with ' + paymentMethod)
+ } else throw new Error('Invalid payment method ' + paymentMethod)
+ } else {
+ console.log('No cost paid action')
+ }
- if (paymentMethod === 'REWARD_SATS' || paymentMethod === 'FEE_CREDIT') {
- await tx.user.update({
- where: {
- id: me?.id ?? USER_ID.anon
- },
- data: paymentMethod === 'REWARD_SATS'
- ? { msats: { decrement: cost } }
- : { mcredits: { decrement: cost } }
- })
- }
-
- const result = await action.perform(args, context)
- await action.onPaid?.(result, context)
+ const result = await performAction(null, paidAction, args, context)
+ await paidAction.onPaid?.({ ...result, invoice: null }, context)
- return {
- result,
- paymentMethod
+ return {
+ result,
+ paymentMethod,
+ retriableWithWallet: false
+ }
+ } catch (e) {
+ console.error('internal paid action failed', e)
+ // if it is retrying a fee credit action, we mark it as failed so it can be retried imeediately
+ if (retryForInvoice) {
+ await tx.invoice.update({
+ where: {
+ id: retryForInvoice.id
+ },
+ data: {
+ actionState: 'FAILED'
+ }
+ })
+ }
+ throw e
}
- }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
+ }
+ const result = context.tx
+ ? await run(context.tx)
+ : await models.$transaction(run, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
// run non critical side effects in the background
// after the transaction has been committed
- action.nonCriticalSideEffects?.(result.result, context).catch(console.error)
+ paidAction.nonCriticalSideEffects?.(result.result, context).catch(console.error)
return result
}
-async function performOptimisticAction (actionType, args, context) {
- const { models } = context
- const action = paidActions[actionType]
-
- context.optimistic = true
- const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(actionType, args, context)
-
- return await models.$transaction(async tx => {
+async function performOptimisticAction (invoiceData, actionType, paidAction, args, context) {
+ const { models, actionId, me } = context
+ const performInvoicedAction = async tx => {
context.tx = tx
-
- const invoice = await createDbInvoice(actionType, args, context, invoiceArgs)
-
+ context.optimistic = true
+ await checkUser(me?.id ?? USER_ID.anon, context)
+
+ const invoiceEntry = await createDbInvoice(invoiceData, {
+ actionType,
+ actionId,
+ optimistic: true,
+ args
+ }, context)
return {
- invoice,
- result: await action.perform?.({ invoiceId: invoice.id, ...args }, context),
- paymentMethod: 'OPTIMISTIC'
+ invoice: invoiceEntry,
+ result: await performAction(invoiceEntry, paidAction, args, context),
+ paymentMethod: 'OPTIMISTIC',
+ retriableWithWallet: invoiceData.retriable
}
- }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
-}
-
-async function performPessimisticAction (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(actionType, args, context)
- return {
- invoice: await createDbInvoice(actionType, args, context, invoiceArgs),
- paymentMethod: 'PESSIMISTIC'
}
+ if (context.tx) return await performInvoicedAction(context.tx)
+ return await models.$transaction(performInvoicedAction, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
}
-async function performP2PAction (actionType, args, context) {
- const { me } = context
- const invoiceArgs = await createWrappedInvoice(actionType, args, context)
- context.invoiceArgs = invoiceArgs
-
- return me
- ? await performOptimisticAction(actionType, args, context)
- : await performPessimisticAction(actionType, args, context)
-}
-
-export async function retryPaidAction (actionType, args, context) {
- const { models, me } = context
- const { invoice: failedInvoice } = args
-
- console.log('retryPaidAction', actionType, args)
-
- const action = paidActions[actionType]
- if (!action) {
- throw new Error(`retryPaidAction - invalid action type ${actionType}`)
- }
-
- if (!me) {
- throw new Error(`retryPaidAction - must be logged in ${actionType}`)
- }
-
- if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)) {
- throw new Error(`retryPaidAction - action does not support optimism ${actionType}`)
- }
-
- if (!action.retry) {
- throw new Error(`retryPaidAction - action does not support retrying ${actionType}`)
- }
-
- if (!failedInvoice) {
- throw new Error(`retryPaidAction - missing invoice ${actionType}`)
- }
-
- context.optimistic = true
- context.me = await models.user.findUnique({ where: { id: me.id } })
-
- const { msatsRequested, actionId } = failedInvoice
- context.cost = BigInt(msatsRequested)
- context.actionId = actionId
- const invoiceArgs = await createSNInvoice(actionType, args, context)
-
- return await models.$transaction(async tx => {
+async function performPessimisticAction (invoiceData, actionType, paidAction, args, context) {
+ const { models, actionId, me } = context
+ const performInvoicedAction = async tx => {
context.tx = tx
-
- // 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, invoiceArgs)
-
+ context.optimistic = false
+ await checkUser(me?.id ?? USER_ID.anon, context)
+
+ const invoiceEntry = await createDbInvoice(invoiceData, {
+ actionType,
+ actionId,
+ optimistic: false,
+ args
+ }, context)
return {
- result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
- invoice,
- paymentMethod: 'OPTIMISTIC'
+ invoice: invoiceEntry,
+ paymentMethod: 'PESSIMISTIC',
+ retriableWithWallet: invoiceData.retriable
}
- }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
+ }
+ if (context.tx) return await performInvoicedAction(context.tx)
+ return await models.$transaction(performInvoicedAction, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
}
-const INVOICE_EXPIRE_SECS = 600
-const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
+export async function retryPaidAction ({ invoiceId, forceInternal, resetAttempts, prioritizeInternal }, context) {
+ const { models, lnd, boss, me } = context
-export async function assertBelowMaxPendingInvoices (context) {
- const { models, me } = context
- const pendingInvoices = await models.invoice.count({
- where: {
- userId: me?.id ?? USER_ID.anon,
- actionState: {
- notIn: PAID_ACTION_TERMINAL_STATES
- }
+ return await models.$transaction(async tx => {
+ context.tx = tx
+ const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id ?? USER_ID.anon } })
+ if (!failedInvoice) {
+ throw new Error('Invoice not found')
}
- })
- if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) {
- throw new Error('You have too many pending paid actions, cancel some or wait for them to expire')
- }
-}
+ if (
+ failedInvoice.actionState !== 'FAILED' &&
+ failedInvoice.actionState !== 'PENDING' &&
+ failedInvoice.actionState !== 'PENDING_HELD'
+ ) {
+ throw new Error(`Invoice is not in a retriable state: ${failedInvoice.actionState}`)
+ }
-export class NonInvoiceablePeerError extends Error {
- constructor () {
- super('non invoiceable peer')
- this.name = 'NonInvoiceablePeerError'
- }
-}
+ // cancel the previous invoice
+ await finalizeHodlInvoice({ data: { hash: failedInvoice.hash }, lnd, models, boss })
-export async function createWrappedInvoice (actionType, args, context) {
- // if the action has an invoiceable peer, we'll create a peer invoice
- // wrap it, and return the wrapped invoice
- const { cost, models, lnd, me } = context
- const userId = await paidActions[actionType]?.invoiceablePeer?.(args, context)
- if (!userId) {
- throw new NonInvoiceablePeerError()
- }
+ const actionType = failedInvoice.actionType
- await assertBelowMaxPendingInvoices(context)
+ console.log('retryPaidAction', actionType, failedInvoice)
- const description = await paidActions[actionType].describe(args, context)
- const { invoice: bolt11, wallet } = await createUserInvoice(userId, {
- // this is the amount the stacker will receive, the other 3/10ths is the sybil fee
- msats: cost * BigInt(7) / BigInt(10),
- description,
- expiry: INVOICE_EXPIRE_SECS
- }, { models })
+ const paidAction = paidActions[actionType]
+ if (!paidAction) {
+ throw new Error(`retryPaidAction - invalid action type ${actionType}`)
+ }
- const { invoice: wrappedInvoice, maxFee } = await wrapInvoice(
- bolt11, { msats: cost, description }, { me, lnd })
+ if (!failedInvoice) {
+ throw new Error(`retryPaidAction - missing invoice ${actionType}`)
+ }
- return {
- bolt11,
- wrappedBolt11: wrappedInvoice.request,
- wallet,
- maxFee
- }
-}
+ const { msatsRequested, actionId, actionArgs } = failedInvoice
+ context.cost = msatsRequested
+ context.actionId = actionId
+ context.retryForInvoice = failedInvoice
+ context.forceInternal = forceInternal
+ context.actionAttempt = failedInvoice.actionAttempt + 1 // next attempt
+ context.prioritizeInternal = prioritizeInternal
-// 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]
- const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice
+ if (resetAttempts) {
+ await tx.invoice.update({
+ where: {
+ id: failedInvoice.id
+ },
+ data: {
+ actionAttempt: 0
+ }
+ })
+ }
- if (cost < 1000n) {
- // sanity check
- throw new Error('The cost of the action must be at least 1 sat')
- }
+ 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'
+ }
+ })
+ }
- const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS })
- const invoice = await createLNDInvoice({
- description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context),
- lnd,
- mtokens: String(cost),
- expires_at: expiresAt
- })
- return { bolt11: invoice.request, preimage: invoice.secret }
+ return await performPaidAction(actionType, actionArgs, context)
+ }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
}
-async function createDbInvoice (actionType, args, context,
- { bolt11, wrappedBolt11, preimage, wallet, maxFee }) {
- const { me, models, tx, cost, optimistic, actionId } = context
+export async function createDbInvoice (
+ invoiceData,
+ actionData,
+ { me, models, tx }
+) {
+ const { invoice: servedBolt11, wallet, maxFee, preimage, innerInvoice: bolt11, isWrapped, isHodl } = invoiceData
+ const { actionId, actionType, optimistic: isOptimisticAction, args } = actionData
+
const db = tx ?? models
- if (cost < 1000n) {
- // sanity check
- throw new Error('The cost of the action must be at least 1 sat')
- }
+ const servedInvoice = await parsePaymentRequest({ request: servedBolt11 })
- const servedBolt11 = wrappedBolt11 ?? bolt11
- const servedInvoice = parsePaymentRequest({ request: servedBolt11 })
const expiresAt = new Date(servedInvoice.expires_at)
- const invoiceData = {
+ // sanity checks
+ if (servedInvoice.mtokens < 1000n) {
+ throw new Error('The amount must be at least 1 sat')
+ }
+ if (!isWrapped) {
+ if (isHodl && isOptimisticAction) throw new Error('optimistic actions should not use hodl invoices')
+ if (!isOptimisticAction && !isHodl) throw new Error('pessimistic actions should use hodl invoices')
+ } else {
+ if (isWrapped && !isHodl) throw new Error('wrapped invoices should always be hodl')
+ }
+
+ const invoiceEntryData = {
hash: servedInvoice.id,
msatsRequested: BigInt(servedInvoice.mtokens),
preimage,
bolt11: servedBolt11,
userId: me?.id ?? USER_ID.anon,
actionType,
- actionState: wrappedBolt11 ? 'PENDING_HELD' : optimistic ? 'PENDING' : 'PENDING_HELD',
- actionOptimistic: optimistic,
+ actionState: (isWrapped ? 'PENDING_HELD' : isOptimisticAction ? 'PENDING' : 'PENDING_HELD'),
+ actionOptimistic: isOptimisticAction,
actionArgs: args,
+ actionAttempt: invoiceData.attempt,
expiresAt,
- actionId
+ actionId,
+ desc: invoiceData.description ?? servedInvoice.description
}
- let invoice
- if (wrappedBolt11) {
- invoice = (await db.invoiceForward.create({
+ let invoiceEntry
+ if (isWrapped) {
+ const invoiceForward = await db.invoiceForward.create({
include: { invoice: true },
data: {
bolt11,
- maxFeeMsats: maxFee,
+ maxFeeMsats: toPositiveNumber(maxFee),
invoice: {
- create: invoiceData
+ create: invoiceEntryData
},
wallet: {
connect: {
- id: wallet.id
+ id: wallet?.id
}
}
}
- })).invoice
+ })
+ invoiceEntry = invoiceForward.invoice
} else {
- invoice = await db.invoice.create({ data: invoiceData })
+ invoiceEntry = await db.invoice.create({ data: invoiceEntryData })
}
// insert a job to check the invoice after it's set to expire
await db.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein, priority)
VALUES ('checkInvoice',
- jsonb_build_object('hash', ${invoice.hash}::TEXT), 21, true,
+ jsonb_build_object('hash', ${invoiceEntry.hash}::TEXT), 21, true,
${expiresAt}::TIMESTAMP WITH TIME ZONE,
${expiresAt}::TIMESTAMP WITH TIME ZONE - now() + interval '10m', 100)`
// the HMAC is only returned during invoice creation
// this makes sure that only the person who created this invoice
// has access to the HMAC
- invoice.hmac = createHmac(invoice.hash)
+ invoiceEntry.hmac = createHmac(invoiceEntry.hash)
+
+ return invoiceEntry
+}
- return invoice
+async function createSNInvoice ({ description, descriptionHash, cost, expiry = INVOICE_EXPIRE_SECS, isHodl }, { lnd }) {
+ // sanity check
+ cost = toPositiveBigInt(cost)
+ if (cost < 1000n) throw new Error('The cost of the action must be at least 1 sat')
+ const expiresAt = datePivot(new Date(), { seconds: expiry ?? INVOICE_EXPIRE_SECS })
+
+ let invoice
+ const mtokens = String(cost)
+ if (!isHodl) {
+ invoice = await lndCreateInvoice({
+ description,
+ description_hash: descriptionHash,
+ lnd,
+ mtokens,
+ expires_at: expiresAt
+ })
+ } else {
+ invoice = await lndCreateHodlInvoice({
+ description,
+ description_hash: descriptionHash,
+ lnd,
+ mtokens,
+ expires_at: expiresAt
+ })
+ }
+ return {
+ invoice: invoice.request,
+ preimage: invoice.secret,
+ wallet: SN_WALLET,
+ isWrapped: false,
+ isHodl,
+ msats: cost,
+ description,
+ retriable: false
+ }
}
diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js
index 8f3a8c2d47..498f3e128d 100644
--- a/api/paidAction/itemCreate.js
+++ b/api/paidAction/itemCreate.js
@@ -149,13 +149,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 d2eb417855..a73e439190 100644
--- a/api/paidAction/pollVote.js
+++ b/api/paidAction/pollVote.js
@@ -42,11 +42,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/transfer.js b/api/paidAction/transfer.js
new file mode 100644
index 0000000000..bea4972cf1
--- /dev/null
+++ b/api/paidAction/transfer.js
@@ -0,0 +1,57 @@
+import { satsToMsats } from '@/lib/format'
+import { notifyDeposit } from '@/lib/webPush'
+import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
+
+export const anonable = true
+
+export const paymentMethods = [
+ PAID_ACTION_PAYMENT_METHODS.P2P
+]
+
+export async function getCost ({ sats }) {
+ return satsToMsats(sats)
+}
+
+export async function invoiceablePeer ({ targetUserId }, { models }) {
+ return targetUserId
+}
+
+export async function perform ({ invoiceId, sats, description, descriptionHash, comment, targetUserId }, { me, tx }) {
+ const invoice = await tx.invoice.update({
+ where: { id: invoiceId },
+ data: {
+ comment
+ }
+ })
+ await notifyDeposit(targetUserId, invoice)
+ return { sats, targetUserId }
+}
+
+export async function describe ({ sats, description, descriptionHash }, context) {
+ return `SN: ${description ?? ''}`
+}
+
+export async function describeHash ({ sats, description, descriptionHash }, context) {
+ return descriptionHash
+}
+
+export async function getSybilFeePercent ({ sats, description, descriptionHash }, context) {
+ return 10n
+}
+
+export async function onPaid ({ invoice }, { tx }) {
+ const isP2P = !!invoice.invoiceForward
+ if (isP2P) return
+ if (!invoice?.msatsReceived) throw new Error('Not paid?')
+ const targetUserId = invoice.actionArgs?.targetUserId
+ if (!targetUserId) throw new Error('No targetUserId')
+
+ await tx.user.update({
+ where: { id: targetUserId },
+ data: {
+ msats: {
+ increment: invoice.msatsReceived
+ }
+ }
+ })
+}
diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js
index 6b8e30364c..3ea87812b3 100644
--- a/api/paidAction/zap.js
+++ b/api/paidAction/zap.js
@@ -61,12 +61,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/item.js b/api/resolvers/item.js
index ae7b6dc586..548e45b711 100644
--- a/api/resolvers/item.js
+++ b/api/resolvers/item.js
@@ -20,8 +20,8 @@ import { uploadIdsFromText } from './upload'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import performPaidAction from '../paidAction'
-import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
-import { verifyHmac } from './wallet'
+import { GqlAuthorizationError, GqlAuthenticationError, GqlInputError } from '@/lib/error'
+import { verifyHmac } from '@/lib/crypto'
function commentsOrderByClause (me, models, sort) {
if (sort === 'recent') {
@@ -1352,7 +1352,10 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
// anybody can edit with valid hash+hmac
let hmacEdit = false
if (old.invoice?.hash && hash && hmac) {
- hmacEdit = old.invoice.hash === hash && verifyHmac(hash, hmac)
+ if (!verifyHmac(hash, hmac)) {
+ throw new GqlAuthorizationError('bad hmac')
+ }
+ hmacEdit = old.invoice.hash === hash
}
// ownership permission check
diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js
index a934f64f00..5c68ed5a10 100644
--- a/api/resolvers/notifications.js
+++ b/api/resolvers/notifications.js
@@ -228,6 +228,23 @@ export default {
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
+ queries.push(
+ `(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", FLOOR("msatsReceived" / 1000) as "earnedSats",
+ 'InvoicePaid' AS type
+ FROM "Invoice"
+ WHERE "Invoice"."userId" = $1
+ AND "confirmedAt" IS NOT NULL
+ AND "actionState" = 'PAID'
+ AND "actionType" = 'TRANSFER'
+ AND created_at < $2
+ AND NOT EXISTS (
+ SELECT 1
+ FROM "InvoiceForward"
+ WHERE "InvoiceForward"."invoiceId" = "Invoice".id
+ )
+ ORDER BY "sortTime" DESC
+ LIMIT ${LIMIT})`
+ )
}
if (meFull.noteWithdrawals) {
diff --git a/api/resolvers/paidAction.js b/api/resolvers/paidAction.js
index 2332790384..410f6a336a 100644
--- a/api/resolvers/paidAction.js
+++ b/api/resolvers/paidAction.js
@@ -19,6 +19,8 @@ function paidActionType (actionType) {
return 'DonatePaidAction'
case 'POLL_VOTE':
return 'PollVotePaidAction'
+ case 'TRANSFER':
+ return 'TransferPaidAction'
default:
throw new Error('Unknown action type')
}
@@ -41,33 +43,17 @@ export default {
type: paidActionType(invoice.actionType),
invoice,
result: invoice.actionResult,
- paymentMethod: invoice.actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC'
+ paymentMethod: invoice.actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC',
+ paid: invoice.actionState === 'PAID'
}
}
},
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, resetAttempts }, { models, me, lnd }) => {
+ const result = await retryPaidAction({ invoiceId, forceInternal, prioritizeInternal, resetAttempts }, { models, me, lnd })
return {
...result,
- type: paidActionType(invoice.actionType)
+ type: paidActionType(result.invoice.actionType)
}
}
},
diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js
index 188693aadd..0030296c0c 100644
--- a/api/resolvers/wallet.js
+++ b/api/resolvers/wallet.js
@@ -3,7 +3,7 @@ import {
getInvoice as getInvoiceFromLnd, deletePayment, getPayment,
parsePaymentRequest
} from 'ln-service'
-import crypto, { timingSafeEqual } from 'crypto'
+import crypto from 'crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item'
@@ -25,6 +25,8 @@ import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/
import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate'
import { canReceive } from '@/wallets/common'
+import { verifyHmac } from '@/lib/crypto'
+import { addWalletLog } from '@/wallets/server'
function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
@@ -126,7 +128,11 @@ export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
},
include: {
user: true,
- invoiceForward: true
+ invoiceForward: {
+ include: {
+ invoice: true
+ }
+ }
}
})
@@ -146,14 +152,6 @@ export function createHmac (hash) {
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
}
-export function verifyHmac (hash, hmac) {
- const hmac2 = createHmac(hash)
- if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
- throw new GqlAuthorizationError('bad hmac')
- }
- return true
-}
-
const resolvers = {
Query: {
invoice: getInvoice,
@@ -501,8 +499,12 @@ const resolvers = {
createWithdrawl: createWithdrawal,
sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => {
- verifyHmac(hash, hmac)
- await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
+ if (!verifyHmac(hash, hmac)) {
+ throw new GqlAuthorizationError('bad hmac')
+ }
+ if (!hash.startsWith('internal')) { // ignore internal invoices
+ await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
+ }
return await models.invoice.findFirst({ where: { hash } })
},
dropBolt11: async (parent, { id }, { me, models, lnd }) => {
@@ -663,14 +665,6 @@ const resolvers = {
export default injectResolvers(resolvers)
-export const addWalletLog = async ({ wallet, level, message }, { models }) => {
- try {
- await models.walletLog.create({ data: { userId: wallet.userId, wallet: wallet.type, level, message } })
- } catch (err) {
- console.error('error creating wallet log:', err)
- }
-}
-
async function upsertWallet (
{ wallet, testCreateInvoice }, { settings, data, vaultEntries }, { me, models }) {
if (!me) {
diff --git a/api/typeDefs/paidAction.js b/api/typeDefs/paidAction.js
index a38180a396..d6c89c0068 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, resetAttempts: Boolean, prioritizeInternal: Boolean): PaidAction!
}
enum PaymentMethod {
@@ -21,36 +21,42 @@ enum PaymentMethod {
interface PaidAction {
invoice: Invoice
paymentMethod: PaymentMethod!
+ retriableWithWallet: Boolean
}
type ItemPaidAction implements PaidAction {
result: Item
invoice: Invoice
paymentMethod: PaymentMethod!
+ retriableWithWallet: Boolean
}
type ItemActPaidAction implements PaidAction {
result: ItemActResult
invoice: Invoice
paymentMethod: PaymentMethod!
+ retriableWithWallet: Boolean
}
type PollVotePaidAction implements PaidAction {
result: PollVoteResult
invoice: Invoice
paymentMethod: PaymentMethod!
+ retriableWithWallet: Boolean
}
type SubPaidAction implements PaidAction {
result: Sub
invoice: Invoice
paymentMethod: PaymentMethod!
+ retriableWithWallet: Boolean
}
type DonatePaidAction implements PaidAction {
result: DonateResult
invoice: Invoice
paymentMethod: PaymentMethod!
+ retriableWithWallet: Boolean
}
`
diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js
index 4cd011cf26..25b87329ab 100644
--- a/api/typeDefs/wallet.js
+++ b/api/typeDefs/wallet.js
@@ -124,6 +124,11 @@ const typeDefs = `
itemAct: ItemAct
}
+ type InvoiceForward {
+ invoice: Invoice
+ }
+
+
type Withdrawl {
id: ID!
createdAt: Date!
@@ -137,6 +142,7 @@ const typeDefs = `
autoWithdraw: Boolean!
p2p: Boolean!
preimage: String
+ invoiceForward: [InvoiceForward]
}
type Fact {
diff --git a/components/item-act.js b/components/item-act.js
index 36d5a0c777..231aa38e08 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 }, retriableWithWallet: true } }
try {
await abortSignal.pause({ me, amount: sats })
diff --git a/components/item-info.js b/components/item-info.js
index 921d57f5dd..a60acdee60 100644
--- a/components/item-info.js
+++ b/components/item-info.js
@@ -277,7 +277,7 @@ function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
if (disableDualRetry) return
setDisableDualRetry(true)
try {
- const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id) } })
+ const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id), resetAttempts: true } })
if (error) throw error
} catch (error) {
toaster.danger(error.message)
diff --git a/components/notifications.js b/components/notifications.js
index 7fb10dbe6d..83693256e9 100644
--- a/components/notifications.js
+++ b/components/notifications.js
@@ -326,10 +326,10 @@ function NostrZap ({ n }) {
)
}
-function InvoicePaid ({ n }) {
+function getPayerSig (lud18Data) {
let payerSig
- if (n.invoice.lud18Data) {
- const { name, identifier, email, pubkey } = n.invoice.lud18Data
+ if (lud18Data) {
+ const { name, identifier, email, pubkey } = lud18Data
const id = identifier || email || pubkey
payerSig = '- '
if (name) {
@@ -339,6 +339,11 @@ function InvoicePaid ({ n }) {
if (id) payerSig += id
}
+ return payerSig
+}
+
+function InvoicePaid ({ n }) {
+ const payerSig = getPayerSig(n.invoice.lud18Data)
return (
{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account
@@ -359,6 +364,7 @@ function useActRetry ({ invoice }) {
: {}
return useAct({
query: RETRY_PAID_ACTION,
+ alwaysShowQROnFailure: true,
onPayError: (e, cache, { data }) => {
paidActionCacheMods?.onPayError?.(e, cache, { data })
bountyCacheMods?.onPayError?.(e, cache, { data })
@@ -464,7 +470,7 @@ function Invoicification ({ n: { invoice, sortTime } }) {
if (disableRetry) return
setDisableRetry(true)
try {
- const { error } = await retry({ variables: { invoiceId: parseInt(invoiceId) } })
+ const { error } = await retry({ variables: { invoiceId: parseInt(invoiceId), resetAttempts: true } })
if (error) throw error
} catch (error) {
toaster.danger(error?.message || error?.toString?.())
@@ -484,6 +490,7 @@ function Invoicification ({ n: { invoice, sortTime } }) {
}
function WithdrawlPaid ({ n }) {
+ const payerSig = getPayerSig(n.withdrawl.invoiceForward?.[0]?.invoice?.lud18Data)
return (
{numWithUnits(n.earnedSats + n.withdrawl.satsFeePaid, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })}
@@ -491,6 +498,11 @@ function WithdrawlPaid ({ n }) {
{timeSince(new Date(n.sortTime))}
{(n.withdrawl.p2p && p2p) ||
(n.withdrawl.autoWithdraw && autowithdraw)}
+ {n.withdrawl.invoiceForward?.[0]?.invoice?.comment &&
+
+ {n.withdrawl.invoiceForward[0].invoice.comment}
+ {payerSig}
+ }
)
}
diff --git a/components/pay-bounty.js b/components/pay-bounty.js
index 35c5ebe602..db7f63d1cf 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 }, retriableWithWallet: true } },
...payBountyCacheMods
})
diff --git a/components/payment.js b/components/payment.js
index 175ca2b3bf..7e4dbba20e 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 dc694f801c..35539239ae 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 }, retriableWithWallet: true } }
try {
const { error } = await pollVote({
variables,
@@ -44,7 +44,7 @@ export default function Poll ({ item }) {
}
const RetryVote = () => {
- const retryVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: item.id })
+ const retryVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: item.id, alwaysShowQROnFailure: true })
const waitForQrPayment = useQrPayment()
if (item.poll.meInvoiceActionState === 'PENDING') {
@@ -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 23757ba33d..a4220ef71f 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-item-submit.js b/components/use-item-submit.js
index f460c51aa1..9afd1f4e8e 100644
--- a/components/use-item-submit.js
+++ b/components/use-item-submit.js
@@ -101,6 +101,7 @@ export function useRetryCreateItem ({ id }) {
const [retryPaidAction] = usePaidMutation(
RETRY_PAID_ACTION,
{
+ alwaysShowQROnFailure: true,
...paidActionCacheMods,
update: (cache, { data }) => {
const response = Object.values(data)[0]
diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js
index 765508b5f1..bc6ef1479f 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,130 @@ 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 usableWallets = walletDefs
+ .map(w => useWallet(w.def.name))
+ .filter(w => !w.def.isAvailable || w.def.isAvailable())
+ .filter(w => w.config?.enabled && canSend(w))
+
+ const waitForActionPayment = useCallback(async (invoice, { wallet, alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }, originalResponse, action) => {
+ const walletErrors = []
+ let response = originalResponse
+ let invoiceUsed = false
+
+ // ensures every invoice is used only once
+ const refreshInvoice = async () => {
+ if (invoiceUsed) {
+ const retry = await retryPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
+ response = retry.data?.retryPaidAction
+ invoice = response?.invoice
+ invoiceUsed = true
+ } else invoiceUsed = true
+ }
+
+ 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)
+ }
+ }
+
+ // 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 }
+ }
+
+ // we try every sender<->receiver combination
+ while (true) {
+ await refreshInvoice()
+ if (!invoice) return { invoice, response }
+
+ for (const wallet of usableWallets) {
+ try {
+ await waitForWalletPayment(invoice, waitFor, wallet)
+ console.log('paid with wallet', wallet.def.name)
+ return { invoice, response }
+ } catch (err) {
+ walletErrors.push(err)
+ console.log('could not pay with wallet', wallet.def.name, 'will try another one ...')
+ }
+ }
- const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }) => {
- let walletError
- const start = Date.now()
+ // every wallet failed, but we can still retry, so let's ask for a better invoice
+ console.log(response)
+ if (response.retriableWithWallet) {
+ console.log('could not pay with any wallet, will retry with a new invoice...')
+ } else {
+ console.log('could not pay with any wallet, and the invoice is not retriable, will fallback to another method')
+ break
+ }
+ }
+
+ // we try an internal payment
+ console.log(response)
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...')
+ 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
+ console.log(response)
+ if ((alwaysShowQROnFailure || usableWallets.length === 0)) {
+ console.log('show qr code for manual payment')
+ await refreshInvoice()
+ // if we get here, we've exhausted all retries and the last invoice is still pending, so we will try to pay it with a qr code
+ 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, usableWallets])
const innerMutate = useCallback(async ({
- onCompleted: innerOnCompleted, ...innerOptions
+ onCompleted: innerOnCompleted, resetAttempts, ...innerOptions
} = {}) => {
innerOptions.optimisticResponse = addOptimisticResponseExtras(innerOptions.optimisticResponse)
- let { data, ...rest } = await mutate(innerOptions)
+ let { data, ...rest } = await mutate({ ...innerOptions, resetAttempts })
// use the most inner callbacks/options if they exist
const {
@@ -75,62 +168,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 +222,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', retriableWithWallet: true, ...optimisticResponse[key] }
return optimisticResponse
}
diff --git a/fragments/notifications.js b/fragments/notifications.js
index ce588ccc14..a92a5ae966 100644
--- a/fragments/notifications.js
+++ b/fragments/notifications.js
@@ -186,8 +186,16 @@ export const NOTIFICATIONS = gql`
withdrawl {
autoWithdraw
p2p
- satsFeePaid
- }
+ satsFeePaid
+ invoiceForward {
+ invoice {
+ id
+ nostr
+ comment
+ lud18Data
+ }
+ }
+ }
}
... on Reminder {
id
diff --git a/fragments/paidAction.js b/fragments/paidAction.js
index c47fa7005c..335ecbf3e0 100644
--- a/fragments/paidAction.js
+++ b/fragments/paidAction.js
@@ -13,6 +13,7 @@ export const PAID_ACTION = gql`
...InvoiceFields
}
paymentMethod
+ retriableWithWallet
}`
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, $resetAttempts: Boolean, $prioritizeInternal: Boolean) {
+ retryPaidAction(invoiceId: $invoiceId, forceInternal: $forceInternal, resetAttempts: $resetAttempts, 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/lib/apollo.js b/lib/apollo.js
index d72a035d5d..af0e0c1ec3 100644
--- a/lib/apollo.js
+++ b/lib/apollo.js
@@ -49,7 +49,8 @@ function getClient (uri) {
'ItemActPaidAction',
'PollVotePaidAction',
'SubPaidAction',
- 'DonatePaidAction'
+ 'DonatePaidAction',
+ 'TransferPaidAction'
],
Notification: [
'Reply',
diff --git a/lib/constants.js b/lib/constants.js
index 1dbabdb9ed..2ccce20126 100644
--- a/lib/constants.js
+++ b/lib/constants.js
@@ -8,7 +8,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
@@ -189,3 +190,11 @@ export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTER
export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL)
export const ZAP_UNDO_DELAY_MS = 5_000
+
+// the fee for the zap sybil service
+export const ZAP_SYBIL_FEE_PERCENT = 30n
+export const MAX_FEE_ESTIMATE_PERMILE = 25n // the maximum fee relative to outgoing we'll allow for the fee estimate
+
+export const INVOICE_EXPIRE_SECS = 600
+export const MAX_PENDING_INVOICES_PER_WALLET = 25
+export const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
diff --git a/lib/crypto.js b/lib/crypto.js
index d1812cbb90..97a8c6e2de 100644
--- a/lib/crypto.js
+++ b/lib/crypto.js
@@ -1,4 +1,4 @@
-import { createHash } from 'node:crypto'
+import crypto, { createHash, timingSafeEqual } from 'node:crypto'
export function hashEmail ({
email,
@@ -7,3 +7,13 @@ export function hashEmail ({
const saltedEmail = `${email.toLowerCase()}${salt}`
return createHash('sha256').update(saltedEmail).digest('hex')
}
+
+export function createHmac (hash) {
+ const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex')
+ return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
+}
+
+export function verifyHmac (hash, hmac) {
+ const hmac2 = createHmac(hash)
+ return timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))
+}
diff --git a/lib/proxy.js b/lib/proxy.js
index ea8c0a87d1..ff08f8fe67 100644
--- a/lib/proxy.js
+++ b/lib/proxy.js
@@ -137,9 +137,9 @@ export function getAgent ({ hostname, cert }) {
return agent
}
- if (process.env.NODE_ENV === 'development' && !cert) {
- return new http.Agent()
- }
+ // if (process.env.NODE_ENV === 'development' && !cert) {
+ // return new http.Agent()
+ // }
// we only support HTTPS over clearnet
return new https.Agent(httpsAgentOptions)
diff --git a/lib/validate.js b/lib/validate.js
index c485ac4081..23141d78f1 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -493,27 +493,6 @@ export const lud18PayerDataSchema = (k1) => object({
email: string().email('bad email address'),
identifier: string()
})
-
-// check if something is _really_ a number.
-// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity]
-export const isNumber = x => typeof x === 'number' && !Number.isNaN(x)
-
-export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => {
- if (typeof x === 'undefined') {
- throw new Error('value is required')
- }
- const n = Number(x)
- if (isNumber(n)) {
- if (x < min || x > max) {
- throw new Error(`value ${x} must be between ${min} and ${max}`)
- }
- return n
- }
- throw new Error(`value ${x} is not a number`)
-}
-
-export const toPositiveNumber = (x) => toNumber(x, 0)
-
export const deviceSyncSchema = object().shape({
passphrase: string().required('required')
.test(async (value, context) => {
@@ -533,3 +512,83 @@ export const deviceSyncSchema = object().shape({
return true
})
})
+
+// check if something is _really_ a number.
+// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity]
+export const isNumber = x => typeof x === 'number' && !Number.isNaN(x)
+
+/**
+ *
+ * @param {any | bigint} x
+ * @param {number} min
+ * @param {number} max
+ * @returns {number}
+ */
+export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => {
+ if (typeof x === 'undefined') {
+ throw new Error('value is required')
+ }
+ if (typeof x === 'bigint') {
+ if (x < BigInt(min) || x > BigInt(max)) {
+ throw new Error(`value ${x} must be between ${min} and ${max}`)
+ }
+ return Number(x)
+ } else {
+ const n = Number(x)
+ if (isNumber(n)) {
+ if (x < min || x > max) {
+ throw new Error(`value ${x} must be between ${min} and ${max}`)
+ }
+ return n
+ }
+ }
+ throw new Error(`value ${x} is not a number`)
+}
+
+/**
+ * @param {number | bigint} x
+ * @returns {number}
+ */
+export const toPositiveNumber = (x) => {
+ return toNumber(x, 0)
+}
+
+/**
+ * @param {any} x
+ * @param {bigint | number} [min]
+ * @param {bigint | number} [max]
+ * @returns {bigint}
+ */
+export const toBigInt = (x, min, max) => {
+ if (typeof x === 'undefined') throw new Error('value is required')
+ min = min !== undefined ? BigInt(min) : undefined
+ max = max !== undefined ? BigInt(max) : undefined
+
+ const n = BigInt(x)
+ if (min !== undefined && n < min) {
+ throw new Error(`value ${x} must be at least ${min}`)
+ }
+
+ if (max !== undefined && n > max) {
+ throw new Error(`value ${x} must be at most ${max}`)
+ }
+
+ return n
+}
+
+/**
+ * @param {number|bigint} x
+ * @returns {bigint}
+ */
+export const toPositiveBigInt = (x) => {
+ return toBigInt(x, 0)
+}
+
+/**
+ * @param {number|bigint} x
+ * @returns {number|bigint}
+ */
+export const toPositive = (x) => {
+ if (typeof x === 'bigint') return toPositiveBigInt(x)
+ return toPositiveNumber(x)
+}
diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js
index 6cfc9b448b..9648167324 100644
--- a/pages/api/lnurlp/[username]/pay.js
+++ b/pages/api/lnurlp/[username]/pay.js
@@ -1,14 +1,13 @@
import models from '@/api/models'
import lnd from '@/api/lnd'
-import { createInvoice } from 'ln-service'
import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '@/lib/lnurl'
-import serialize from '@/api/resolvers/serial'
import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto'
-import { datePivot } from '@/lib/time'
-import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants'
-import { validateSchema, lud18PayerDataSchema } from '@/lib/validate'
+import { LNURLP_COMMENT_MAX_LENGTH } from '@/lib/constants'
+import { validateSchema, lud18PayerDataSchema, toPositiveNumber } from '@/lib/validate'
import assertGofacYourself from '@/api/resolvers/ofac'
+import performPaidAction from '@/api/paidAction'
+import { msatsToSats } from '@/lib/format'
export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => {
const user = await models.user.findUnique({ where: { name: username } })
@@ -30,14 +29,15 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
// If there is an amount tag, it MUST be equal to the amount query parameter
const eventAmount = note.tags?.find(t => t[0] === 'amount')?.[1]
if (schnorr.verify(note.sig, note.id, note.pubkey) && hasPTag && hasETag && (!eventAmount || Number(eventAmount) === Number(amount))) {
- description = user.hideInvoiceDesc ? undefined : 'zap'
+ description = 'zap'
descriptionHash = createHash('sha256').update(noteStr).digest('hex')
} else {
res.status(400).json({ status: 'ERROR', reason: 'invalid NIP-57 note' })
return
}
} else {
- description = user.hideInvoiceDesc ? undefined : `Funding @${username} on stacker.news`
+ description = `Funding @${username} on stacker.news`
+ description += comment ? `: ${comment}` : '.'
descriptionHash = lnurlPayDescriptionHashForUser(username)
}
@@ -45,7 +45,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' })
}
- if (comment && comment.length > LNURLP_COMMENT_MAX_LENGTH) {
+ if ((comment && comment.length > LNURLP_COMMENT_MAX_LENGTH) || (description && description.length > LNURLP_COMMENT_MAX_LENGTH)) {
return res.status(400).json({ status: 'ERROR', reason: `comment cannot exceed ${LNURLP_COMMENT_MAX_LENGTH} characters in length` })
}
@@ -71,27 +71,20 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
}
// generate invoice
- const expiresAt = datePivot(new Date(), { minutes: 5 })
- const invoice = await createInvoice({
+ const actionResponse = await performPaidAction('TRANSFER', {
+ sats: toPositiveNumber(msatsToSats(BigInt(amount))),
description,
- description_hash: descriptionHash,
- lnd,
- mtokens: amount,
- expires_at: expiresAt
- })
+ descriptionHash,
+ comment: comment || '',
+ targetUserId: user.id
+ }, { models, lnd, me: user, disableFeeCredit: true })
- await serialize(
- models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.secret}::TEXT, ${invoice.request},
- ${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description},
- ${comment || null}, ${parsedPayerData || null}::JSONB, ${INV_PENDING_LIMIT}::INTEGER,
- ${USER_IDS_BALANCE_NO_LIMIT.includes(Number(user.id)) ? 0 : BALANCE_LIMIT_MSATS})`,
- { models }
- )
+ if (!actionResponse.invoice?.bolt11) throw new Error('could not generate invoice')
return res.status(200).json({
- pr: invoice.request,
+ pr: actionResponse.invoice.bolt11,
routes: [],
- verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${invoice.id}`
+ verify: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/verify/${actionResponse.invoice.hash}`
})
} catch (error) {
console.log(error)
diff --git a/prisma/migrations/20241029160827_transfer_paid_action/migration.sql b/prisma/migrations/20241029160827_transfer_paid_action/migration.sql
new file mode 100644
index 0000000000..a10c9d17e7
--- /dev/null
+++ b/prisma/migrations/20241029160827_transfer_paid_action/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "InvoiceActionType" ADD VALUE 'TRANSFER';
\ No newline at end of file
diff --git a/prisma/migrations/20241106101356_retriable_invoiced_paidactions/migration.sql b/prisma/migrations/20241106101356_retriable_invoiced_paidactions/migration.sql
new file mode 100644
index 0000000000..c861f00ebc
--- /dev/null
+++ b/prisma/migrations/20241106101356_retriable_invoiced_paidactions/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Invoice" ADD COLUMN "actionAttempt" INTEGER NOT NULL DEFAULT 0;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1279d995bb..4a6c132b98 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -864,6 +864,7 @@ enum InvoiceActionType {
TERRITORY_UPDATE
TERRITORY_BILLING
TERRITORY_UNARCHIVE
+ TRANSFER
}
enum InvoiceActionState {
@@ -922,6 +923,8 @@ model Invoice {
actionArgs Json? @db.JsonB
actionError String?
actionResult Json? @db.JsonB
+ actionAttempt Int @default(0)
+
ItemAct ItemAct[]
Item Item[]
Upload Upload[]
diff --git a/wallets/server.js b/wallets/server.js
index 91a68a1ba8..8948703de0 100644
--- a/wallets/server.js
+++ b/wallets/server.js
@@ -11,113 +11,169 @@ import * as blink from 'wallets/blink/server'
import * as lnc from 'wallets/lnc'
import * as webln from 'wallets/webln'
-import { addWalletLog } from '@/api/resolvers/wallet'
-import walletDefs from 'wallets/server'
import { parsePaymentRequest } from 'ln-service'
-import { toPositiveNumber } from '@/lib/validate'
-import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
+import { PAID_ACTION_TERMINAL_STATES, INVOICE_EXPIRE_SECS, MAX_PENDING_INVOICES_PER_WALLET, ZAP_SYBIL_FEE_PERCENT } from '@/lib/constants'
+import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate'
import { withTimeout } from '@/lib/time'
import { canReceive } from './common'
+import wrapInvoice from 'wallets/wrap'
+import assert from 'assert'
+
+const walletDefs = [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]
+export default walletDefs
+
+export const SN_WALLET = null
+
+export async function createHodlInvoice (
+ userId,
+ {
+ msats,
+ description,
+ descriptionHash,
+ expiry = INVOICE_EXPIRE_SECS,
+ sybilFeePercent,
+ direct = false,
+ attempt = 0
+ },
+ { models, lnd, useWallet }
+) {
+ return createInvoice(userId, {
+ msats,
+ description,
+ descriptionHash,
+ expiry,
+ sybilFeePercent,
+ hodl: true,
+ direct
+ }, { models, lnd, useWallet })
+}
-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 }) {
- // get the wallets in order of priority
- 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 createInvoice (
+ receiverUserId,
+ {
+ msats,
+ description,
+ descriptionHash,
+ expiry = INVOICE_EXPIRE_SECS,
+ sybilFeePercent,
+ hodl = false,
+ direct = false,
+ attempt = 0
+ },
+ { models, lnd, useWallet }
+) {
+ if (expiry < INVOICE_EXPIRE_SECS) console.warn('The invoice might be expiring too early', expiry, 'default:', INVOICE_EXPIRE_SECS)
+ msats = toPositiveBigInt(msats)
+
+ if (!receiverUserId) {
+ throw new Error('no receiverUserId provided')
+ }
- msats = toPositiveNumber(msats)
+ // we are creating an invoice paid to an user
+ const receiverUser = await models.user.findUnique({ where: { id: receiverUserId } })
+ if (!receiverUser) throw new Error('user not found')
+
+ // respect receiver's privacy settings
+ if (receiverUser.hideInvoiceDesc) {
+ description = undefined
+ descriptionHash = undefined
+ }
+
+ const wallets =
+ useWallet
+ ? [useWallet] // if a wallet is specified, use it
+ : await models.wallet.findMany({
+ where: { userId: receiverUserId, enabled: true },
+ orderBy: [
+ { priority: 'asc' },
+ // use id as tie breaker (older wallet first)
+ { id: 'asc' }
+ ]
+ })
+
+ let i = attempt
+ if (i >= wallets.length) i = 0
+ for (; i < wallets.length; i++) {
+ const wallet = wallets[i]
- for (const wallet of wallets) {
const w = walletDefs.find(w => w.walletType === wallet.type)
try {
+ if (!w) throw new Error(`wallet type ${wallet.type} not found`)
if (!canReceive({ def: w, config: wallet.wallet })) {
continue
}
-
const { walletType, walletField, createInvoice } = w
const walletFull = await models.wallet.findFirst({
where: {
- userId,
+ userId: receiverUserId,
type: walletType
},
include: {
[walletField]: true
}
})
-
- if (!walletFull || !walletFull[walletField]) {
- throw new Error(`no ${walletType} wallet found`)
- }
-
- // check for pending withdrawals
- const pendingWithdrawals = await models.withdrawl.count({
- where: {
- walletId: walletFull.id,
- status: null
- }
- })
-
- // and pending forwards
- const pendingForwards = await models.invoiceForward.count({
- where: {
- walletId: walletFull.id,
- invoice: {
- actionState: {
- notIn: PAID_ACTION_TERMINAL_STATES
- }
- }
- }
- })
-
- console.log('pending invoices', pendingWithdrawals + pendingForwards)
- if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) {
- throw new Error('wallet has too many pending invoices')
- }
- console.log('use wallet', walletType)
-
- const invoice = await withTimeout(
- createInvoice({
+ await checkWallet(models, walletFull, walletField, walletType)
+
+ console.log('use wallet', wallet.type)
+ if (hodl || !direct) {
+ // we always wrap p2p invoices unless it is a direct non-hodl invoice
+ const wrappedMsats = msats
+ const innerMsats = BigInt(msats) * (100n - BigInt(sybilFeePercent ?? ZAP_SYBIL_FEE_PERCENT)) / 100n
+ assert(innerMsats <= wrappedMsats, 'innerMsats must be less than or equal to wrappedMsats')
+
+ const invoice = await withTimeout(
+ createInvoice({
+ msats: toPositiveNumber(innerMsats),
+ description,
+ descriptionHash,
+ expiry
+ }, walletFull[walletField]), 10_000)
+ await checkInvoice(models, wallet, invoice, innerMsats)
+ if (!lnd) throw new Error('no lnd provided for wrapping')
+ const { invoice: wrappedInvoice, maxFee } = await wrapInvoice(invoice,
+ {
+ msats: wrappedMsats,
+ description: !descriptionHash ? description : undefined,
+ descriptionHash
+ }, { lnd }, sybilFeePercent)
+ await checkInvoice(models, wallet, wrappedInvoice.request, wrappedMsats)
+ return {
+ innerInvoice: invoice,
+ invoice: wrappedInvoice.request,
+ wallet,
+ maxFee,
+ isWrapped: true,
+ isHodl: true,
msats,
- description: wallet.user.hideInvoiceDesc ? undefined : description,
- descriptionHash,
- expiry
- }, walletFull[walletField]), 10_000)
-
- const bolt11 = await parsePaymentRequest({ request: invoice })
- if (BigInt(bolt11.mtokens) !== BigInt(msats)) {
- if (BigInt(bolt11.mtokens) > BigInt(msats)) {
- throw new Error(`invoice is for an amount greater than requested ${bolt11.mtokens} > ${msats}`)
- }
- if (BigInt(bolt11.mtokens) === 0n) {
- throw new Error('invoice is for 0 msats')
+ description,
+ retriable: i < wallets.length - 1,
+ attempt
}
- if (BigInt(msats) - BigInt(bolt11.mtokens) >= 1000n) {
- throw new Error(`invoice has a different satoshi amount ${bolt11.mtokens} !== ${msats}`)
- }
-
- await addWalletLog({
+ } else {
+ // return a regular invoice straight from the attached wallet
+ const invoice = await withTimeout(
+ createInvoice({
+ msats,
+ description,
+ descriptionHash,
+ expiry
+ }, walletFull[walletField]), 10_000)
+ await checkInvoice(models, wallet, invoice, msats)
+
+ return {
+ invoice,
wallet,
- level: 'INFO',
- message: `wallet does not support msats so we floored ${msats} msats to nearest sat ${BigInt(bolt11.mtokens)} msats`
- }, { models })
+ isWrapped: false,
+ isHodl: false,
+ msats,
+ description,
+ retriable: i < wallets.length - 1,
+ attempt
+ }
}
-
- return { invoice, wallet }
} catch (error) {
- console.error(error)
+ console.error('error checking wallet:', error)
await addWalletLog({
wallet,
level: 'ERROR',
@@ -125,6 +181,63 @@ export async function createInvoice (userId, { msats, description, descriptionHa
}, { models })
}
}
-
throw new Error('no wallet available')
}
+
+export const addWalletLog = async ({ wallet, level, message }, { models }) => {
+ try {
+ await models.walletLog.create({ data: { userId: wallet.userId, wallet: wallet.type, level, message } })
+ } catch (err) {
+ console.error('error creating wallet log:', err)
+ }
+}
+
+async function checkInvoice (models, wallet, invoice, msats) {
+ const bolt11 = await parsePaymentRequest({ request: invoice })
+ if (BigInt(bolt11.mtokens) !== msats) {
+ if (BigInt(bolt11.mtokens) > msats) {
+ throw new Error(`invoice is for an amount greater than requested ${bolt11.mtokens} > ${msats}`)
+ }
+ if (BigInt(bolt11.mtokens) === 0n) {
+ throw new Error('invoice is for 0 msats')
+ }
+ if (msats - BigInt(bolt11.mtokens) >= 1000n) {
+ throw new Error(`invoice has a different satoshi amount ${bolt11.mtokens} !== ${msats}`)
+ }
+
+ await addWalletLog({
+ wallet,
+ level: 'INFO',
+ message: `wallet does not support msats so we floored ${msats} msats to nearest sat ${BigInt(bolt11.mtokens)} msats`
+ }, { models })
+ }
+}
+
+async function checkWallet (models, walletFull, walletField, walletType) {
+ if (!walletFull || !walletFull[walletField]) throw new Error(`no ${walletType} wallet found`)
+
+ // check for pending withdrawals
+ const pendingWithdrawals = await models.withdrawl.count({
+ where: {
+ walletId: walletFull.id,
+ status: null
+ }
+ })
+
+ // and pending forwards
+ const pendingForwards = await models.invoiceForward.count({
+ where: {
+ walletId: walletFull.id,
+ invoice: {
+ actionState: {
+ notIn: PAID_ACTION_TERMINAL_STATES
+ }
+ }
+ }
+ })
+
+ console.log('pending invoices', pendingWithdrawals + pendingForwards)
+ if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) {
+ throw new Error('wallet has too many pending invoices')
+ }
+}
diff --git a/wallets/wrap.js b/wallets/wrap.js
index b470587b96..1369740960 100644
--- a/wallets/wrap.js
+++ b/wallets/wrap.js
@@ -1,6 +1,7 @@
import { createHodlInvoice, parsePaymentRequest } from 'ln-service'
import { estimateRouteFee, getBlockHeight } from '../api/lnd'
-import { toPositiveNumber } from '@/lib/validate'
+import { toPositiveBigInt, toPositiveNumber } from '@/lib/validate'
+import { ZAP_SYBIL_FEE_PERCENT, MAX_FEE_ESTIMATE_PERMILE } from '@/lib/constants'
const MIN_OUTGOING_MSATS = BigInt(900) // the minimum msats we'll allow for the outgoing invoice
const MAX_OUTGOING_MSATS = BigInt(900_000_000) // the maximum msats we'll allow for the outgoing invoice
@@ -9,21 +10,34 @@ const INCOMING_EXPIRATION_BUFFER_MSECS = 300_000 // the buffer enforce for the i
const MAX_OUTGOING_CLTV_DELTA = 500 // the maximum cltv delta we'll allow for the outgoing invoice
export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice
const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request
-const MAX_FEE_ESTIMATE_PERCENT = 0.025 // the maximum fee relative to outgoing we'll allow for the fee estimate
-const ZAP_SYBIL_FEE_MULT = 10 / 7 // the fee for the zap sybil service
-/*
- The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice.
-
- @param bolt11 {string} the bolt11 invoice to wrap
- @param options {object}
- @returns {
- invoice: the wrapped incoming invoice,
- maxFee: number
- }
-*/
-export default async function wrapInvoice (bolt11, { msats, description, descriptionHash }, { me, lnd }) {
+/**
+ * The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice.
+ *
+ * @typedef {import('lightning').CreateHodlInvoiceResult} CreateHodlInvoiceResult
+
+ * @typedef {Object} WrapedInvoice
+ * @property {CreateHodlInvoiceResult} invoice the wrapped incoming invoice
+ * @property {bigint} maxFee the maximum fee allowed for the outgoing invoice
+ *
+ *
+ * @param {*} bolt11 the bolt11 invoice to wrap
+ * @param {Object} args
+ * @param {bigint} args.msats
+ * @param {string} args.description
+ * @param {string} [args.descriptionHash]
+ * @param {Object} context
+ * @param {Object} context.lnd
+ * @param {bigint} [zapSybilFeePercent]
+ * @returns {Promise
}
+ */
+export default async function wrapInvoice (bolt11, { msats, description, descriptionHash }, { lnd }, zapSybilFeePercent) {
try {
+ if (!zapSybilFeePercent) {
+ console.log('Use default zapSybilFeePercent', ZAP_SYBIL_FEE_PERCENT)
+ zapSybilFeePercent = ZAP_SYBIL_FEE_PERCENT
+ }
+
console.group('wrapInvoice', description)
// create a new object to hold the wrapped invoice values
@@ -40,7 +54,7 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip
// validate outgoing amount
if (inv.mtokens) {
- outgoingMsat = toPositiveNumber(inv.mtokens)
+ outgoingMsat = toPositiveBigInt(inv.mtokens)
if (outgoingMsat < MIN_OUTGOING_MSATS) {
throw new Error(`Invoice amount is too low: ${outgoingMsat}`)
}
@@ -53,9 +67,12 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip
// validate incoming amount
if (msats) {
- msats = toPositiveNumber(msats)
- if (outgoingMsat * ZAP_SYBIL_FEE_MULT > msats) {
+ msats = toPositiveBigInt(msats)
+ const msatsCheck = outgoingMsat * 100n / (100n - zapSybilFeePercent)
+ if (msatsCheck > msats) {
throw new Error('Sybil fee is too low')
+ } else if (msatsCheck < msats) {
+ console.warn('Sybil fee is too high')
}
} else {
throw new Error('Incoming invoice amount is missing')
@@ -112,11 +129,6 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip
wrapped.description = inv.description
}
- if (me?.hideInvoiceDesc) {
- wrapped.description = undefined
- wrapped.description_hash = undefined
- }
-
// validate the expiration
if (new Date(inv.expires_at) < new Date(Date.now() + INCOMING_EXPIRATION_BUFFER_MSECS)) {
throw new Error('Invoice expiration is too soon')
@@ -161,8 +173,8 @@ export default async function wrapInvoice (bolt11, { msats, description, descrip
}
// validate the fee budget
- const minEstFees = toPositiveNumber(routingFeeMsat)
- const outgoingMaxFeeMsat = Math.ceil(msats * MAX_FEE_ESTIMATE_PERCENT)
+ const minEstFees = toPositiveBigInt(routingFeeMsat)
+ const outgoingMaxFeeMsat = msats * MAX_FEE_ESTIMATE_PERMILE / 1000n
if (minEstFees > outgoingMaxFeeMsat) {
throw new Error('Estimated fees are too high')
}
diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js
index 046115994a..0862fb3570 100644
--- a/worker/autowithdraw.js
+++ b/worker/autowithdraw.js
@@ -42,7 +42,14 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
if (pendingOrFailed.exists) return
- const { invoice, wallet } = await createInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 }, { models })
+ const { invoice, wallet } = await createInvoice(id,
+ {
+ msats,
+ description: 'SN: autowithdrawal',
+ expiry: 360,
+ direct: true // no need to wrap this one
+ }, { models, lnd })
+
return await createWithdrawal(null,
{ invoice, maxFee: msatsToSats(maxFeeMsats) },
{ me: { id }, models, lnd, walletId: wallet.id })
diff --git a/worker/territory.js b/worker/territory.js
index d7a7f479f5..87c2e4cf5b 100644
--- a/worker/territory.js
+++ b/worker/territory.js
@@ -36,7 +36,7 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
try {
const { result } = await performPaidAction('TERRITORY_BILLING',
- { name: subName }, { models, me: sub.user, lnd, forceFeeCredits: true })
+ { 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/wallet.js b/worker/wallet.js
index 44149ec318..cae4423860 100644
--- a/worker/wallet.js
+++ b/worker/wallet.js
@@ -7,7 +7,7 @@ import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush'
import { INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import { datePivot, sleep } from '@/lib/time'
import retry from 'async-retry'
-import { addWalletLog } from '@/api/resolvers/wallet'
+import { addWalletLog } from '@/wallets/server'
import { msatsToSats, numWithUnits } from '@/lib/format'
import {
paidActionPaid, paidActionForwarded,
diff --git a/worker/weeklyPosts.js b/worker/weeklyPosts.js
index ca96c73f44..5b764b7f58 100644
--- a/worker/weeklyPosts.js
+++ b/worker/weeklyPosts.js
@@ -6,7 +6,7 @@ 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, forceFeeCredits: true })
+ { models, me: { id: USER_ID.sn }, lnd, forceInternal: true })
}
export async function weeklyPost (args) {
@@ -47,5 +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, forceFeeCredits: true })
+ { models, me: { id: USER_ID.sn }, lnd, forceInternal: true })
}