Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

November 5th #1481

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions api/paidAction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,15 @@ Each paid action is implemented in its own file in the `paidAction` directory. E

### Boolean flags
- `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
- REWARD_SATS: a payment made from the user's reward sats balance
- OPTIMISTIC: an optimistic payment flow
- PESSIMISTIC: a pessimistic payment flow

### Functions

Expand Down
9 changes: 7 additions & 2 deletions api/paidAction/boost.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'

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.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]

export async function getCost ({ sats }) {
return satsToMsats(sats)
Expand Down
13 changes: 6 additions & 7 deletions api/paidAction/buyCredits.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// 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 { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'

export const anonable = false
export const supportsPessimism = false
export const supportsOptimism = true

export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]

export async function getCost ({ amount }) {
return satsToMsats(amount)
Expand Down
10 changes: 7 additions & 3 deletions api/paidAction/donate.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { USER_ID } from '@/lib/constants'
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'

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.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]

export async function getCost ({ sats }) {
return satsToMsats(sats)
Expand Down
9 changes: 7 additions & 2 deletions api/paidAction/downZap.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'

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.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]

export async function getCost ({ sats }) {
return satsToMsats(sats)
Expand Down
192 changes: 109 additions & 83 deletions api/paidAction/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
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 '../resolvers/wallet'
import { Prisma } from '@prisma/client'
import * as ITEM_CREATE from './itemCreate'
Expand All @@ -14,6 +14,7 @@ import * as TERRITORY_BILLING from './territoryBilling'
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'

Expand All @@ -28,7 +29,8 @@ export const paidActions = {
TERRITORY_UPDATE,
TERRITORY_BILLING,
TERRITORY_UNARCHIVE,
DONATE
DONATE,
BUY_CREDITS
}

export default async function performPaidAction (actionType, args, context) {
Expand All @@ -42,51 +44,61 @@ export default async function performPaidAction (actionType, args, context) {
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 = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined
context.cost = await paidAction.getCost(args, context)

if (!me) {
if (!paidAction.anonable) {
throw new Error('You must be logged in to perform this action')
// special case for zero cost actions
if (context.cost === 0n) {
console.log('performing zero cost action')
return await performNoInvoiceAction(actionType, args, context, 'ZERO_COST')
}

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 (context.cost > 0) {
console.log('we are anon so can only perform pessimistic action that require payment')
// 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
}
}
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) {
return await performPessimisticAction(actionType, args, context)
}
}

const isRich = context.cost <= (context.me?.msats ?? 0)
if (isRich) {
try {
console.log('enough fee credits available, performing fee credit action')
return await performFeeCreditAction(actionType, args, context)
} catch (e) {
console.error('fee credit action failed', e)

// if we fail with fee credits, but not because of insufficient funds, bail
if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
throw e
// 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) {
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 \\"mcredits_positive\\"') &&
!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.
// 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) {
console.error('performPaidAction failed', e)
throw e
Expand All @@ -95,30 +107,30 @@ export default async function performPaidAction (actionType, args, context) {
}
}

async function performFeeCreditAction (actionType, args, context) {
async function performNoInvoiceAction (actionType, args, context, paymentMethod) {
const { me, models, cost } = context
const action = paidActions[actionType]

const result = await models.$transaction(async tx => {
context.tx = tx

await tx.user.update({
where: {
id: me?.id ?? USER_ID.anon
},
data: {
msats: {
decrement: cost
}
}
})
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)

return {
result,
paymentMethod: 'FEE_CREDIT'
paymentMethod
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })

Expand All @@ -133,7 +145,7 @@ async function performOptimisticAction (actionType, args, context) {
const action = paidActions[actionType]

context.optimistic = true
const invoiceArgs = await createLightningInvoice(actionType, args, context)
const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(actionType, args, context)

return await models.$transaction(async tx => {
context.tx = tx
Expand All @@ -151,18 +163,28 @@ async function performOptimisticAction (actionType, args, context) {
async function performPessimisticAction (actionType, args, context) {
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`)
}

// 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 {
invoice: await createDbInvoice(actionType, args, context, invoiceArgs),
paymentMethod: 'PESSIMISTIC'
}
}

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
Expand All @@ -178,7 +200,7 @@ export async function retryPaidAction (actionType, args, context) {
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}`)
}

Expand Down Expand Up @@ -226,53 +248,57 @@ export async function retryPaidAction (actionType, args, context) {
const INVOICE_EXPIRE_SECS = 600
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100

export async function createLightningInvoice (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)

// count pending invoices and bail if we're over the limit
export async function assertBelowMaxPendingInvoices (context) {
const { models, me } = context
const pendingInvoices = await models.invoice.count({
where: {
userId: me?.id ?? USER_ID.anon,
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 (userId) {
try {
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 { invoice: wrappedInvoice, maxFee } = await wrapInvoice(
bolt11, { msats: cost, description }, { lnd })
export class NonInvoiceablePeerError extends Error {
constructor () {
super('non invoiceable peer')
this.name = 'NonInvoiceablePeerError'
}
}

return {
bolt11,
wrappedBolt11: wrappedInvoice.request,
wallet,
maxFee
}
} catch (e) {
console.error('failed to create stacker invoice, falling back to SN invoice', e)
}
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 } = context
const userId = await paidActions[actionType]?.invoiceablePeer?.(args, context)
if (!userId) {
throw new NonInvoiceablePeerError()
}

return await createSNInvoice(actionType, args, context)
await assertBelowMaxPendingInvoices(context)

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 { invoice: wrappedInvoice, maxFee } = await wrapInvoice(
bolt11, { msats: cost, description }, { lnd })

return {
bolt11,
wrappedBolt11: wrappedInvoice.request,
wallet,
maxFee
}
}

// we seperate the invoice creation into two functions because
Expand Down
Loading
Loading