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:
Keyan 2024-11-12 19:00:51 -06:00 committed by GitHub
parent d1c770dbbc
commit a44d0daf09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 260 additions and 181 deletions

View File

@ -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

View File

@ -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)

View File

@ -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}`
}

View File

@ -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)

View File

@ -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)

View File

@ -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) {

View File

@ -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 } })

View File

@ -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

View File

@ -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({

View File

@ -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({

View File

@ -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))

View File

@ -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))

View File

@ -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({

View File

@ -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)

View File

@ -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 })

View File

@ -12,6 +12,7 @@ extend type Mutation {
enum PaymentMethod { enum PaymentMethod {
FEE_CREDIT FEE_CREDIT
ZERO_COST
OPTIMISTIC OPTIMISTIC
PESSIMISTIC PESSIMISTIC
} }

View File

@ -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) {

View File

@ -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'

View File

@ -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: {

View File

@ -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')
} }

View File

@ -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
})
} }