improve pessimistic paid actions by letting the server perform actions and settle invoice on `HELD` (#1253)

* get rid of hash and hmac based pessimism

* fix readme
This commit is contained in:
Keyan 2024-07-04 12:30:42 -05:00 committed by GitHub
parent 7c294478fb
commit 79f0df17b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 295 additions and 252 deletions

View File

@ -25,7 +25,7 @@ For paid actions that support it, if the stacker doesn't have enough fee credits
</details> </details>
### Pessimistic ### Pessimistic
For paid actions that don't support optimistic actions (or when the stacker is `@anon`), if the client doesn't have enough fee credits, we return a payment request to the client without storing the action. After the client pays the invoice, the client resends the action with proof of payment and action is executed fully. Pessimistic actions require the client to wait for the payment to complete before being visible to them and everyone else. For paid actions that don't support optimistic actions (or when the stacker is `@anon`), if the client doesn't have enough fee credits, we return a payment request to the client without performing the action and only storing the action's arguments. After the client pays the invoice, the server performs the action with original arguments. Pessimistic actions require the payment to complete before being visible to them and everyone else.
Internally, pessimistic flows use hold invoices. If the action doesn't succeed, the payment is cancelled and it's as if the payment never happened (ie it's a lightning native refund mechanism). Internally, pessimistic flows use hold invoices. If the action doesn't succeed, the payment is cancelled and it's as if the payment never happened (ie it's a lightning native refund mechanism).
@ -34,8 +34,8 @@ Internally, pessimistic flows use hold invoices. If the action doesn't succeed,
Internally, pessimistic flows make use of a state machine that's transitioned by the invoice payment progress much like optimistic flows, but with extra steps. All pessimistic actions start in a `PENDING_HELD` state and has the following transitions: Internally, pessimistic flows make use of a state machine that's transitioned by the invoice payment progress much like optimistic flows, but with extra steps. All pessimistic actions start in a `PENDING_HELD` state and has the following transitions:
- `PENDING_HELD` -> `HELD`: when the invoice is paid, but the action is not yet executed - `PENDING_HELD` -> `HELD`: when the invoice is paid and the action's `perform` is run and the invoice is settled
- `HELD` -> `PAID`: when the invoice is paid - `HELD` -> `PAID`: when the action's `onPaid` is called
- `PENDING_HELD` -> `FAILED`: when the invoice for the action expires or is cancelled - `PENDING_HELD` -> `FAILED`: when the invoice for the action expires or is cancelled
- `HELD` -> `FAILED`: when the action fails after the invoice is paid - `HELD` -> `FAILED`: when the action fails after the invoice is paid
</details> </details>
@ -92,7 +92,7 @@ All functions have the following signature: `function(args: Object, context: Obj
`args` contains the arguments for the action as defined in the `graphql` schema. If the action is optimistic or pessimistic, `args` will contain an `invoiceId` field which can be stored alongside the paid action's data. If this is a call to `retry`, `args` will contain the original `invoiceId` and `newInvoiceId` fields. `args` contains the arguments for the action as defined in the `graphql` schema. If the action is optimistic or pessimistic, `args` will contain an `invoiceId` field which can be stored alongside the paid action's data. If this is a call to `retry`, `args` will contain the original `invoiceId` and `newInvoiceId` fields.
`context` contains the following fields: `context` contains the following fields:
- `user`: the user performing the action (null if anonymous) - `me`: the user performing the action (undefined if anonymous)
- `cost`: the cost of the action in msats as a `BigInt` - `cost`: the cost of the action in msats as a `BigInt`
- `tx`: the current transaction (for anything that needs to be done atomically with the payment) - `tx`: the current transaction (for anything that needs to be done atomically with the payment)
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment) - `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)

View File

