paid action payment methods as an array (#1584)
* introduce fee credits & allow paid actions to specify payment method priority * fix merge issue * express supported paid action payment methods as an array * log force payment method skipping methods * fix stuff * immutable context * immutable paidAction context and other fixes --------- Co-authored-by: Riccardo Balbo <riccardo0blb@gmail.com>
This commit is contained in:
parent
d1c770dbbc
commit
a44d0daf09
@ -139,8 +139,14 @@ Each paid action is implemented in its own file in the `paidAction` directory. E
|
|||||||
|
|
||||||
### Boolean flags
|
### Boolean flags
|
||||||
- `anonable`: can be performed anonymously
|
- `anonable`: can be performed anonymously
|
||||||
- `supportsPessimism`: supports a pessimistic payment flow
|
|
||||||
- `supportsOptimism`: supports an optimistic payment flow
|
### Payment methods
|
||||||
|
- `paymentMethods`: an array of payment methods that the action supports ordered from most preferred to least preferred
|
||||||
|
- P2P: a p2p payment made directly from the client to the recipient
|
||||||
|
- after wrapping the invoice, anonymous users will follow a PESSIMISTIC flow to pay the invoice and logged in users will follow an OPTIMISTIC flow
|
||||||
|
- FEE_CREDIT: a payment made from the user's fee credit balance
|
||||||
|
- OPTIMISTIC: an optimistic payment flow
|
||||||
|
- PESSIMISTIC: a pessimistic payment flow
|
||||||
|
|
||||||
### Functions
|
### Functions
|
||||||
|
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
|
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
export const supportsPessimism = false
|
|
||||||
export const supportsOptimism = true
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ sats }) {
|
export async function getCost ({ sats }) {
|
||||||
return satsToMsats(sats)
|
return satsToMsats(sats)
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
// XXX we don't use this yet ...
|
|
||||||
// it's just showing that even buying credits
|
|
||||||
// can eventually be a paid action
|
|
||||||
|
|
||||||
import { USER_ID } from '@/lib/constants'
|
|
||||||
import { satsToMsats } from '@/lib/format'
|
|
||||||
|
|
||||||
export const anonable = false
|
|
||||||
export const supportsPessimism = false
|
|
||||||
export const supportsOptimism = true
|
|
||||||
|
|
||||||
export async function getCost ({ amount }) {
|
|
||||||
return satsToMsats(amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function onPaid ({ invoice }, { tx }) {
|
|
||||||
return await tx.users.update({
|
|
||||||
where: { id: invoice.userId },
|
|
||||||
data: { balance: { increment: invoice.msatsReceived } }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function describe ({ amount }, { models, me }) {
|
|
||||||
const user = await models.user.findUnique({ where: { id: me?.id ?? USER_ID.anon } })
|
|
||||||
return `SN: buying credits for @${user.name}`
|
|
||||||
}
|
|
@ -1,9 +1,12 @@
|
|||||||
import { USER_ID } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
|
|
||||||
export const anonable = true
|
export const anonable = true
|
||||||
export const supportsPessimism = true
|
|
||||||
export const supportsOptimism = false
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ sats }) {
|
export async function getCost ({ sats }) {
|
||||||
return satsToMsats(sats)
|
return satsToMsats(sats)
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
|
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
export const supportsPessimism = false
|
|
||||||
export const supportsOptimism = true
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ sats }) {
|
export async function getCost ({ sats }) {
|
||||||
return satsToMsats(sats)
|
return satsToMsats(sats)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
|
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
|
||||||
import { createHmac } from '@/api/resolvers/wallet'
|
import { createHmac } from '@/api/resolvers/wallet'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import * as ITEM_CREATE from './itemCreate'
|
import * as ITEM_CREATE from './itemCreate'
|
||||||
@ -30,9 +30,9 @@ export const paidActions = {
|
|||||||
DONATE
|
DONATE
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function performPaidAction (actionType, args, context) {
|
export default async function performPaidAction (actionType, args, incomingContext) {
|
||||||
try {
|
try {
|
||||||
const { me, models, forceFeeCredits } = context
|
const { me, models, forcePaymentMethod } = incomingContext
|
||||||
const paidAction = paidActions[actionType]
|
const paidAction = paidActions[actionType]
|
||||||
|
|
||||||
console.group('performPaidAction', actionType, args)
|
console.group('performPaidAction', actionType, args)
|
||||||
@ -41,52 +41,71 @@ export default async function performPaidAction (actionType, args, context) {
|
|||||||
throw new Error(`Invalid action type ${actionType}`)
|
throw new Error(`Invalid action type ${actionType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined
|
if (!me && !paidAction.anonable) {
|
||||||
context.cost = await paidAction.getCost(args, context)
|
throw new Error('You must be logged in to perform this action')
|
||||||
context.sybilFeePercent = await paidAction.getSybilFeePercent?.(args, context)
|
|
||||||
|
|
||||||
if (!me) {
|
|
||||||
if (!paidAction.anonable) {
|
|
||||||
throw new Error('You must be logged in to perform this action')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.cost > 0) {
|
|
||||||
console.log('we are anon so can only perform pessimistic action that require payment')
|
|
||||||
return await performPessimisticAction(actionType, args, context)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRich = context.cost <= (context.me?.msats ?? 0)
|
// treat context as immutable
|
||||||
if (isRich) {
|
const contextWithMe = {
|
||||||
try {
|
...incomingContext,
|
||||||
console.log('enough fee credits available, performing fee credit action')
|
me: me ? await models.user.findUnique({ where: { id: me.id } }) : undefined
|
||||||
return await performFeeCreditAction(actionType, args, context)
|
}
|
||||||
} catch (e) {
|
const context = {
|
||||||
console.error('fee credit action failed', e)
|
...contextWithMe,
|
||||||
|
cost: await paidAction.getCost(args, contextWithMe),
|
||||||
|
sybilFeePercent: await paidAction.getSybilFeePercent?.(args, contextWithMe)
|
||||||
|
}
|
||||||
|
|
||||||
// if we fail with fee credits, but not because of insufficient funds, bail
|
// special case for zero cost actions
|
||||||
if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
|
if (context.cost === 0n) {
|
||||||
|
console.log('performing zero cost action')
|
||||||
|
return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod: 'ZERO_COST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const paymentMethod of paidAction.paymentMethods) {
|
||||||
|
console.log(`considering payment method ${paymentMethod}`)
|
||||||
|
|
||||||
|
if (forcePaymentMethod &&
|
||||||
|
paymentMethod !== forcePaymentMethod) {
|
||||||
|
console.log('skipping payment method', paymentMethod, 'because forcePaymentMethod is set to', forcePaymentMethod)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
console.error(`${paymentMethod} action failed`, e)
|
||||||
throw e
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) {
|
||||||
|
return await performOptimisticAction(actionType, args, context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is set if the worker executes a paid action in behalf of a user.
|
throw new Error('No working payment method found')
|
||||||
// in that case, only payment via fee credits is possible
|
|
||||||
// since there is no client to which we could send an invoice.
|
|
||||||
// example: automated territory billing
|
|
||||||
if (forceFeeCredits) {
|
|
||||||
throw new Error('forceFeeCredits is set, but user does not have enough fee credits')
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we fail to do the action with fee credits, we should fall back to optimistic
|
|
||||||
if (paidAction.supportsOptimism) {
|
|
||||||
console.log('performing optimistic action')
|
|
||||||
return await performOptimisticAction(actionType, args, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('action does not support optimism and fee credits failed, performing pessimistic action')
|
|
||||||
return await performPessimisticAction(actionType, args, context)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('performPaidAction failed', e)
|
console.error('performPaidAction failed', e)
|
||||||
throw e
|
throw e
|
||||||
@ -95,50 +114,48 @@ export default async function performPaidAction (actionType, args, context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performFeeCreditAction (actionType, args, context) {
|
async function performNoInvoiceAction (actionType, args, incomingContext) {
|
||||||
const { me, models, cost } = context
|
const { me, models, cost, paymentMethod } = incomingContext
|
||||||
const action = paidActions[actionType]
|
const action = paidActions[actionType]
|
||||||
|
|
||||||
const result = await models.$transaction(async tx => {
|
const result = await models.$transaction(async tx => {
|
||||||
context.tx = tx
|
const context = { ...incomingContext, tx }
|
||||||
|
|
||||||
await tx.user.update({
|
if (paymentMethod === 'FEE_CREDIT') {
|
||||||
where: {
|
await tx.user.update({
|
||||||
id: me?.id ?? USER_ID.anon
|
where: {
|
||||||
},
|
id: me?.id ?? USER_ID.anon
|
||||||
data: {
|
},
|
||||||
msats: {
|
data: { msats: { decrement: cost } }
|
||||||
decrement: cost
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await action.perform(args, context)
|
const result = await action.perform(args, context)
|
||||||
await action.onPaid?.(result, context)
|
await action.onPaid?.(result, context)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result,
|
result,
|
||||||
paymentMethod: 'FEE_CREDIT'
|
paymentMethod
|
||||||
}
|
}
|
||||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
||||||
|
|
||||||
// run non critical side effects in the background
|
// run non critical side effects in the background
|
||||||
// after the transaction has been committed
|
// after the transaction has been committed
|
||||||
action.nonCriticalSideEffects?.(result.result, context).catch(console.error)
|
action.nonCriticalSideEffects?.(result.result, incomingContext).catch(console.error)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performOptimisticAction (actionType, args, context) {
|
async function performOptimisticAction (actionType, args, incomingContext) {
|
||||||
const { models } = context
|
const { models, invoiceArgs: incomingInvoiceArgs } = incomingContext
|
||||||
const action = paidActions[actionType]
|
const action = paidActions[actionType]
|
||||||
|
|
||||||
context.optimistic = true
|
const optimisticContext = { ...incomingContext, optimistic: true }
|
||||||
const invoiceArgs = await createLightningInvoice(actionType, args, context)
|
const invoiceArgs = incomingInvoiceArgs ?? await createSNInvoice(actionType, args, optimisticContext)
|
||||||
|
|
||||||
return await models.$transaction(async tx => {
|
return await models.$transaction(async tx => {
|
||||||
context.tx = tx
|
const context = { ...optimisticContext, tx, invoiceArgs }
|
||||||
|
|
||||||
const invoice = await createDbInvoice(actionType, args, context, invoiceArgs)
|
const invoice = await createDbInvoice(actionType, args, context)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invoice,
|
invoice,
|
||||||
@ -148,23 +165,61 @@ async function performOptimisticAction (actionType, args, context) {
|
|||||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performPessimisticAction (actionType, args, context) {
|
async function beginPessimisticAction (actionType, args, context) {
|
||||||
const action = paidActions[actionType]
|
const action = paidActions[actionType]
|
||||||
|
|
||||||
if (!action.supportsPessimism) {
|
if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC)) {
|
||||||
throw new Error(`This action ${actionType} does not support pessimistic invoicing`)
|
throw new Error(`This action ${actionType} does not support pessimistic invoicing`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// just create the invoice and complete action when it's paid
|
// just create the invoice and complete action when it's paid
|
||||||
const invoiceArgs = await createLightningInvoice(actionType, args, context)
|
const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(actionType, args, context)
|
||||||
return {
|
return {
|
||||||
invoice: await createDbInvoice(actionType, args, context, invoiceArgs),
|
invoice: await createDbInvoice(actionType, args, { ...context, invoiceArgs }),
|
||||||
paymentMethod: 'PESSIMISTIC'
|
paymentMethod: 'PESSIMISTIC'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function retryPaidAction (actionType, args, context) {
|
async function performP2PAction (actionType, args, incomingContext) {
|
||||||
const { models, me } = 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, sybilFeePercent, me } = incomingContext
|
||||||
|
if (!sybilFeePercent) {
|
||||||
|
throw new Error('sybil fee percent is not set for an invoiceable peer action')
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext)
|
||||||
|
if (!userId) {
|
||||||
|
throw new NonInvoiceablePeerError()
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertBelowMaxPendingInvoices(incomingContext)
|
||||||
|
|
||||||
|
const description = await paidActions[actionType].describe(args, incomingContext)
|
||||||
|
const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
|
||||||
|
msats: cost,
|
||||||
|
feePercent: sybilFeePercent,
|
||||||
|
description,
|
||||||
|
expiry: INVOICE_EXPIRE_SECS
|
||||||
|
}, { models, me, lnd })
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
...incomingContext,
|
||||||
|
invoiceArgs: {
|
||||||
|
bolt11: invoice,
|
||||||
|
wrappedBolt11: wrappedInvoice,
|
||||||
|
wallet,
|
||||||
|
maxFee
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return me
|
||||||
|
? 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
|
const { invoice: failedInvoice } = args
|
||||||
|
|
||||||
console.log('retryPaidAction', actionType, args)
|
console.log('retryPaidAction', actionType, args)
|
||||||
@ -178,7 +233,7 @@ export async function retryPaidAction (actionType, args, context) {
|
|||||||
throw new Error(`retryPaidAction - must be logged in ${actionType}`)
|
throw new Error(`retryPaidAction - must be logged in ${actionType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!action.supportsOptimism) {
|
if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)) {
|
||||||
throw new Error(`retryPaidAction - action does not support optimism ${actionType}`)
|
throw new Error(`retryPaidAction - action does not support optimism ${actionType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,16 +245,19 @@ export async function retryPaidAction (actionType, args, context) {
|
|||||||
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
|
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.optimistic = true
|
|
||||||
context.me = await models.user.findUnique({ where: { id: me.id } })
|
|
||||||
|
|
||||||
const { msatsRequested, actionId } = failedInvoice
|
const { msatsRequested, actionId } = failedInvoice
|
||||||
context.cost = BigInt(msatsRequested)
|
const retryContext = {
|
||||||
context.actionId = actionId
|
...incomingContext,
|
||||||
const invoiceArgs = await createSNInvoice(actionType, args, context)
|
optimistic: true,
|
||||||
|
me: await models.user.findUnique({ where: { id: me.id } }),
|
||||||
|
cost: BigInt(msatsRequested),
|
||||||
|
actionId
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceArgs = await createSNInvoice(actionType, args, retryContext)
|
||||||
|
|
||||||
return await models.$transaction(async tx => {
|
return await models.$transaction(async tx => {
|
||||||
context.tx = tx
|
const context = { ...retryContext, tx, invoiceArgs }
|
||||||
|
|
||||||
// update the old invoice to RETRYING, so that it's not confused with FAILED
|
// update the old invoice to RETRYING, so that it's not confused with FAILED
|
||||||
await tx.invoice.update({
|
await tx.invoice.update({
|
||||||
@ -213,7 +271,7 @@ export async function retryPaidAction (actionType, args, context) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// create a new invoice
|
// create a new invoice
|
||||||
const invoice = await createDbInvoice(actionType, args, context, invoiceArgs)
|
const invoice = await createDbInvoice(actionType, args, context)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
|
result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
|
||||||
@ -226,55 +284,27 @@ export async function retryPaidAction (actionType, args, context) {
|
|||||||
const INVOICE_EXPIRE_SECS = 600
|
const INVOICE_EXPIRE_SECS = 600
|
||||||
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
|
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
|
||||||
|
|
||||||
export async function createLightningInvoice (actionType, args, context) {
|
export async function assertBelowMaxPendingInvoices (context) {
|
||||||
// if the action has an invoiceable peer, we'll create a peer invoice
|
const { models, me } = context
|
||||||
// wrap it, and return the wrapped invoice
|
|
||||||
const { cost, models, lnd, sybilFeePercent, me } = context
|
|
||||||
|
|
||||||
// count pending invoices and bail if we're over the limit
|
|
||||||
const pendingInvoices = await models.invoice.count({
|
const pendingInvoices = await models.invoice.count({
|
||||||
where: {
|
where: {
|
||||||
userId: me?.id ?? USER_ID.anon,
|
userId: me?.id ?? USER_ID.anon,
|
||||||
actionState: {
|
actionState: {
|
||||||
// not in a terminal state. Note: null isn't counted by prisma
|
|
||||||
notIn: PAID_ACTION_TERMINAL_STATES
|
notIn: PAID_ACTION_TERMINAL_STATES
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('pending paid actions', pendingInvoices)
|
|
||||||
if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) {
|
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')
|
throw new Error('You have too many pending paid actions, cancel some or wait for them to expire')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, context)
|
export class NonInvoiceablePeerError extends Error {
|
||||||
if (userId) {
|
constructor () {
|
||||||
try {
|
super('non invoiceable peer')
|
||||||
if (!sybilFeePercent) {
|
this.name = 'NonInvoiceablePeerError'
|
||||||
throw new Error('sybil fee percent is not set for an invoiceable peer action')
|
|
||||||
}
|
|
||||||
|
|
||||||
const description = await paidActions[actionType].describe(args, context)
|
|
||||||
|
|
||||||
const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, {
|
|
||||||
msats: cost,
|
|
||||||
feePercent: sybilFeePercent,
|
|
||||||
description,
|
|
||||||
expiry: INVOICE_EXPIRE_SECS
|
|
||||||
}, { models, me, lnd })
|
|
||||||
|
|
||||||
return {
|
|
||||||
bolt11: invoice,
|
|
||||||
wrappedBolt11: wrappedInvoice,
|
|
||||||
wallet,
|
|
||||||
maxFee
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('failed to create stacker invoice, falling back to SN invoice', e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await createSNInvoice(actionType, args, context)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// we seperate the invoice creation into two functions because
|
// we seperate the invoice creation into two functions because
|
||||||
@ -299,9 +329,10 @@ async function createSNInvoice (actionType, args, context) {
|
|||||||
return { bolt11: invoice.request, preimage: invoice.secret }
|
return { bolt11: invoice.request, preimage: invoice.secret }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDbInvoice (actionType, args, context,
|
async function createDbInvoice (actionType, args, context) {
|
||||||
{ bolt11, wrappedBolt11, preimage, wallet, maxFee }) {
|
const { me, models, tx, cost, optimistic, actionId, invoiceArgs } = context
|
||||||
const { me, models, tx, cost, optimistic, actionId } = context
|
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs
|
||||||
|
|
||||||
const db = tx ?? models
|
const db = tx ?? models
|
||||||
|
|
||||||
if (cost < 1000n) {
|
if (cost < 1000n) {
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, USER_ID } from '@/lib/constants'
|
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||||
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers } from '@/lib/webPush'
|
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers } from '@/lib/webPush'
|
||||||
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
||||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
|
|
||||||
export const anonable = true
|
export const anonable = true
|
||||||
export const supportsPessimism = true
|
|
||||||
export const supportsOptimism = true
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
|
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
|
||||||
const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
|
const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
import { USER_ID } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||||
import { uploadFees } from '../resolvers/upload'
|
import { uploadFees } from '../resolvers/upload'
|
||||||
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
||||||
import { notifyItemMention, notifyMention } from '@/lib/webPush'
|
import { notifyItemMention, notifyMention } from '@/lib/webPush'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
|
|
||||||
export const anonable = true
|
export const anonable = true
|
||||||
export const supportsPessimism = true
|
|
||||||
export const supportsOptimism = false
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }) {
|
export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }) {
|
||||||
// the only reason updating items costs anything is when it has new uploads
|
// the only reason updating items costs anything is when it has new uploads
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
|
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
export const supportsPessimism = true
|
|
||||||
export const supportsOptimism = true
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ id }, { me, models }) {
|
export async function getCost ({ id }, { me, models }) {
|
||||||
const pollOption = await models.pollOption.findUnique({
|
const pollOption = await models.pollOption.findUnique({
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
import { nextBilling } from '@/lib/territory'
|
import { nextBilling } from '@/lib/territory'
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
export const supportsPessimism = true
|
|
||||||
export const supportsOptimism = false
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ name }, { models }) {
|
export async function getCost ({ name }, { models }) {
|
||||||
const sub = await models.sub.findUnique({
|
const sub = await models.sub.findUnique({
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
import { nextBilling } from '@/lib/territory'
|
import { nextBilling } from '@/lib/territory'
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
export const supportsPessimism = true
|
|
||||||
export const supportsOptimism = false
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ billingType }) {
|
export async function getCost ({ billingType }) {
|
||||||
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
|
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
import { nextBilling } from '@/lib/territory'
|
import { nextBilling } from '@/lib/territory'
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
export const supportsPessimism = true
|
|
||||||
export const supportsOptimism = false
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ billingType }) {
|
export async function getCost ({ billingType }) {
|
||||||
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
|
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { TERRITORY_PERIOD_COST } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
import { proratedBillingCost } from '@/lib/territory'
|
import { proratedBillingCost } from '@/lib/territory'
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
|
|
||||||
export const anonable = false
|
export const anonable = false
|
||||||
export const supportsPessimism = true
|
|
||||||
export const supportsOptimism = false
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ oldName, billingType }, { models }) {
|
export async function getCost ({ oldName, billingType }, { models }) {
|
||||||
const oldSub = await models.sub.findUnique({
|
const oldSub = await models.sub.findUnique({
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
import { USER_ID } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
import { notifyZapped } from '@/lib/webPush'
|
import { notifyZapped } from '@/lib/webPush'
|
||||||
|
|
||||||
export const anonable = true
|
export const anonable = true
|
||||||
export const supportsPessimism = true
|
|
||||||
export const supportsOptimism = true
|
export const paymentMethods = [
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.P2P,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
|
||||||
|
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
|
||||||
|
]
|
||||||
|
|
||||||
export async function getCost ({ sats }) {
|
export async function getCost ({ sats }) {
|
||||||
return satsToMsats(sats)
|
return satsToMsats(sats)
|
||||||
|
@ -509,7 +509,7 @@ const resolvers = {
|
|||||||
verifyHmac(hash, hmac)
|
verifyHmac(hash, hmac)
|
||||||
const dbInv = await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
|
const dbInv = await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
|
||||||
|
|
||||||
if (dbInv.invoiceForward) {
|
if (dbInv?.invoiceForward) {
|
||||||
const { wallet, bolt11 } = dbInv.invoiceForward
|
const { wallet, bolt11 } = dbInv.invoiceForward
|
||||||
const logger = walletLogger({ wallet, models })
|
const logger = walletLogger({ wallet, models })
|
||||||
const decoded = await parsePaymentRequest({ request: bolt11 })
|
const decoded = await parsePaymentRequest({ request: bolt11 })
|
||||||
|
@ -12,6 +12,7 @@ extend type Mutation {
|
|||||||
|
|
||||||
enum PaymentMethod {
|
enum PaymentMethod {
|
||||||
FEE_CREDIT
|
FEE_CREDIT
|
||||||
|
ZERO_COST
|
||||||
OPTIMISTIC
|
OPTIMISTIC
|
||||||
PESSIMISTIC
|
PESSIMISTIC
|
||||||
}
|
}
|
||||||
|
@ -509,6 +509,7 @@ function InputInner ({
|
|||||||
if (storageKey) {
|
if (storageKey) {
|
||||||
window.localStorage.setItem(storageKey, overrideValue)
|
window.localStorage.setItem(storageKey, overrideValue)
|
||||||
}
|
}
|
||||||
|
onChange && onChange(formik, { target: { value: overrideValue } })
|
||||||
} else if (storageKey) {
|
} else if (storageKey) {
|
||||||
const draft = window.localStorage.getItem(storageKey)
|
const draft = window.localStorage.getItem(storageKey)
|
||||||
if (draft) {
|
if (draft) {
|
||||||
|
@ -3,6 +3,12 @@
|
|||||||
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
|
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
|
||||||
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
|
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
|
||||||
|
|
||||||
|
export const PAID_ACTION_PAYMENT_METHODS = {
|
||||||
|
FEE_CREDIT: 'FEE_CREDIT',
|
||||||
|
PESSIMISTIC: 'PESSIMISTIC',
|
||||||
|
OPTIMISTIC: 'OPTIMISTIC',
|
||||||
|
P2P: 'P2P'
|
||||||
|
}
|
||||||
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
|
export const PAID_ACTION_TERMINAL_STATES = ['FAILED', 'PAID', 'RETRYING']
|
||||||
export const NOFOLLOW_LIMIT = 1000
|
export const NOFOLLOW_LIMIT = 1000
|
||||||
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener'
|
export const UNKNOWN_LINK_REL = 'noreferrer nofollow noopener'
|
||||||
|
@ -112,10 +112,14 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, tran
|
|||||||
async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, lnd, boss }) {
|
async function performPessimisticAction ({ lndInvoice, dbInvoice, tx, models, lnd, boss }) {
|
||||||
try {
|
try {
|
||||||
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
|
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
|
||||||
const context = { tx, cost: BigInt(lndInvoice.received_mtokens) }
|
const context = {
|
||||||
context.sybilFeePercent = await paidActions[dbInvoice.actionType].getSybilFeePercent?.(args, context)
|
tx,
|
||||||
|
cost: BigInt(lndInvoice.received_mtokens),
|
||||||
|
me: dbInvoice.user
|
||||||
|
}
|
||||||
|
const sybilFeePercent = await paidActions[dbInvoice.actionType].getSybilFeePercent?.(args, context)
|
||||||
|
|
||||||
const result = await paidActions[dbInvoice.actionType].perform(args, context)
|
const result = await paidActions[dbInvoice.actionType].perform(args, { ...context, sybilFeePercent })
|
||||||
await tx.invoice.update({
|
await tx.invoice.update({
|
||||||
where: { id: dbInvoice.id },
|
where: { id: dbInvoice.id },
|
||||||
data: {
|
data: {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import lnd from '@/api/lnd'
|
import lnd from '@/api/lnd'
|
||||||
import performPaidAction from '@/api/paidAction'
|
import performPaidAction from '@/api/paidAction'
|
||||||
import serialize from '@/api/resolvers/serial'
|
import serialize from '@/api/resolvers/serial'
|
||||||
|
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
|
||||||
import { nextBillingWithGrace } from '@/lib/territory'
|
import { nextBillingWithGrace } from '@/lib/territory'
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
|
|
||||||
@ -36,7 +37,12 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { result } = await performPaidAction('TERRITORY_BILLING',
|
const { result } = await performPaidAction('TERRITORY_BILLING',
|
||||||
{ name: subName }, { models, me: sub.user, lnd, forceFeeCredits: true })
|
{ name: subName }, {
|
||||||
|
models,
|
||||||
|
me: sub.user,
|
||||||
|
lnd,
|
||||||
|
forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT
|
||||||
|
})
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error('not enough fee credits to auto-renew territory')
|
throw new Error('not enough fee credits to auto-renew territory')
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
import performPaidAction from '@/api/paidAction'
|
import performPaidAction from '@/api/paidAction'
|
||||||
import { USER_ID } from '@/lib/constants'
|
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
|
|
||||||
export async function autoPost ({ data: item, models, apollo, lnd, boss }) {
|
export async function autoPost ({ data: item, models, apollo, lnd, boss }) {
|
||||||
return await performPaidAction('ITEM_CREATE',
|
return await performPaidAction('ITEM_CREATE',
|
||||||
{ ...item, subName: 'meta', userId: USER_ID.sn, apiKey: true },
|
{ ...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,
|
||||||
|
forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function weeklyPost (args) {
|
export async function weeklyPost (args) {
|
||||||
@ -47,5 +52,10 @@ export async function payWeeklyPostBounty ({ data: { id }, models, apollo, lnd }
|
|||||||
|
|
||||||
await performPaidAction('ZAP',
|
await performPaidAction('ZAP',
|
||||||
{ id: winner.id, sats: item.bounty },
|
{ id: winner.id, sats: item.bounty },
|
||||||
{ models, me: { id: USER_ID.sn }, lnd, forceFeeCredits: true })
|
{
|
||||||
|
models,
|
||||||
|
me: { id: USER_ID.sn },
|
||||||
|
lnd,
|
||||||
|
forcePaymentMethod: PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user