Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
88 commits
Select commit Hold shift + click to select a range
7e93200
basic wip schema additions
huumn Apr 8, 2025
366c069
WIP execution logic
huumn Apr 13, 2025
5f7ad5e
invoice wrapping/creation flow
huumn Apr 18, 2025
e7c93f4
conceptually working state machine
huumn Apr 21, 2025
a81b4b8
Merge branch 'master' into remodel
huumn Apr 22, 2025
c5575a1
Merge branch 'master' into remodel
huumn Apr 24, 2025
396d643
lots of state machine work
huumn Apr 22, 2025
c1c8380
more declaritive payIn modules
huumn Apr 24, 2025
cd03533
Merge branch 'master' into remodel
huumn Apr 25, 2025
1f076e0
refactor zap
huumn Apr 25, 2025
8c69ef3
wip approach to retries
huumn Apr 26, 2025
27c3615
decent retry logic
huumn Apr 28, 2025
b5f8aa8
conceptually coherent modules and retry logic
huumn Apr 30, 2025
deb10bf
Merge branch 'master' into remodel
huumn Apr 30, 2025
75c93f6
Merge branch 'master' into remodel
huumn May 15, 2025
a47f24a
wip
huumn May 20, 2025
e9423b9
Merge branch 'master' into remodel
huumn May 26, 2025
da4ff28
wip
huumn May 27, 2025
68709ed
merge master
huumn Jun 24, 2025
e1939d2
wip
huumn Jul 4, 2025
2bbfe5d
merge master
huumn Jul 22, 2025
ff0f7b3
merge master
huumn Jul 24, 2025
a9460e1
some work on clientside payins
huumn Jul 27, 2025
64bb7bf
make custodial purchase of cowboy credits work
huumn Jul 29, 2025
8bd337a
make custodial donations work
huumn Jul 29, 2025
843ceaf
Merge branch 'master' into remodel
huumn Jul 30, 2025
c2252ae
working intitialization for proxy payments
huumn Jul 31, 2025
94290d2
Merge branch 'master' into remodel
huumn Jul 31, 2025
32f1d56
wip payIn worker parts
huumn Aug 2, 2025
71829fd
fixes to state machine
huumn Aug 2, 2025
2e5f3ff
more concise error block for invoice creation
huumn Aug 3, 2025
49e63c0
merge master
huumn Aug 3, 2025
61193a6
successful proxy payment
huumn Aug 3, 2025
42a9e7b
Merge branch 'master' into remodel
huumn Aug 5, 2025
843cd4b
custodial item creation
huumn Aug 5, 2025
0cd4260
small improvements to item payins
huumn Aug 6, 2025
5ed7b50
Merge branch 'master' into remodel
huumn Aug 6, 2025
4e7624c
make item update action work
huumn Aug 7, 2025
09e6a3a
make vanilla custodial zaps not fail
huumn Aug 7, 2025
04eb2d7
make custodial downzaps work
huumn Aug 8, 2025
333b400
Merge branch 'master' into remodel
huumn Aug 8, 2025
51d6757
Merge branch 'master' into remodel
huumn Aug 8, 2025
4527adb
custodial boost without benefactor + share payout logic
huumn Aug 9, 2025
b75e631
update untested payIns with new abstractions
huumn Aug 9, 2025
009742e
add todo about dealing with row-level deadlocks
huumn Aug 9, 2025
d5a1d04
add another option for dealing with row-level deadlocks
huumn Aug 9, 2025
eaa56e3
more comments about deadlocks
huumn Aug 9, 2025
b186f0d
merge master
huumn Aug 9, 2025
589de9a
add initial solutions for payIn deadlocks
huumn Aug 10, 2025
131583d
Merge branch 'master' into remodel
huumn Aug 10, 2025
cee4734
custodial poll vote pay in (+ post creation)
huumn Aug 10, 2025
c96eb14
invite gift pay in works
huumn Aug 10, 2025
5e89765
custodial territory create/update payIn working
huumn Aug 10, 2025
dfe9442
territory unarchive
huumn Aug 12, 2025
8d1330d
territory billing works
huumn Aug 12, 2025
d8ff7d9
improve withdrawals
huumn Aug 12, 2025
806b1c1
squash db migrations
huumn Aug 12, 2025
7d12f0a
Merge branch 'master' into remodel
huumn Aug 13, 2025
2d83ca6
make autowithdraw work
huumn Aug 13, 2025
3a97d2a
bolt11 drop through payOutBolt11
huumn Aug 13, 2025
8206f77
pessimistic qr payment happy path working
huumn Aug 14, 2025
add7e62
improve invoice status
huumn Aug 14, 2025
5f65ef2
fix optimistic responses for payIn
huumn Aug 14, 2025
e4ae357
fix boost describe
huumn Aug 14, 2025
65b8c71
fix itemCreate describe
huumn Aug 14, 2025
2f1a8b6
cancel payInBolt11
huumn Aug 14, 2025
9673327
pessimistic item creation and anon create/edits
huumn Aug 15, 2025
3d4bc55
migrate cron bolt11 check
huumn Aug 15, 2025
79dea61
Merge branch 'master' into remodel
huumn Aug 15, 2025
a1f1522
Merge branch 'master' into remodel
huumn Aug 17, 2025
1a47f8b
payment state presentation
huumn Aug 18, 2025
44aac9e
immutable-ish custodial retries for item creation - manual with funct…
huumn Aug 19, 2025
852a443
fix edit timer
huumn Aug 19, 2025
525c63d
wip non-custodial retries
huumn Aug 20, 2025
6505645
improvements and fixes to manual retries
huumn Aug 20, 2025
d51126e
fix pessimistic actions
huumn Aug 20, 2025
5e9eed4
convert poll vote to pessimistic payIn
huumn Aug 20, 2025
0dc571e
fixes for poll voting and pessimistic payIns
huumn Aug 20, 2025
c018b62
notification dot for failed payIns
huumn Aug 20, 2025
a5ad6ec
resolver/worker upgrade to payins (excepting satistics and wallets pa…
huumn Aug 21, 2025
a8a3427
fix comment recent sorting
huumn Aug 21, 2025
620227e
wip satsstics
huumn Aug 24, 2025
e1327fa
mtokens after rather than before
huumn Aug 25, 2025
bbdffef
format cost differently
huumn Aug 25, 2025
f710799
more UI improvements
huumn Aug 25, 2025
8bb8478
merge master
huumn Aug 26, 2025
d3cf479
fix commenting cache update
huumn Aug 26, 2025
f2afa24
basic transaction page
huumn Aug 26, 2025
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
5 changes: 1 addition & 4 deletions api/lnd/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { cachedFetcher } from '@/lib/fetch'
import { toPositiveNumber } from '@/lib/format'
import { authenticatedLndGrpc } from '@/lib/lnd'
import { getIdentity, getHeight, getWalletInfo, getNode, getPayment, parsePaymentRequest } from 'ln-service'
import { datePivot } from '@/lib/time'
import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'

const lnd = global.lnd || authenticatedLndGrpc({
cert: process.env.LND_CERT,
Expand Down Expand Up @@ -189,8 +187,7 @@ export async function getPaymentOrNotSent ({ id, lnd, createdAt }) {
try {
return await getPayment({ id, lnd })
} catch (err) {
if (err[1] === 'SentPaymentNotFound' &&
createdAt < datePivot(new Date(), { milliseconds: -LND_PATHFINDING_TIMEOUT_MS * 2 })) {
if (err[1] === 'SentPaymentNotFound') {
// if the payment is older than 2x timeout, but not found in LND, we can assume it errored before lnd stored it
return { notSent: true, is_failed: true }
} else {
Expand Down
1 change: 1 addition & 0 deletions api/paidAction/territoryUpdate.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx }
})
}

// TODO: this is nasty
await throwOnExpiredUploads(data.uploadIds, { tx })
if (data.uploadIds.length > 0) {
await tx.upload.updateMany({
Expand Down
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
}
}
341 changes: 341 additions & 0 deletions api/payIn/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,341 @@
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 { NoReceiveWalletError, payOutBolt11Replacement } from './lib/payOutBolt11'
import { payInClone } from './lib/payInPrisma'
import { createHmac } from '../resolvers/wallet'

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

// TODO: 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('payIn failed', e)
throw e
} finally {
console.groupEnd()
}
}

// we lock all users in the payIn in order to avoid deadlocks with other payIns
// that might be competing to update the same users, e.g. two users simultaneously zapping each other
// https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
// alternative approaches:
// 1. do NOT lock all users, but use NOWAIT on users locks so that we can catch AND retry transactions that fail with a deadlock error
// 2. issue onPaid in a separate transaction, so that payInCustodialTokens and payOutCustodialTokens cannot be interleaved
async function obtainRowLevelLocks (tx, payInInitial) {
console.log('obtainRowLevelLocks', payInInitial)
const payOutUserIds = [...new Set(payInInitial.payOutCustodialTokens.map(t => t.userId)).add(payInInitial.userId)]
await tx.$executeRaw`SELECT * FROM users WHERE id IN (${Prisma.join(payOutUserIds)}) ORDER BY id ASC FOR UPDATE`
}

async function begin (models, payInInitial, payInArgs, { me }) {
const { payIn, result, mCostRemaining } = await models.$transaction(async tx => {
await obtainRowLevelLocks(tx, payInInitial)
const { payIn, mCostRemaining } = await payInCreate(tx, payInInitial, payInArgs, { 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
const result = await onBegin(tx, payIn.id, payInArgs)

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

// TODO: create a job for this
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,
result,
mCostRemaining
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })

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

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

const result = await payInTypeModules[payIn.payInType].onBegin?.(tx, payIn.id, payInArgs)

for (const beneficiary of payIn.beneficiaries) {
await onBegin(tx, beneficiary.id, payInArgs, result)
}

return result
}

async function afterBegin (models, { payIn, result, mCostRemaining }, { me }) {
console.log('afterBegin', result, mCostRemaining)
async function afterInvoiceCreation ({ payInState, payInBolt11 }) {
const updatedPayIn = 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
})
// the HMAC is only returned during invoice creation
// this makes sure that only the person who created this invoice
// has access to the HMAC
updatedPayIn.payInBolt11.hmac = createHmac(updatedPayIn.payInBolt11.hash)
return { ...updatedPayIn, result: result ? { ...result, payIn: updatedPayIn } : undefined }
}

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, description: await payInTypeModules[payIn.payInType].describe(models, payIn.id) })
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, description: await payInTypeModules[payIn.payInType].describe(models, payIn.id) })
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) {
models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority)
VALUES (
'payInFailed',
jsonb_build_object(
'payInId', ${payIn.id}::INTEGER,
'payInFailureReason', ${e.payInFailureReason ?? 'EXECUTION_FAILED'}),
now(), 1000)`.catch(console.error)
throw e
}

return { ...payIn, result: result ? { ...result, payIn } : undefined }
}

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.$executeRaw`
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 are ordered by userId, so that we can lock all users FOR UPDATE in order
// https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
payOutCustodialTokens: {
orderBy: { userId: 'asc' }
},
payOutBolt11: true,
beneficiaries: true,
pessimisticEnv: 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.$executeRaw`
WITH outuser AS (
UPDATE users
SET msats = users.msats + ${payOut.custodialTokenType === 'SATS' ? payOut.mtokens : 0},
"stackedMsats" = users."stackedMsats" + ${!isWithdrawal(payIn) ? payOut.mtokens : 0},
mcredits = users.mcredits + ${payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0},
"stackedMcredits" = users."stackedMcredits" + ${!isWithdrawal(payIn) && payOut.custodialTokenType === 'CREDITS' ? payOut.mtokens : 0}
WHERE users.id = ${payOut.userId}
RETURNING mcredits as "mcreditsAfter", msats as "msatsAfter"
)
UPDATE "PayOutCustodialToken"
SET "mtokensAfter" = CASE WHEN "custodialTokenType" = 'SATS' THEN outuser."msatsAfter" ELSE outuser."mcreditsAfter" END
FROM outuser
WHERE "id" = ${payOut.id}`
}

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 onPaidSideEffects (models, payInId) {
const payIn = await models.payIn.findUnique({ where: { id: payInId }, include: { beneficiaries: true } })
if (!payIn) {
throw new Error('PayIn not found')
}

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

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

const payInFailed = await models.payIn.findFirst({
where,
include: { ...include, beneficiaries: { include } }
})
if (!payInFailed) {
throw new Error('PayIn with id ' + payInId + ' 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')
}

// TODO: if we can't produce a new payOutBolt11, we need to update the payOuts to use
// custodial tokens instead ... or don't clone the payIn, and just start from scratch
let payOutBolt11
if (payInFailed.payOutBolt11) {
try {
payOutBolt11 = await payOutBolt11Replacement(models, payInFailed.genesisId ?? payInFailed.id, payInFailed.payOutBolt11)
} catch (e) {
console.error('payOutBolt11Replacement failed', e)
if (!(e instanceof NoReceiveWalletError)) {
throw e
}
}
}

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

// use an optimistic lock on successorId on the payIn
const rows = await tx.$queryRaw`UPDATE "PayIn" SET "successorId" = ${payIn.id} WHERE "id" = ${payInFailed.id} AND "successorId" IS NULL RETURNING *`
if (rows.length === 0) {
throw new Error('PayIn with id ' + payInFailed.id + ' is already being retried')
}

// run the onRetry hook for the payIn and its beneficiaries
const result = 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,
result,
mCostRemaining: 0n
}
}

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

return await afterBegin(models, { payIn, result, mCostRemaining }, { me })
} catch (e) {
console.error('retry failed', e)
throw e
}
}
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')
}
}
Loading