@ -1,9 +1,8 @@
import { createHodlInvoice, createInvoice, settleHodlInvoice } from 'ln-service' import { createHodlInvoice, createInvoice } from 'ln-service'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import { createHmac } from '../resolvers/wallet' import { createHmac } from '../resolvers/wallet'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { timingSafeEqual } from 'crypto'
import * as ITEM_CREATE from './itemCreate' import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate' import * as ITEM_UPDATE from './itemUpdate'
import * as ZAP from './zap' import * as ZAP from './zap'
@ -30,7 +29,7 @@ export const paidActions = {
export default async function performPaidAction (actionType, args, context) { export default async function performPaidAction (actionType, args, context) {
try { try {
const { me, models, hash, hmac, forceFeeCredits } = context const { me, models, forceFeeCredits } = context
const paidAction = paidActions[actionType] const paidAction = paidActions[actionType]
console.group('performPaidAction', actionType, args) console.group('performPaidAction', actionType, args)
@ -39,18 +38,19 @@ export default async function performPaidAction (actionType, args, context) {
throw new Error(`Invalid action type ${actionType}`) throw new Error(`Invalid action type ${actionType}`)
} }
if (!me && !paidAction.anonable) { context.me = me ? await models.user.findUnique({ where: { id: me.id } }) : undefined
throw new Error('You must be logged in to perform this action')
}
context.user = me ? await models.user.findUnique({ where: { id: me.id } }) : null
context.cost = await paidAction.getCost(args, context) context.cost = await paidAction.getCost(args, context)
if (hash || hmac || !me) {
console.log('hash or hmac provided, or anon, performing pessimistic action') if (!me) {
if (!paidAction.anonable) {
throw new Error('You must be logged in to perform this action')
}
console.log('we are anon so can only perform pessimistic action')
return await performPessimisticAction(actionType, args, context) return await performPessimisticAction(actionType, args, context)
} }
const isRich = context.cost <= context.user.msats const isRich = context.cost <= context.me.msats
if (isRich) { if (isRich) {
try { try {
console.log('enough fee credits available, performing fee credit action') console.log('enough fee credits available, performing fee credit action')
@ -62,34 +62,25 @@ export default async function performPaidAction (actionType, args, context) {
if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) { if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
throw e throw e
} }
// if we fail to do the action with fee credits, we should fall back to optimistic
if (!paidAction.supportsOptimism) {
console.error('action does not support optimism and fee credits failed, performing pessimistic action')
return await performPessimisticAction(actionType, args, context)
}
}
} else {
// 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 (!paidAction.supportsOptimism) {
console.log('not enough fee credits available, optimism not supported, performing pessimistic action')
return await performPessimisticAction(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) { if (paidAction.supportsOptimism) {
console.log('performing optimistic action') console.log('performing optimistic action')
return await performOptimisticAction(actionType, args, context) return await performOptimisticAction(actionType, args, context)
} }
throw new Error(`This action ${actionType} could not be done`) 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
@ -147,39 +138,17 @@ async function performOptimisticAction (actionType, args, context) {
} }
async function performPessimisticAction (actionType, args, context) { async function performPessimisticAction (actionType, args, context) {
const { models, lnd } = context
const action = paidActions[actionType] const action = paidActions[actionType]
if (!action.supportsPessimism) { if (!action.supportsPessimism) {
throw new Error(`This action ${actionType} does not support pessimistic invoicing`) throw new Error(`This action ${actionType} does not support pessimistic invoicing`)
} }
if (context.hmac) { // just create the invoice and complete action when it's paid
return await models.$transaction(async tx => { context.lndInvoice = await createLndInvoice(actionType, args, context)
context.tx = tx return {
invoice: await createDbInvoice(actionType, args, context),
// make sure the invoice is HELD paymentMethod: 'PESSIMISTIC'
const invoice = await verifyPayment(context)
args.invoiceId = invoice.id
// make sure to perform before settling so we don't race with worker to onPaid
const result = await action.perform(args, context)
// XXX this might cause the interactive tx to time out
await settleHodlInvoice({ secret: invoice.preimage, lnd })
return {
result,
paymentMethod: 'PESSIMISTIC'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
} else {
// just create the invoice and complete action when it's paid
context.lndInvoice = await createLndInvoice(actionType, args, context)
return {
invoice: await createDbInvoice(actionType, args, context),
paymentMethod: 'PESSIMISTIC'
}
} }
} }
@ -209,7 +178,7 @@ export async function retryPaidAction (actionType, args, context) {
} }
context.optimistic = true context.optimistic = true
context.user = await models.user.findUnique({ where: { id: me.id } }) context.me = await models.user.findUnique({ where: { id: me.id } })
const { msatsRequested } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } }) const { msatsRequested } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
context.cost = BigInt(msatsRequested) context.cost = BigInt(msatsRequested)
@ -248,7 +217,7 @@ const PESSIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
// we seperate the invoice creation into two functions because // we seperate the invoice creation into two functions because
// because if lnd is slow, it'll timeout the interactive tx // because if lnd is slow, it'll timeout the interactive tx
async function createLndInvoice (actionType, args, context) { async function createLndInvoice (actionType, args, context) {
const { user, lnd, cost, optimistic } = context const { me, lnd, cost, optimistic } = context
const action = paidActions[actionType] const action = paidActions[actionType]
const [createLNDInvoice, expirePivot] = optimistic const [createLNDInvoice, expirePivot] = optimistic
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE] ? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE]
@ -261,7 +230,7 @@ async function createLndInvoice (actionType, args, context) {
const expiresAt = datePivot(new Date(), expirePivot) const expiresAt = datePivot(new Date(), expirePivot)
return await createLNDInvoice({ return await createLNDInvoice({
description: user?.hideInvoiceDesc ? undefined : await action.describe(args, context), description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context),
lnd, lnd,
mtokens: String(cost), mtokens: String(cost),
expires_at: expiresAt expires_at: expiresAt
@ -269,7 +238,7 @@ async function createLndInvoice (actionType, args, context) {
} }
async function createDbInvoice (actionType, args, context) { async function createDbInvoice (actionType, args, context) {
const { user, models, tx, lndInvoice, cost, optimistic, actionId } = context const { me, models, tx, lndInvoice, cost, optimistic, actionId } = context
const db = tx ?? models const db = tx ?? models
const [expirePivot, actionState] = optimistic const [expirePivot, actionState] = optimistic
? [OPTIMISTIC_INVOICE_EXPIRE, 'PENDING'] ? [OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
@ -287,9 +256,10 @@ async function createDbInvoice (actionType, args, context) {
msatsRequested: cost, msatsRequested: cost,
preimage: optimistic ? undefined : lndInvoice.secret, preimage: optimistic ? undefined : lndInvoice.secret,
bolt11: lndInvoice.request, bolt11: lndInvoice.request,
userId: user?.id || USER_ID.anon, userId: me?.id ?? USER_ID.anon,
actionType, actionType,
actionState, actionState,
actionArgs: args,
expiresAt, expiresAt,
actionId actionId
} }
@ -310,35 +280,3 @@ async function createDbInvoice (actionType, args, context) {
return invoice return invoice
} }
export async function verifyPayment ({ hash, hmac, models, cost }) {
if (!hash) {
throw new Error('hash required')
}
if (!hmac) {
throw new Error('hmac required')
}
const hmac2 = createHmac(hash)
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
throw new Error('hmac invalid')
}
const invoice = await models.invoice.findUnique({
where: {
hash,
actionState: 'HELD'
}
})
if (!invoice) {
throw new Error('invoice not found')
}
if (invoice.msatsReceived < cost) {
throw new Error('invoice amount too low')
}
return invoice
}

View File

@ -7,22 +7,22 @@ export const anonable = true
export const supportsPessimism = true export const supportsPessimism = true
export const supportsOptimism = true export const supportsOptimism = true
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, user }) { 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 } })
const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + image fees + boost // cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + image fees + boost
const [{ cost }] = await models.$queryRaw` const [{ cost }] = await models.$queryRaw`
SELECT ${baseCost}::INTEGER SELECT ${baseCost}::INTEGER
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${user?.id || USER_ID.anon}::INTEGER, * POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER,
${user?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL)) ${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
* ${user ? 1 : 100}::INTEGER * ${me ? 1 : 100}::INTEGER
+ (SELECT "nUnpaid" * "imageFeeMsats" + (SELECT "nUnpaid" * "imageFeeMsats"
FROM image_fees_info(${user?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[])) FROM image_fees_info(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost` + ${satsToMsats(boost)}::INTEGER as cost`
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon, and cost must be greater than user's balance // sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon, and cost must be greater than user's balance
const freebie = (parentId || bio || sub?.allowFreebies) && cost <= baseCost && !!user && cost > user?.msats const freebie = (parentId || bio || sub?.allowFreebies) && cost <= baseCost && !!me && cost > me?.msats
return freebie ? BigInt(0) : BigInt(cost) return freebie ? BigInt(0) : BigInt(cost)
} }

View File

@ -27,8 +27,8 @@ export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, c
const acts = await tx.itemAct.createManyAndReturn({ const acts = await tx.itemAct.createManyAndReturn({
data: [ data: [
{ msats: feeMsats, itemId, userId: me?.id || USER_ID.anon, act: 'FEE', ...invoiceData }, { msats: feeMsats, itemId, userId: me?.id ?? USER_ID.anon, act: 'FEE', ...invoiceData },
{ msats: zapMsats, itemId, userId: me?.id || USER_ID.anon, act: 'TIP', ...invoiceData } { msats: zapMsats, itemId, userId: me?.id ?? USER_ID.anon, act: 'TIP', ...invoiceData }
] ]
}) })

View File

@ -738,34 +738,34 @@ export default {
return await deleteItemByAuthor({ models, id, item: old }) return await deleteItemByAuthor({ models, id, item: old })
}, },
upsertLink: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { upsertLink: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(linkSchema, item, { models, me }) await ssValidate(linkSchema, item, { models, me })
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac }) return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else { } else {
return await createItem(parent, item, { me, models, lnd, hash, hmac }) return await createItem(parent, item, { me, models, lnd })
} }
}, },
upsertDiscussion: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { upsertDiscussion: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(discussionSchema, item, { models, me }) await ssValidate(discussionSchema, item, { models, me })
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac }) return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else { } else {
return await createItem(parent, item, { me, models, lnd, hash, hmac }) return await createItem(parent, item, { me, models, lnd })
} }
}, },
upsertBounty: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { upsertBounty: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(bountySchema, item, { models, me }) await ssValidate(bountySchema, item, { models, me })
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac }) return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else { } else {
return await createItem(parent, item, { me, models, lnd, hash, hmac }) return await createItem(parent, item, { me, models, lnd })
} }
}, },
upsertPoll: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { upsertPoll: async (parent, { id, ...item }, { me, models, lnd }) => {
const numExistingChoices = id const numExistingChoices = id
? await models.pollOption.count({ ? await models.pollOption.count({
where: { where: {
@ -777,13 +777,13 @@ export default {
await ssValidate(pollSchema, item, { models, me, numExistingChoices }) await ssValidate(pollSchema, item, { models, me, numExistingChoices })
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac }) return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else { } else {
item.pollCost = item.pollCost || POLL_COST item.pollCost = item.pollCost || POLL_COST
return await createItem(parent, item, { me, models, lnd, hash, hmac }) return await createItem(parent, item, { me, models, lnd })
} }
}, },
upsertJob: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { upsertJob: async (parent, { id, ...item }, { me, models, lnd }) => {
if (!me) { if (!me) {
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
} }
@ -797,18 +797,18 @@ export default {
item.maxBid ??= 0 item.maxBid ??= 0
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac }) return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else { } else {
return await createItem(parent, item, { me, models, lnd, hash, hmac }) return await createItem(parent, item, { me, models, lnd })
} }
}, },
upsertComment: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { upsertComment: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(commentSchema, item) await ssValidate(commentSchema, item)
if (id) { if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac }) return await updateItem(parent, { id, ...item }, { me, models, lnd })
} else { } else {
item = await createItem(parent, item, { me, models, lnd, hash, hmac }) item = await createItem(parent, item, { me, models, lnd })
return item return item
} }
}, },
@ -824,14 +824,14 @@ export default {
return { id, noteId } return { id, noteId }
}, },
pollVote: async (parent, { id, hash, hmac }, { me, models, lnd }) => { pollVote: async (parent, { id }, { me, models, lnd }) => {
if (!me) { if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
} }
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd, hash, hmac }) return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd })
}, },
act: async (parent, { id, sats, act = 'TIP', idempotent, hash, hmac }, { me, models, lnd, headers }) => { act: async (parent, { id, sats, act = 'TIP', idempotent }, { me, models, lnd, headers }) => {
assertApiKeyNotPermitted({ me }) assertApiKeyNotPermitted({ me })
await ssValidate(actSchema, { sats, act }) await ssValidate(actSchema, { sats, act })
await assertGofacYourself({ models, headers }) await assertGofacYourself({ models, headers })
@ -865,9 +865,9 @@ export default {
} }
if (act === 'TIP') { if (act === 'TIP') {
return await performPaidAction('ZAP', { id, sats }, { me, models, lnd, hash, hmac }) return await performPaidAction('ZAP', { id, sats }, { me, models, lnd })
} else if (act === 'DONT_LIKE_THIS') { } else if (act === 'DONT_LIKE_THIS') {
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd, hash, hmac }) return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
} else { } else {
throw new GraphQLError('unknown act', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('unknown act', { extensions: { code: 'BAD_INPUT' } })
} }
@ -1213,7 +1213,7 @@ export default {
} }
} }
export const updateItem = async (parent, { sub: subName, forward, ...item }, { me, models, lnd, hash, hmac }) => { export const updateItem = async (parent, { sub: subName, forward, ...item }, { me, models, lnd }) => {
// update iff this item belongs to me // update iff this item belongs to me
const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } }) const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } })
@ -1276,13 +1276,13 @@ export const updateItem = async (parent, { sub: subName, forward, ...item }, { m
} }
item.uploadIds = uploadIdsFromText(item.text, { models }) item.uploadIds = uploadIdsFromText(item.text, { models })
const resultItem = await performPaidAction('ITEM_UPDATE', item, { models, me, lnd, hash, hmac }) const resultItem = await performPaidAction('ITEM_UPDATE', item, { models, me, lnd })
resultItem.comments = [] resultItem.comments = []
return resultItem return resultItem
} }
export const createItem = async (parent, { forward, ...item }, { me, models, lnd, hash, hmac }) => { export const createItem = async (parent, { forward, ...item }, { me, models, lnd }) => {
// rename to match column name // rename to match column name
item.subName = item.sub item.subName = item.sub
delete item.sub delete item.sub
@ -1307,7 +1307,7 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd
// mark item as created with API key // mark item as created with API key
item.apiKey = me?.apiKey item.apiKey = me?.apiKey
const resultItem = await performPaidAction('ITEM_CREATE', item, { models, me, lnd, hash, hmac }) const resultItem = await performPaidAction('ITEM_CREATE', item, { models, me, lnd })
resultItem.comments = [] resultItem.comments = []
return resultItem return resultItem

View File

@ -1,6 +1,44 @@
import { retryPaidAction } from '../paidAction' import { retryPaidAction } from '../paidAction'
import { USER_ID } from '@/lib/constants'
function paidActionType (actionType) {
switch (actionType) {
case 'ITEM_CREATE':
case 'ITEM_UPDATE':
return 'ItemPaidAction'
case 'ZAP':
case 'DOWN_ZAP':
return 'ItemActPaidAction'
case 'TERRITORY_CREATE':
case 'TERRITORY_UPDATE':
case 'TERRITORY_BILLING':
case 'TERRITORY_UNARCHIVE':
return 'SubPaidAction'
case 'DONATE':
return 'DonatePaidAction'
case 'POLL_VOTE':
return 'PollVotePaidAction'
default:
throw new Error('Unknown action type')
}
}
export default { export default {
Query: {
paidAction: async (parent, { invoiceId }, { models, me }) => {
const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me?.id ?? USER_ID.anon } })
if (!invoice) {
throw new Error('Invoice not found')
}
return {
type: paidActionType(invoice.actionType),
invoice,
result: invoice.actionResult,
paymentMethod: invoice.preimage ? 'PESSIMISTIC' : 'OPTIMISTIC'
}
}
},
Mutation: { Mutation: {
retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => { retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => {
if (!me) { if (!me) {
@ -12,24 +50,11 @@ export default {
throw new Error('Invoice not found') throw new Error('Invoice not found')
} }
let type
if (invoice.actionType === 'ITEM_CREATE') {
type = 'ItemPaidAction'
} else if (invoice.actionType === 'ZAP') {
type = 'ItemActPaidAction'
} else if (invoice.actionType === 'POLL_VOTE') {
type = 'PollVotePaidAction'
} else if (invoice.actionType === 'DOWN_ZAP') {
type = 'ItemActPaidAction'
} else {
throw new Error('Unknown action type')
}
const result = await retryPaidAction(invoice.actionType, { invoiceId }, { models, me, lnd }) const result = await retryPaidAction(invoice.actionType, { invoiceId }, { models, me, lnd })
return { return {
...result, ...result,
type type: paidActionType(invoice.actionType)
} }
} }
}, },

View File

@ -160,10 +160,10 @@ export default {
} }
}, },
Mutation: { Mutation: {
donateToRewards: async (parent, { sats, hash, hmac }, { me, models, lnd }) => { donateToRewards: async (parent, { sats }, { me, models, lnd }) => {
await ssValidate(amountSchema, { amount: sats }) await ssValidate(amountSchema, { amount: sats })
return await performPaidAction('DONATE', { sats }, { me, models, lnd, hash, hmac }) return await performPaidAction('DONATE', { sats }, { me, models, lnd })
} }
}, },
Reward: { Reward: {

View File

@ -152,7 +152,7 @@ export default {
} }
}, },
Mutation: { Mutation: {
upsertSub: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => { upsertSub: async (parent, { ...data }, { me, models, lnd }) => {
if (!me) { if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
} }
@ -160,12 +160,12 @@ export default {
await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } }) await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } })
if (data.oldName) { if (data.oldName) {
return await updateSub(parent, data, { me, models, lnd, hash, hmac }) return await updateSub(parent, data, { me, models, lnd })
} else { } else {
return await createSub(parent, data, { me, models, lnd, hash, hmac }) return await createSub(parent, data, { me, models, lnd })
} }
}, },
paySub: async (parent, { name, hash, hmac }, { me, models, lnd }) => { paySub: async (parent, { name }, { me, models, lnd }) => {
// check that they own the sub // check that they own the sub
const sub = await models.sub.findUnique({ const sub = await models.sub.findUnique({
where: { where: {
@ -185,7 +185,7 @@ export default {
return sub return sub
} }
return await performPaidAction('TERRITORY_BILLING', { name }, { me, models, lnd, hash, hmac }) return await performPaidAction('TERRITORY_BILLING', { name }, { me, models, lnd })
}, },
toggleMuteSub: async (parent, { name }, { me, models }) => { toggleMuteSub: async (parent, { name }, { me, models }) => {
if (!me) { if (!me) {
@ -253,7 +253,7 @@ export default {
return updatedSub return updatedSub
}, },
unarchiveTerritory: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => { unarchiveTerritory: async (parent, { ...data }, { me, models, lnd }) => {
if (!me) { if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
} }
@ -276,7 +276,7 @@ export default {
throw new GraphQLError('sub should not be archived', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('sub should not be archived', { extensions: { code: 'BAD_INPUT' } })
} }
return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd, hash, hmac }) return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd })
} }
}, },
Sub: { Sub: {
@ -314,9 +314,9 @@ export default {
} }
} }
async function createSub (parent, data, { me, models, lnd, hash, hmac }) { async function createSub (parent, data, { me, models, lnd }) {
try { try {
return await performPaidAction('TERRITORY_CREATE', data, { me, models, lnd, hash, hmac }) return await performPaidAction('TERRITORY_CREATE', data, { me, models, lnd })
} catch (error) { } catch (error) {
if (error.code === 'P2002') { if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
@ -325,7 +325,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
} }
} }
async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash, hmac }) { async function updateSub (parent, { oldName, ...data }, { me, models, lnd }) {
const oldSub = await models.sub.findUnique({ const oldSub = await models.sub.findUnique({
where: { where: {
name: oldName, name: oldName,
@ -343,7 +343,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash,
} }
try { try {
return await performPaidAction('TERRITORY_UPDATE', { oldName, ...data }, { me, models, lnd, hash, hmac }) return await performPaidAction('TERRITORY_UPDATE', { oldName, ...data }, { me, models, lnd })
} catch (error) { } catch (error) {
if (error.code === 'P2002') { if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })

View File

@ -1,6 +1,6 @@
import { getIdentity, createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, authenticatedLndGrpc, deletePayment, getPayment } from 'ln-service' import { getIdentity, createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, authenticatedLndGrpc, deletePayment, getPayment } from 'ln-service'
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import crypto from 'crypto' import crypto, { timingSafeEqual } from 'crypto'
import serialize from './serial' import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item' import { SELECT, itemQueryWithMeta } from './item'
@ -379,7 +379,7 @@ export default {
sendToLnAddr, sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => { cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
const hmac2 = createHmac(hash) const hmac2 = createHmac(hash)
if (hmac !== hmac2) { if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
} }
await cancelHodlInvoice({ id: hash, lnd }) await cancelHodlInvoice({ id: hash, lnd })

View File

@ -35,16 +35,16 @@ export default gql`
pinItem(id: ID): Item pinItem(id: ID): Item
subscribeItem(id: ID): Item subscribeItem(id: ID): Item
deleteItem(id: ID): Item deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): ItemPaidAction! upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): ItemPaidAction! upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction! upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, boost: Int, forward: [ItemForwardInput]): ItemPaidAction!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): ItemPaidAction! text: String!, url: String!, maxBid: Int!, status: String, logo: Int): ItemPaidAction!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String, pollExpiresAt: Date): ItemPaidAction! upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date): ItemPaidAction!
updateNoteId(id: ID!, noteId: String!): Item! updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): ItemPaidAction! upsertComment(id:ID, text: String!, parentId: ID): ItemPaidAction!
act(id: ID!, sats: Int, act: String, idempotent: Boolean, hash: String, hmac: String): ItemActPaidAction! act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction!
pollVote(id: ID!, hash: String, hmac: String): PollVotePaidAction! pollVote(id: ID!): PollVotePaidAction!
toggleOutlaw(id: ID!): Item! toggleOutlaw(id: ID!): Item!
} }

View File

@ -2,6 +2,10 @@ import { gql } from 'graphql-tag'
export default gql` export default gql`
extend type Query {
paidAction(invoiceId: Int!): PaidAction
}
extend type Mutation { extend type Mutation {
retryPaidAction(invoiceId: Int!): PaidAction! retryPaidAction(invoiceId: Int!): PaidAction!
} }

View File

@ -7,11 +7,11 @@ export default gql`
} }
extend type Mutation { extend type Mutation {
donateToRewards(sats: Int!, hash: String, hmac: String): DonatePaidAction! donateToRewards(sats: Int!): DonatePaidAction!
} }
type DonateResult { type DonateResult {
sats: Int sats: Int!
} }
type Rewards { type Rewards {

View File

@ -18,15 +18,15 @@ export default gql`
upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!, upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!, postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!, billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): SubPaidAction! moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
paySub(name: String!, hash: String, hmac: String): SubPaidAction! paySub(name: String!): SubPaidAction!
toggleMuteSub(name: String!): Boolean! toggleMuteSub(name: String!): Boolean!
toggleSubSubscription(name: String!): Boolean! toggleSubSubscription(name: String!): Boolean!
transferTerritory(subName: String!, userName: String!): Sub transferTerritory(subName: String!, userName: String!): Sub
unarchiveTerritory(name: String!, desc: String, baseCost: Int!, unarchiveTerritory(name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!, postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!, billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): SubPaidAction! moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
} }
type Sub { type Sub {

View File

@ -76,6 +76,7 @@ export default gql`
confirmedPreimage: String confirmedPreimage: String
actionState: String actionState: String
actionType: String actionType: String
actionError: String
item: Item item: Item
itemAct: ItemAct itemAct: ItemAct
} }

View File

@ -14,7 +14,11 @@ import Item from './item'
import { CommentFlat } from './comment' import { CommentFlat } from './comment'
import classNames from 'classnames' import classNames from 'classnames'
export default function Invoice ({ id, query = INVOICE, modal, onPayment, info, successVerb, webLn = true, webLnError, poll, waitFor, ...props }) { export default function Invoice ({
id, query = INVOICE, modal, onPayment, onCanceled,
info, successVerb, webLn = true, webLnError,
poll, waitFor, ...props
}) {
const [expired, setExpired] = useState(false) const [expired, setExpired] = useState(false)
const { data, error } = useQuery(query, SSR const { data, error } = useQuery(query, SSR
? {} ? {}
@ -34,6 +38,9 @@ export default function Invoice ({ id, query = INVOICE, modal, onPayment, info,
if (waitFor?.(invoice)) { if (waitFor?.(invoice)) {
onPayment?.(invoice) onPayment?.(invoice)
} }
if (invoice.cancelled || invoice.actionError) {
onCanceled?.(invoice)
}
setExpired(new Date(invoice.expiredAt) <= new Date()) setExpired(new Date(invoice.expiredAt) <= new Date())
}, [invoice, onPayment, setExpired]) }, [invoice, onPayment, setExpired])

View File

@ -61,7 +61,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
const act = useAct() const act = useAct()
const strike = useLightning() const strike = useLightning()
const onSubmit = useCallback(async ({ amount, hash, hmac }) => { const onSubmit = useCallback(async ({ amount }) => {
if (abortSignal && zapUndoTrigger({ me, amount })) { if (abortSignal && zapUndoTrigger({ me, amount })) {
onClose?.() onClose?.()
try { try {
@ -76,9 +76,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
variables: { variables: {
id: item.id, id: item.id,
sats: Number(amount), sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP', act: down ? 'DONT_LIKE_THIS' : 'TIP'
hash,
hmac
}, },
optimisticResponse: me optimisticResponse: me
? { ? {

View File

@ -9,9 +9,10 @@ import { useFeeButton } from './fee-button'
import { useShowModal } from './modal' import { useShowModal } from './modal'
export class InvoiceCanceledError extends Error { export class InvoiceCanceledError extends Error {
constructor (hash) { constructor (hash, actionError) {
super(`invoice canceled: ${hash}`) super(actionError ?? `invoice canceled: ${hash}`)
this.name = 'InvoiceCanceledError' this.name = 'InvoiceCanceledError'
this.hash = hash
} }
} }
@ -65,10 +66,10 @@ export const useInvoice = () => {
if (error) { if (error) {
throw error throw error
} }
const { hash, cancelled } = data.invoice const { hash, cancelled, actionError } = data.invoice
if (cancelled) { if (cancelled || actionError) {
throw new InvoiceCanceledError(hash) throw new InvoiceCanceledError(hash, actionError)
} }
return that(data.invoice) return that(data.invoice)
@ -187,6 +188,7 @@ export const useQrPayment = () => {
webLn={false} webLn={false}
webLnError={webLnError} webLnError={webLnError}
waitFor={waitFor} waitFor={waitFor}
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }}
onPayment={() => { paid = true; onClose(); resolve() }} onPayment={() => { paid = true; onClose(); resolve() }}
poll poll
/>, />,

View File

@ -1,6 +1,7 @@
import { useApolloClient, useMutation } from '@apollo/client' import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, useInvoice, useQrPayment, useWebLnPayment } from './payment' import { InvoiceCanceledError, InvoiceExpiredError, useQrPayment, useWebLnPayment } from './payment'
import { GET_PAID_ACTION } from '@/fragments/paidAction'
/* /*
this is just like useMutation with a few changes: this is just like useMutation with a few changes:
@ -18,18 +19,20 @@ export function usePaidMutation (mutation,
{ onCompleted, ...options } = {}) { { onCompleted, ...options } = {}) {
options.optimisticResponse = addOptimisticResponseExtras(options.optimisticResponse) options.optimisticResponse = addOptimisticResponseExtras(options.optimisticResponse)
const [mutate, result] = useMutation(mutation, options) const [mutate, result] = useMutation(mutation, options)
const [getPaidAction] = useLazyQuery(GET_PAID_ACTION, {
fetchPolicy: 'network-only'
})
const waitForWebLnPayment = useWebLnPayment() const waitForWebLnPayment = useWebLnPayment()
const waitForQrPayment = useQrPayment() const waitForQrPayment = useQrPayment()
const invoiceWaiter = useInvoice()
const client = useApolloClient() const client = useApolloClient()
// innerResult is used to store/control the result of the mutation when innerMutate runs // innerResult is used to store/control the result of the mutation when innerMutate runs
const [innerResult, setInnerResult] = useState(result) const [innerResult, setInnerResult] = useState(result)
const waitForPayment = useCallback(async (invoice, persistOnNavigate = false) => { const waitForPayment = useCallback(async (invoice, { persistOnNavigate = false, waitFor }) => {
let webLnError let webLnError
const start = Date.now() const start = Date.now()
try { try {
return await waitForWebLnPayment(invoice) return await waitForWebLnPayment(invoice, waitFor)
} catch (err) { } catch (err) {
if (Date.now() - start > 1000 || err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) { if (Date.now() - start > 1000 || err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
// bail since qr code payment will also fail // bail since qr code payment will also fail
@ -38,7 +41,7 @@ export function usePaidMutation (mutation,
} }
webLnError = err webLnError = err
} }
return await waitForQrPayment(invoice, webLnError, { persistOnNavigate }) return await waitForQrPayment(invoice, webLnError, { persistOnNavigate, waitFor })
}, [waitForWebLnPayment, waitForQrPayment]) }, [waitForWebLnPayment, waitForQrPayment])
const innerMutate = useCallback(async ({ const innerMutate = useCallback(async ({
@ -48,7 +51,7 @@ export function usePaidMutation (mutation,
let { data, ...rest } = await mutate(innerOptions) let { data, ...rest } = await mutate(innerOptions)
// use the most inner callbacks/options if they exist // use the most inner callbacks/options if they exist
const { onPaid, onPayError, forceWaitForPayment, persistOnNavigate } = { ...options, ...innerOptions } const { onPaid, onPayError, forceWaitForPayment, persistOnNavigate, update } = { ...options, ...innerOptions }
const ourOnCompleted = innerOnCompleted || onCompleted const ourOnCompleted = innerOnCompleted || onCompleted
// get invoice without knowing the mutation name // get invoice without knowing the mutation name
@ -65,7 +68,7 @@ export function usePaidMutation (mutation,
// onCompleted is called before the invoice is paid for optimistic updates // onCompleted is called before the invoice is paid for optimistic updates
ourOnCompleted?.(data) ourOnCompleted?.(data)
// don't wait to pay the invoice // don't wait to pay the invoice
waitForPayment(invoice, persistOnNavigate).then(() => { waitForPayment(invoice, { persistOnNavigate }).then(() => {
onPaid?.(client.cache, { data }) onPaid?.(client.cache, { data })
}).catch(e => { }).catch(e => {
console.error('usePaidMutation: failed to pay invoice', e) console.error('usePaidMutation: failed to pay invoice', e)
@ -75,30 +78,24 @@ export function usePaidMutation (mutation,
setInnerResult(r => ({ payError: e, ...r })) setInnerResult(r => ({ payError: e, ...r }))
}) })
} else { } else {
// the action is pessimistic
try { try {
// wait for the invoice to be held // wait for the invoice to be paid
await waitForPayment(invoice, persistOnNavigate); await waitForPayment(invoice, { persistOnNavigate, waitFor: inv => inv?.actionState === 'PAID' })
// and the mutation to complete if (!response.result) {
({ data, ...rest } = await mutate({ // if the mutation didn't return any data, ie pessimistic, we need to fetch it
...innerOptions, const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
variables: { // create new data object
...options.variables, data = { [Object.keys(data)[0]]: paidAction }
...innerOptions.variables, // we need to run update functions on mutations now that we have the data
hmac: invoice.hmac, update?.(client.cache, { data })
hash: invoice.hash }
}
}))
ourOnCompleted?.(data) ourOnCompleted?.(data)
onPaid?.(client.cache, { data }) onPaid?.(client.cache, { data })
// block until the invoice to be marked as paid
// for pessimisitic actions, they won't show up on navigation until they are marked as paid
await invoiceWaiter.waitUntilPaid(invoice, inv => inv?.actionState === 'PAID')
} catch (e) { } catch (e) {
console.error('usePaidMutation: failed to pay invoice', e) console.error('usePaidMutation: failed to pay invoice', e)
onPayError?.(e, client.cache, { data }) onPayError?.(e, client.cache, { data })
rest = { payError: e, ...rest } rest = { ...rest, payError: e, error: e }
} finally {
invoiceWaiter.stopWaiting()
} }
} }
} else { } else {
@ -109,7 +106,7 @@ export function usePaidMutation (mutation,
setInnerResult({ data, ...rest }) setInnerResult({ data, ...rest })
return { data, ...rest } return { data, ...rest }
}, [mutate, options, waitForPayment, onCompleted, client.cache]) }, [mutate, options, waitForPayment, onCompleted, client.cache, getPaidAction, setInnerResult])
return [innerMutate, innerResult] return [innerMutate, innerResult]
} }

View File

@ -37,6 +37,8 @@ services:
CONNECT: "localhost:5431" CONNECT: "localhost:5431"
app: app:
container_name: app container_name: app
stdin_open: true
tty: true
build: build:
context: ./ context: ./
args: args:

View File

@ -36,6 +36,39 @@ const ITEM_ACT_PAID_ACTION_FIELDS = gql`
} }
}` }`
export const GET_PAID_ACTION = gql`
${PAID_ACTION}
${ITEM_PAID_ACTION_FIELDS}
${ITEM_ACT_PAID_ACTION_FIELDS}
${SUB_FULL_FIELDS}
query paidAction($invoiceId: Int!) {
paidAction(invoiceId: $invoiceId) {
__typename
...PaidActionFields
... on ItemPaidAction {
...ItemPaidActionFields
}
... on ItemActPaidAction {
...ItemActPaidActionFields
}
... on PollVotePaidAction {
result {
id
}
}
... on SubPaidAction {
result {
...SubFullFields
}
}
... on DonatePaidAction {
result {
sats
}
}
}
}`
export const RETRY_PAID_ACTION = gql` export const RETRY_PAID_ACTION = gql`
${PAID_ACTION} ${PAID_ACTION}
${ITEM_PAID_ACTION_FIELDS} ${ITEM_PAID_ACTION_FIELDS}
@ -60,8 +93,8 @@ export const RETRY_PAID_ACTION = gql`
export const DONATE = gql` export const DONATE = gql`
${PAID_ACTION} ${PAID_ACTION}
mutation donateToRewards($sats: Int!, $hash: String, $hmac: String) { mutation donateToRewards($sats: Int!) {
donateToRewards(sats: $sats, hash: $hash, hmac: $hmac) { donateToRewards(sats: $sats) {
result { result {
sats sats
} }
@ -72,8 +105,8 @@ export const DONATE = gql`
export const ACT_MUTATION = gql` export const ACT_MUTATION = gql`
${PAID_ACTION} ${PAID_ACTION}
${ITEM_ACT_PAID_ACTION_FIELDS} ${ITEM_ACT_PAID_ACTION_FIELDS}
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) { mutation act($id: ID!, $sats: Int!, $act: String) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) { act(id: $id, sats: $sats, act: $act) {
...ItemActPaidActionFields ...ItemActPaidActionFields
...PaidActionFields ...PaidActionFields
} }
@ -82,9 +115,9 @@ export const ACT_MUTATION = gql`
export const UPSERT_DISCUSSION = gql` export const UPSERT_DISCUSSION = gql`
${PAID_ACTION} ${PAID_ACTION}
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String,
$boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) { $boost: Int, $forward: [ItemForwardInput]) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost,
forward: $forward, hash: $hash, hmac: $hmac) { forward: $forward) {
result { result {
id id
deleteScheduledAt deleteScheduledAt
@ -98,10 +131,10 @@ export const UPSERT_JOB = gql`
${PAID_ACTION} ${PAID_ACTION}
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!,
$location: String, $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $location: String, $remote: Boolean, $text: String!, $url: String!, $maxBid: Int!,
$status: String, $logo: Int, $hash: String, $hmac: String) { $status: String, $logo: Int) {
upsertJob(sub: $sub, id: $id, title: $title, company: $company, upsertJob(sub: $sub, id: $id, title: $title, company: $company,
location: $location, remote: $remote, text: $text, location: $location, remote: $remote, text: $text,
url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) { url: $url, maxBid: $maxBid, status: $status, logo: $logo) {
result { result {
id id
deleteScheduledAt deleteScheduledAt
@ -114,9 +147,9 @@ export const UPSERT_JOB = gql`
export const UPSERT_LINK = gql` export const UPSERT_LINK = gql`
${PAID_ACTION} ${PAID_ACTION}
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!,
$text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) { $text: String, $boost: Int, $forward: [ItemForwardInput]) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text, upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text,
boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { boost: $boost, forward: $forward) {
result { result {
id id
deleteScheduledAt deleteScheduledAt
@ -129,11 +162,9 @@ export const UPSERT_LINK = gql`
export const UPSERT_POLL = gql` export const UPSERT_POLL = gql`
${PAID_ACTION} ${PAID_ACTION}
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
$options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $hash: String, $options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $pollExpiresAt: Date) {
$hmac: String, $pollExpiresAt: Date) {
upsertPoll(sub: $sub, id: $id, title: $title, text: $text, upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward, hash: $hash, options: $options, boost: $boost, forward: $forward, pollExpiresAt: $pollExpiresAt) {
hmac: $hmac, pollExpiresAt: $pollExpiresAt) {
result { result {
id id
deleteScheduledAt deleteScheduledAt
@ -146,9 +177,9 @@ export const UPSERT_POLL = gql`
export const UPSERT_BOUNTY = gql` export const UPSERT_BOUNTY = gql`
${PAID_ACTION} ${PAID_ACTION}
mutation upsertBounty($sub: String, $id: ID, $title: String!, $bounty: Int!, mutation upsertBounty($sub: String, $id: ID, $title: String!, $bounty: Int!,
$text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) { $text: String, $boost: Int, $forward: [ItemForwardInput]) {
upsertBounty(sub: $sub, id: $id, title: $title, bounty: $bounty, text: $text, upsertBounty(sub: $sub, id: $id, title: $title, bounty: $bounty, text: $text,
boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { boost: $boost, forward: $forward) {
result { result {
id id
deleteScheduledAt deleteScheduledAt
@ -160,8 +191,8 @@ export const UPSERT_BOUNTY = gql`
export const POLL_VOTE = gql` export const POLL_VOTE = gql`
${PAID_ACTION} ${PAID_ACTION}
mutation pollVote($id: ID!, $hash: String, $hmac: String) { mutation pollVote($id: ID!) {
pollVote(id: $id, hash: $hash, hmac: $hmac) { pollVote(id: $id) {
result { result {
id id
} }
@ -172,8 +203,8 @@ export const POLL_VOTE = gql`
export const CREATE_COMMENT = gql` export const CREATE_COMMENT = gql`
${ITEM_PAID_ACTION_FIELDS} ${ITEM_PAID_ACTION_FIELDS}
${PAID_ACTION} ${PAID_ACTION}
mutation upsertComment($text: String!, $parentId: ID!, $hash: String, $hmac: String) { mutation upsertComment($text: String!, $parentId: ID!) {
upsertComment(text: $text, parentId: $parentId, hash: $hash, hmac: $hmac) { upsertComment(text: $text, parentId: $parentId) {
...ItemPaidActionFields ...ItemPaidActionFields
...PaidActionFields ...PaidActionFields
} }
@ -182,8 +213,8 @@ export const CREATE_COMMENT = gql`
export const UPDATE_COMMENT = gql` export const UPDATE_COMMENT = gql`
${ITEM_PAID_ACTION_FIELDS} ${ITEM_PAID_ACTION_FIELDS}
${PAID_ACTION} ${PAID_ACTION}
mutation upsertComment($id: ID!, $text: String!, $hash: String, $hmac: String) { mutation upsertComment($id: ID!, $text: String!) {
upsertComment(id: $id, text: $text, hash: $hash, hmac: $hmac) { upsertComment(id: $id, text: $text) {
...ItemPaidActionFields ...ItemPaidActionFields
...PaidActionFields ...PaidActionFields
} }
@ -193,10 +224,10 @@ export const UPSERT_SUB = gql`
${PAID_ACTION} ${PAID_ACTION}
mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!, mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!, $postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) { $billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) {
upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost, upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType, postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) { billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) {
result { result {
name name
} }
@ -208,10 +239,10 @@ export const UNARCHIVE_TERRITORY = gql`
${PAID_ACTION} ${PAID_ACTION}
mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!, mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!, $postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) { $billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) {
unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost, unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType, postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) { billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) {
result { result {
name name
} }
@ -222,8 +253,8 @@ export const UNARCHIVE_TERRITORY = gql`
export const SUB_PAY = gql` export const SUB_PAY = gql`
${SUB_FULL_FIELDS} ${SUB_FULL_FIELDS}
${PAID_ACTION} ${PAID_ACTION}
mutation paySub($name: String!, $hash: String, $hmac: String) { mutation paySub($name: String!) {
paySub(name: $name, hash: $hash, hmac: $hmac) { paySub(name: $name) {
result { result {
...SubFullFields ...SubFullFields
} }

View File

@ -19,6 +19,7 @@ export const INVOICE_FIELDS = gql`
confirmedPreimage confirmedPreimage
actionState actionState
actionType actionType
actionError
}` }`
export const INVOICE_FULL = gql` export const INVOICE_FULL = gql`

View File

@ -172,12 +172,10 @@ export function DonateButton () {
amount: 10000 amount: 10000
}} }}
schema={amountSchema} schema={amountSchema}
onSubmit={async ({ amount, hash, hmac }) => { onSubmit={async ({ amount }) => {
const { error } = await donateToRewards({ const { error } = await donateToRewards({
variables: { variables: {
sats: Number(amount), sats: Number(amount)
hash,
hmac
}, },
onCompleted: () => { onCompleted: () => {
strike() strike()

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "actionArgs" JSONB,
ADD COLUMN "actionError" TEXT,
ADD COLUMN "actionResult" JSONB;

View File

@ -782,6 +782,9 @@ model Invoice {
actionState InvoiceActionState? actionState InvoiceActionState?
actionType InvoiceActionType? actionType InvoiceActionType?
actionId Int? actionId Int?
actionArgs Json? @db.JsonB
actionError String?
actionResult Json? @db.JsonB
ItemAct ItemAct[] ItemAct ItemAct[]
Item Item[] Item Item[]
Upload Upload[] Upload Upload[]

View File

@ -1,16 +1,21 @@
import { paidActions } from '@/api/paidAction' import { paidActions } from '@/api/paidAction'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { getInvoice } from 'ln-service' import { getInvoice, settleHodlInvoice } from 'ln-service'
async function transitionInvoice (jobName, { invoiceId, fromState, toState, toData, onTransition }, { models, lnd, boss }) { async function transitionInvoice (jobName, { invoiceId, fromState, toState, toData, onTransition }, { models, lnd, boss }) {
console.group(`${jobName}: transitioning invoice ${invoiceId} from ${fromState} to ${toState}`) console.group(`${jobName}: transitioning invoice ${invoiceId} from ${fromState} to ${toState}`)
let dbInvoice let dbInvoice
try { try {
console.log('fetching invoice from db')
dbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } }) dbInvoice = await models.invoice.findUnique({ where: { id: invoiceId } })
console.log('invoice is in state', dbInvoice.actionState)
if (['FAILED', 'PAID'].includes(dbInvoice.actionState)) {
console.log('invoice is already in a terminal state, skipping transition')
return
}
const lndInvoice = await getInvoice({ id: dbInvoice.hash, lnd }) const lndInvoice = await getInvoice({ id: dbInvoice.hash, lnd })
const data = toData(lndInvoice) const data = toData(lndInvoice)
@ -18,10 +23,9 @@ async function transitionInvoice (jobName, { invoiceId, fromState, toState, toDa
fromState = [fromState] fromState = [fromState]
} }
console.log('invoice is in state', dbInvoice.actionState)
await models.$transaction(async tx => { await models.$transaction(async tx => {
dbInvoice = await tx.invoice.update({ dbInvoice = await tx.invoice.update({
include: { user: true },
where: { where: {
id: invoiceId, id: invoiceId,
actionState: { actionState: {
@ -105,9 +109,36 @@ export async function holdAction ({ data: { invoiceId }, models, lnd, boss }) {
onTransition: async ({ dbInvoice, tx }) => { onTransition: async ({ dbInvoice, tx }) => {
// make sure settled or cancelled in 60 seconds to minimize risk of force closures // make sure settled or cancelled in 60 seconds to minimize risk of force closures
const expiresAt = new Date(Math.min(dbInvoice.expiresAt, datePivot(new Date(), { seconds: 60 }))) const expiresAt = new Date(Math.min(dbInvoice.expiresAt, datePivot(new Date(), { seconds: 60 })))
await tx.$executeRaw` // do outside of transaction because we don't want this to rollback if the rest of the job fails
await models.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('finalizeHodlInvoice', jsonb_build_object('hash', ${dbInvoice.hash}), 21, true, ${expiresAt})` VALUES ('finalizeHodlInvoice', jsonb_build_object('hash', ${dbInvoice.hash}), 21, true, ${expiresAt})`
// perform the action now that we have the funds
try {
const args = { ...dbInvoice.actionArgs, invoiceId: dbInvoice.id }
const result = await paidActions[dbInvoice.actionType].perform(args,
{ models, tx, lnd, cost: dbInvoice.msatsReceived, me: dbInvoice.user })
await tx.invoice.update({
where: { id: dbInvoice.id },
data: {
actionResult: result
}
})
} catch (e) {
// store the error in the invoice, nonblocking and outside of this tx, finalizing immediately
models.invoice.update({
where: { id: dbInvoice.id },
data: {
actionError: e.message
}
}).catch(e => console.error('failed to cancel invoice', e))
boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash })
throw e
}
// settle the invoice, allowing us to transition to PAID
await settleHodlInvoice({ secret: dbInvoice.preimage, lnd })
} }
}, { models, lnd, boss }) }, { models, lnd, boss })
} }

View File

@ -14,6 +14,7 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
async function territoryStatusUpdate () { async function territoryStatusUpdate () {
if (sub.status !== 'STOPPED') { if (sub.status !== 'STOPPED') {
await models.sub.update({ await models.sub.update({
include: { user: true },
where: { where: {
name: subName name: subName
}, },
@ -35,7 +36,7 @@ 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: { id: sub.userId }, lnd, forceFeeCredits: true }) { name: subName }, { models, me: sub.user, lnd, forceFeeCredits: true })
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')
} }