Skip to content

remodel paid actions schema #2084

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

Draft
wants to merge 15 commits 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
371 changes: 371 additions & 0 deletions api/payIn/README.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions api/payIn/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class PayInFailureReasonError extends Error {
constructor (message, payInFailureReason) {
super(message)
this.name = 'PayInFailureReasonError'
this.payInFailureReason = payInFailureReason
}
}
263 changes: 263 additions & 0 deletions api/payIn/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, USER_ID } from '@/lib/constants'
import { Prisma } from '@prisma/client'
import { payViaPaymentRequest } from 'ln-service'
import lnd from '../lnd'
import payInTypeModules from './types'
import { msatsToSats } from '@/lib/format'
import { payInBolt11Prospect, payInBolt11WrapProspect } from './lib/payInBolt11'
import { isPessimistic, isWithdrawal } from './lib/is'
import { PAY_IN_INCLUDE, payInCreate } from './lib/payInCreate'
import { payOutBolt11Replacement } from './lib/payOutBolt11'
import { payInClone } from './lib/payInPrisma'
import { PayInFailureReasonError } from './errors'

export default async function pay (payInType, payInArgs, { models, me }) {
try {
const payInModule = payInTypeModules[payInType]

console.group('payIn', payInType, payInArgs)

if (!payInModule) {
throw new Error(`Invalid payIn type ${payInType}`)
}

if (!me && !payInModule.anonable) {
throw new Error('You must be logged in to perform this action')
}

// need to double check all old usage of !me for detecting anon users
me ??= { id: USER_ID.anon }

const payIn = await payInModule.getInitial(models, payInArgs, { me })
return await begin(models, payIn, payInArgs, { me })
} catch (e) {
console.error('performPaidAction failed', e)
throw e
} finally {
console.groupEnd()
}
}

async function begin (models, payInInitial, payInArgs, { me }) {
const payInModule = payInTypeModules[payInInitial.payInType]

const { payIn, mCostRemaining } = await models.$transaction(async tx => {
const { payIn, mCostRemaining } = await payInCreate(tx, payInInitial, { me })

// if it's pessimistic, we don't perform the action until the invoice is held
if (payIn.pessimisticEnv) {
return {
payIn,
mCostRemaining
}
}

// if it's optimistic or already paid, we perform the action
await payInModule.onBegin?.(tx, payIn.id, payInArgs, { models, me })

// if it's already paid, we run onPaid and do payOuts in the same transaction
if (payIn.payInState === 'PAID') {
await onPaid(tx, payIn.id, { me })
return {
payIn,
mCostRemaining: 0n
}
}

tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority)
VALUES ('checkPayIn', jsonb_build_object('payInId', ${payIn.id}::INTEGER), now() + INTERVAL '30 seconds', 1000)`
return {
payIn,
mCostRemaining
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })

return await afterBegin(models, { payIn, mCostRemaining }, { me })
}

async function afterBegin (models, { payIn, mCostRemaining }, { me }) {
async function afterInvoiceCreation ({ payInState, payInBolt11 }) {
return await models.payIn.update({
where: {
id: payIn.id,
payInState: { in: ['PENDING_INVOICE_CREATION', 'PENDING_INVOICE_WRAP'] }
},
data: {
payInState,
payInStateChangedAt: new Date(),
payInBolt11: {
create: payInBolt11
}
},
include: PAY_IN_INCLUDE
})
}

try {
if (payIn.payInState === 'PAID') {
payInTypeModules[payIn.payInType].onPaidSideEffects?.(models, payIn.id).catch(console.error)
} else if (payIn.payInState === 'PENDING_INVOICE_CREATION') {
const payInBolt11 = await payInBolt11Prospect(models, payIn, { msats: mCostRemaining })
return await afterInvoiceCreation({
payInState: payIn.pessimisticEnv ? 'PENDING_HELD' : 'PENDING',
payInBolt11
})
} else if (payIn.payInState === 'PENDING_INVOICE_WRAP') {
const payInBolt11 = await payInBolt11WrapProspect(models, payIn, { msats: mCostRemaining })
return await afterInvoiceCreation({
payInState: 'PENDING_HELD',
payInBolt11
})
} else if (payIn.payInState === 'PENDING_WITHDRAWAL') {
const { mtokens } = payIn.payOutCustodialTokens.find(t => t.payOutType === 'ROUTING_FEE')
payViaPaymentRequest({
lnd,
request: payIn.payOutBolt11.bolt11,
max_fee: msatsToSats(mtokens),
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS,
confidence: LND_PATHFINDING_TIME_PREF_PPM
}).catch(console.error)
} else {
throw new Error('Invalid payIn begin state')
}
} catch (e) {
let payInFailureReason = 'EXECUTION_FAILED'
if (e instanceof PayInFailureReasonError) {
payInFailureReason = e.payInFailureReason
}
models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority)
VALUES ('payInFailed', jsonb_build_object('payInId', ${payIn.id}::INTEGER, 'payInFailureReason', ${payInFailureReason}), now(), 1000)`.catch(console.error)
throw e
}

return payIn
}

