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:
parent
7c294478fb
commit
79f0df17b2
@ -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)
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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: {
|
||||||
|
@ -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' } })
|
||||||
|
@ -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 })
|
||||||
|
@ -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!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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!
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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])
|
||||||
|
|
||||||
|
@ -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
|
||||||
? {
|
? {
|
||||||
|
@ -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
|
||||||
/>,
|
/>,
|
||||||
|
@ -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]
|
||||||
}
|
}
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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`
|
||||||
|
@ -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()
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Invoice" ADD COLUMN "actionArgs" JSONB,
|
||||||
|
ADD COLUMN "actionError" TEXT,
|
||||||
|
ADD COLUMN "actionResult" JSONB;
|
@ -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[]
|
||||||
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
@ -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')
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user