export async function onFail (tx, payInId) {
const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payInCustodialTokens: true, beneficiaries: true } })
if (!payIn) {
throw new Error('PayIn not found')
}

// refund the custodial tokens
for (const payInCustodialToken of payIn.payInCustodialTokens) {
await tx.$queryRaw`
UPDATE users
SET msats = msats + ${payInCustodialToken.custodialTokenType === 'SATS' ? payInCustodialToken.mtokens : 0},
mcredits = mcredits + ${payInCustodialToken.custodialTokenType === 'CREDITS' ? payInCustodialToken.mtokens : 0}
WHERE id = ${payIn.userId}`
}

await payInTypeModules[payIn.payInType].onFail?.(tx, payInId)
for (const beneficiary of payIn.beneficiaries) {
await onFail(tx, beneficiary.id)
}
}

export async function onPaid (tx, payInId) {
const payIn = await tx.payIn.findUnique({ where: { id: payInId }, include: { payOutCustodialTokens: true, payOutBolt11: true, beneficiaries: true } })
if (!payIn) {
throw new Error('PayIn not found')
}

for (const payOut of payIn.payOutCustodialTokens) {
// if the payOut is not for a user, it's a system payOut
if (!payOut.userId) {
continue
}
await tx.$queryRaw`
WITH user AS (
UPDATE users
SET msats = msats + ${payOut.custodialTokenType === 'SATS' ? payOut.mtokens : 0},
"stackedMsats" = "stackedMsats" + ${!isWithdrawal(payIn) ? payOut.mtokens : 0},
mcredits = mcredits + ${payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0},
"stackedMcredits" = "stackedMcredits" + ${!isWithdrawal(payIn) && payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0}
FROM (SELECT id, mcredits, msats FROM users WHERE id = ${payOut.userId} FOR UPDATE) before
WHERE users.id = before.id
RETURNING before.mcredits as mcreditsBefore, before.msats as msatsBefore
)
UPDATE "PayOutCustodialToken"
SET "msatsBefore" = user.msatsBefore, "mcreditsBefore" = user.mcreditsBefore
FROM user
WHERE "id" = ${payOut.userId}`
}

if (!isWithdrawal(payIn)) {
if (payIn.payOutBolt11) {
await tx.$queryRaw`
UPDATE users
SET msats = msats + ${payIn.payOutBolt11.msats},
"stackedMsats" = "stackedMsats" + ${payIn.payOutBolt11.msats}
WHERE id = ${payIn.payOutBolt11.userId}`
}

// most paid actions are eligible for a cowboy hat streak
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data)
VALUES ('checkStreak', jsonb_build_object('id', ${payIn.userId}, 'type', 'COWBOY_HAT'))`
}

const payInModule = payInTypeModules[payIn.payInType]
await payInModule.onPaid?.(tx, payInId)
for (const beneficiary of payIn.beneficiaries) {
await onPaid(tx, beneficiary.id)
}
}

export async function retry (payInId, { models, me }) {
const include = { payOutCustodialTokens: true, payOutBolt11: true }
const where = { id: payInId, userId: me.id, payInState: 'FAILED', successorId: { is: null } }

const payInFailed = await models.payIn.findUnique({
where,
include: { ...include, beneficiaries: { include } }
})
if (!payInFailed) {
throw new Error('PayIn not found')
}
if (isWithdrawal(payInFailed)) {
throw new Error('Withdrawal payIns cannot be retried')
}
if (isPessimistic(payInFailed, { me })) {
throw new Error('Pessimistic payIns cannot be retried')
}

let payOutBolt11
if (payInFailed.payOutBolt11) {
payOutBolt11 = await payOutBolt11Replacement(models, payInFailed.genesisId ?? payInFailed.id, payInFailed.payOutBolt11)
}

const { payIn, mCostRemaining } = await models.$transaction(async tx => {
const { payIn, mCostRemaining } = await payInCreate(tx, payInClone({ ...payInFailed, payOutBolt11 }), { me })

// use an optimistic lock on successorId on the payIn
await tx.payIn.update({
where,
data: {
successorId: payIn.id
}
})

// run the onRetry hook for the payIn and its beneficiaries
await payInTypeModules[payIn.payInType].onRetry?.(tx, payInFailed.id, payIn.id)
for (const beneficiary of payIn.beneficiaries) {
await payInTypeModules[beneficiary.payInType].onRetry?.(tx, beneficiary.id, payIn.id)
}

// if it's already paid, we run onPaid and do payOuts in the same transaction
if (payIn.payInState === 'PAID') {
await onPaid(tx, payIn.id, { me })
return {
payIn,
mCostRemaining: 0n
}
}

return {
payIn,
mCostRemaining
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })

return await afterBegin(models, { payIn, mCostRemaining }, { me })
}
15 changes: 15 additions & 0 deletions api/payIn/lib/assert.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const MAX_PENDING_PAY_IN_BOLT_11_PER_USER = 100

export async function assertBelowMaxPendingPayInBolt11s (models, userId) {
const pendingBolt11s = await models.payInBolt11.count({
where: {
userId,
confirmedAt: null,
cancelledAt: null
}
})

if (pendingBolt11s >= MAX_PENDING_PAY_IN_BOLT_11_PER_USER) {
throw new Error('You have too many pending paid actions, cancel some or wait for them to expire')
}
}
40 changes: 40 additions & 0 deletions api/payIn/lib/is.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { payInTypeModules } from '../types'

export const PAY_IN_RECEIVER_FAILURE_REASONS = [
'INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_FEE',
'INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_EXPIRY',
'INVOICE_WRAPPING_FAILED_UNKNOWN',
'INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW',
'INVOICE_FORWARDING_FAILED'
]

export function isPessimistic (payIn, { me }) {
const payInModule = payInTypeModules[payIn.payInType]
return !me || !payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)
}

export function isPayableWithCredits (payIn) {
const payInModule = payInTypeModules[payIn.payInType]
return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT)
}

export function isInvoiceable (payIn) {
const payInModule = payInTypeModules[payIn.payInType]
return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) ||
payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) ||
payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P)
}

export function isP2P (payIn) {
const payInModule = payInTypeModules[payIn.payInType]
return payInModule.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.P2P)
}

export function isWithdrawal (payIn) {
return payIn.payInType === 'WITHDRAWAL' || payIn.payInType === 'AUTO_WITHDRAWAL'
}

export function isReceiverFailure (payInFailureReason) {
return PAY_IN_RECEIVER_FAILURE_REASONS.includes(payInFailureReason)
}
Loading